Backport MCP from staging (part 1)

This commit is contained in:
Andrey Antukh 2026-04-16 11:30:36 +02:00
parent 69e505a6a2
commit 3a39676969
165 changed files with 5714 additions and 3018 deletions

View File

@ -90,6 +90,8 @@
- Optimize sidebar performance for deeply nested shapes [Taiga #13017](https://tree.taiga.io/project/penpot/task/13017) - 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) - 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) - 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 ### :bug: Bugs fixed

View File

@ -463,8 +463,10 @@
:fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")} :fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")}
{:name "0145-fix-plugins-uri-on-profile" {: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! (defn apply-migrations!
[pool name migrations] [pool name migrations]

View File

@ -0,0 +1,2 @@
ALTER TABLE access_token
ADD COLUMN type text NULL;

View File

@ -23,7 +23,7 @@
(dissoc row :perms)) (dissoc row :perms))
(defn create-access-token (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) (let [token-id (uuid/next)
expires-at (some-> expiration (ct/in-future)) expires-at (some-> expiration (ct/in-future))
created-at (ct/now) created-at (ct/now)
@ -36,6 +36,7 @@
{:id token-id {:id token-id
:name name :name name
:token token :token token
:type type
:profile-id profile-id :profile-id profile-id
:created-at created-at :created-at created-at
:updated-at created-at :updated-at created-at
@ -50,17 +51,18 @@
(def ^:private schema:create-access-token (def ^:private schema:create-access-token
[:map {:title "create-access-token"} [:map {:title "create-access-token"}
[:name [:string {:max 250 :min 1}]] [: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 (sv/defmethod ::create-access-token
{::doc/added "1.18" {::doc/added "1.18"
::sm/params schema:create-access-token} ::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/check! cfg {::quotes/id ::quotes/access-tokens-per-profile
::quotes/profile-id profile-id}) ::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 (def ^:private schema:delete-access-token
[:map {:title "delete-access-token"} [:map {:title "delete-access-token"}
@ -83,5 +85,22 @@
(->> (db/query pool :access-token (->> (db/query pool :access-token
{:profile-id profile-id} {:profile-id profile-id}
{:order-by [[:expires-at :asc] [:created-at :asc]] {: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))) (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)))

View File

@ -48,6 +48,7 @@
(def schema:props (def schema:props
[:map {:title "ProfileProps"} [:map {:title "ProfileProps"}
[:plugins {:optional true} schema:plugin-registry] [:plugins {:optional true} schema:plugin-registry]
[:mcp-enabled {:optional true} ::sm/boolean]
[:newsletter-updates {:optional true} ::sm/boolean] [:newsletter-updates {:optional true} ::sm/boolean]
[:newsletter-news {:optional true} ::sm/boolean] [:newsletter-news {:optional true} ::sm/boolean]
[:onboarding-team-id {:optional true} ::sm/uuid] [:onboarding-team-id {:optional true} ::sm/uuid]

View File

@ -102,7 +102,7 @@
(t/deftest access-token-authz (t/deftest access-token-authz
(let [profile (th/create-profile* 1) (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*)] handler (#'app.http.access-token/wrap-authz identity th/*system*)]
(let [response (handler nil)] (let [response (handler nil)]

View File

@ -107,4 +107,18 @@
;; (th/print-result! out) ;; (th/print-result! out)
(t/is (nil? (:error out))) (t/is (nil? (:error out)))
(let [results (:result 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)))))))

View File

@ -152,7 +152,9 @@
:redis-cache :redis-cache
;; Activates the nitrate module ;; Activates the nitrate module
:nitrate}) :nitrate
:mcp})
(def all-flags (def all-flags
(set/union email login varia)) (set/union email login varia))

View File

@ -13,3 +13,10 @@
unit tests or backend code for logs or error messages." unit tests or backend code for logs or error messages."
[key & _args] [key & _args]
key) 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)

View File

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

View File

@ -242,17 +242,19 @@
(update-token- [this token-id f] (update-token- [this token-id f]
(assert (uuid? token-id) "expected uuid for `token-id`") (assert (uuid? token-id) "expected uuid for `token-id`")
(if-let [token (get-token- this token-id)] (if-let [token (get-token- this token-id)]
(let [token' (-> (make-token (f token)) (let [token' (f token)]
(assoc :modified-at (ct/now)))] (if (not= token token')
(TokenSet. id (let [token' (assoc token' :modified-at (ct/now))]
name (TokenSet. id
description name
(ct/now) description
(if (= (:name token) (:name token')) (ct/now)
(assoc tokens (:name token') token') (if (= (:name token) (:name token'))
(-> tokens (assoc tokens (:name token') token')
(d/oassoc-before (:name token) (:name token') token') (-> tokens
(dissoc (:name token)))))) (d/oassoc-before (:name token) (:name token') token')
(dissoc (:name token))))))
this))
this)) this))
(delete-token- [this token-id] (delete-token- [this token-id]
@ -303,6 +305,35 @@
(-clj->js [this] (-clj->js [this]
(clj->js (datafy 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? (defn token-set?
[o] [o]
(instance? TokenSet o)) (instance? TokenSet o))
@ -357,6 +388,7 @@
(def check-token-set (def check-token-set
(sm/check-fn schema:token-set :hint "expected valid token set")) (sm/check-fn schema:token-set :hint "expected valid token set"))
(defn map->token-set (defn map->token-set
[& {:as attrs}] [& {:as attrs}]
(TokenSet. (:id attrs) (TokenSet. (:id attrs)
@ -372,38 +404,10 @@
(update :modified-at #(or % (ct/now))) (update :modified-at #(or % (ct/now)))
(update :tokens #(into (d/ordered-map) %)) (update :tokens #(into (d/ordered-map) %))
(update :description d/nilv "") (update :description d/nilv "")
(update :name normalize-set-name)
(check-token-set-attrs) (check-token-set-attrs)
(map->token-set))) (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? (defn normalized-set-name?
"Check if a set name is normalized (no extra spaces)." "Check if a set name is normalized (no extra spaces)."
[name] [name]
@ -609,7 +613,7 @@
is-source is-source
external-id external-id
(ct/now) (ct/now)
set-names)) (into #{} (filter some?) set-names)))
(enable-set [this set-name] (enable-set [this set-name]
(set-sets this (conj sets set-name))) (set-sets this (conj sets set-name)))
@ -630,14 +634,9 @@
(update-set-name [this prev-set-name set-name] (update-set-name [this prev-set-name set-name]
(if (get sets prev-set-name) (if (get sets prev-set-name)
(TokenTheme. id (let [sets (-> (disj sets prev-set-name)
name (conj set-name))]
group (set-sets this sets))
description
is-source
external-id
(ct/now)
(conj (disj sets prev-set-name) set-name))
this)) this))
(theme-matches-group-name [this group name] (theme-matches-group-name [this group name]
@ -722,7 +721,8 @@
(update :is-source d/nilv false) (update :is-source d/nilv false)
(update :external-id #(or % (str new-id))) (update :external-id #(or % (str new-id)))
(update :modified-at #(or % (ct/now))) (update :modified-at #(or % (ct/now)))
(update :sets set) (update :sets #(into #{} (comp (filter some?)
(map normalize-set-name)) %))
(check-token-theme-attrs) (check-token-theme-attrs)
(map->TokenTheme)))) (map->TokenTheme))))

View File

@ -8,7 +8,7 @@ localhost:3449 {
header -Strict-Transport-Security header -Strict-Transport-Security
} }
http://localhost:3450 { :3450 {
# For subpath test # For subpath test
# handle_path /penpot/* { # handle_path /penpot/* {
# reverse_proxy localhost:4449 # reverse_proxy localhost:4449

View File

@ -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 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 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 l='ls --color -GFlh'
alias ll='ls --color -GFlh' alias ll='ls --color -GFlh'
alias rm='rm -rf' alias rm='rm -rf'

View File

@ -126,6 +126,12 @@ http {
proxy_http_version 1.1; proxy_http_version 1.1;
} }
location /plugins {
autoindex on;
alias /home/penpot/penpot/plugins/dist/apps;
proxy_http_version 1.1;
}
location /mcp/ws { location /mcp/ws {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade'; proxy_set_header Connection 'upgrade';

View File

@ -5,7 +5,8 @@ ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \
NODE_VERSION=v22.21.1 \ NODE_VERSION=v22.21.1 \
DEBIAN_FRONTEND=noninteractive \ DEBIAN_FRONTEND=noninteractive \
PATH=/opt/node/bin:$PATH PATH=/opt/node/bin:$PATH \
PENPOT_MCP_SERVER_HOST=0.0.0.0
RUN set -ex; \ RUN set -ex; \
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \ useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \

View File

@ -50,7 +50,7 @@
"devDependencies": { "devDependencies": {
"@penpot/draft-js": "workspace:./packages/draft-js", "@penpot/draft-js": "workspace:./packages/draft-js",
"@penpot/mousetrap": "workspace:./packages/mousetrap", "@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/svgo": "penpot/svgo#v3.2",
"@penpot/text-editor": "workspace:./text-editor", "@penpot/text-editor": "workspace:./text-editor",
"@penpot/tokenscript": "workspace:./packages/tokenscript", "@penpot/tokenscript": "workspace:./packages/tokenscript",

View File

@ -20,8 +20,8 @@ importers:
specifier: workspace:./packages/mousetrap specifier: workspace:./packages/mousetrap
version: link:packages/mousetrap version: link:packages/mousetrap
'@penpot/plugins-runtime': '@penpot/plugins-runtime':
specifier: link:../plugins/dist/plugins-runtime specifier: link:../plugins/libs/plugins-runtime
version: link:../plugins/dist/plugins-runtime version: link:../plugins/libs/plugins-runtime
'@penpot/svgo': '@penpot/svgo':
specifier: penpot/svgo#v3.2 specifier: penpot/svgo#v3.2
version: svgo@https://codeload.github.com/penpot/svgo/tar.gz/8c9b0e32e9cb5f106085260bd9375f3c91a5010b version: svgo@https://codeload.github.com/penpot/svgo/tar.gz/8c9b0e32e9cb5f106085260bd9375f3c91a5010b

View File

@ -36,7 +36,7 @@ popd
pushd ../mcp; pushd ../mcp;
rm -rf node_modules; rm -rf node_modules;
./scripts/setup ./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; popd;
pnpm run build:app:main $EXTRA_PARAMS; pnpm run build:app:main $EXTRA_PARAMS;

View File

@ -172,6 +172,10 @@
(normalize-uri (or (obj/get global "penpotPublicURI") (normalize-uri (or (obj/get global "penpotPublicURI")
(obj/get location "origin")))) (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 (def rasterizer-uri
(or (some-> (obj/get global "penpotRasterizerURI") normalize-uri) (or (some-> (obj/get global "penpotRasterizerURI") normalize-uri)
public-uri)) public-uri))
@ -205,6 +209,9 @@
(let [f (obj/get global "initializeExternalConfigInfo")] (let [f (obj/get global "initializeExternalConfigInfo")]
(when (fn? f) (f)))) (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 ;; --- Helper Functions
(defn ^boolean check-browser? [candidate] (defn ^boolean check-browser? [candidate]

View File

@ -57,5 +57,6 @@
[type data] [type data]
(ptk/reify ::event (ptk/reify ::event
ptk/EffectEvent ptk/EffectEvent
(effect [_ _ _] (effect [_ state _]
(emit! type data)))) (let [session-id (get state :session-id)]
(emit! session-id type data)))))

View File

@ -7,12 +7,14 @@
(ns app.main.data.plugins (ns app.main.data.plugins
(:require (:require
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.changes-builder :as pcb] [app.common.files.changes-builder :as pcb]
[app.common.time :as ct] [app.common.time :as ct]
[app.main.data.changes :as dch] [app.main.data.changes :as dch]
[app.main.data.event :as ev] [app.main.data.event :as ev]
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
[app.main.data.notifications :as ntf] [app.main.data.notifications :as ntf]
[app.main.errors :as errors]
[app.main.store :as st] [app.main.store :as st]
[app.plugins.flags :as pflag] [app.plugins.flags :as pflag]
[app.plugins.register :as preg] [app.plugins.register :as preg]
@ -20,7 +22,8 @@
[app.util.http :as http] [app.util.http :as http]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[potok.v2.core :as ptk])) [potok.v2.core :as ptk]
[promesa.core :as p]))
(defn save-plugin-permissions-peek (defn save-plugin-permissions-peek
[id permissions] [id permissions]
@ -52,27 +55,47 @@
(update [_ state] (update [_ state]
(update-in state [:workspace-local :open-plugins] (fnil disj #{}) id)))) (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! (defn- load-plugin!
[{:keys [plugin-id name description host code icon permissions]}] [{:keys [plugin-id name version description host code icon permissions]}]
(try (st/emit! (pflag/clear plugin-id)
(st/emit! (pflag/clear plugin-id) (save-current-plugin plugin-id))
(save-current-plugin plugin-id))
(.ɵloadPlugin (-> (.ɵloadPlugin
^js ug/global ^js ug/global
#js {:pluginId plugin-id #js {:pluginId plugin-id
:name name :name name
:description description :description description
:host host :version version
:code code :host host
:icon icon :code code
:permissions (apply array permissions)} :icon icon
(fn [] :permissions (apply array permissions)}
(st/emit! (remove-current-plugin plugin-id)))) (fn []
(st/emit! (remove-current-plugin plugin-id))))
(catch :default e (p/catch (fn [cause]
(st/emit! (remove-current-plugin plugin-id)) (st/emit! (remove-current-plugin plugin-id))
(.error js/console "Error" e)))) (ex/print-throwable cause :prefix "Plugin Error")
(errors/flash :cause cause :type :handled)))))
(defn open-plugin! (defn open-plugin!
[{:keys [url] :as manifest} user-can-edit?] [{:keys [url] :as manifest} user-can-edit?]

View File

@ -498,4 +498,3 @@
(->> (rp/cmd! :delete-access-token params) (->> (rp/cmd! :delete-access-token params)
(rx/tap on-success) (rx/tap on-success)
(rx/catch on-error)))))) (rx/catch on-error))))))

View File

@ -52,6 +52,7 @@
[app.main.data.workspace.layers :as dwly] [app.main.data.workspace.layers :as dwly]
[app.main.data.workspace.layout :as layout] [app.main.data.workspace.layout :as layout]
[app.main.data.workspace.libraries :as dwl] [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.notifications :as dwn]
[app.main.data.workspace.pages :as dwpg] [app.main.data.workspace.pages :as dwpg]
[app.main.data.workspace.path :as dwdp] [app.main.data.workspace.path :as dwdp]
@ -211,8 +212,11 @@
ptk/WatchEvent ptk/WatchEvent
(watch [_ _ _] (watch [_ _ _]
(rx/of (dp/check-open-plugin) (rx/merge
(fdf/fix-deleted-fonts-for-local-library file-id))))) (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 (defn- bundle-fetched
[{:keys [file file-id thumbnails] :as bundle}] [{:keys [file file-id thumbnails] :as bundle}]
@ -304,163 +308,169 @@
:team-id (dm/str team-id) :team-id (dm/str team-id)
:file-id (dm/str file-id)) :file-id (dm/str file-id))
(->> (rx/merge (rx/concat
(rx/concat (->> (rx/merge
;; Fetch all essential data that should be loaded before the file (rx/concat
(rx/merge ;; Fetch all essential data that should be loaded before the file
(if ^boolean render-wasm? (rx/merge
(->> (rx/from @wasm/module) (if ^boolean render-wasm?
(rx/filter true?) (->> (rx/from @wasm/module)
(rx/tap (fn [_] (rx/filter true?)
(let [event (ug/event "penpot:wasm:loaded")] (rx/tap (fn [_]
(ug/dispatch! event)))) (let [event (ug/event "penpot:wasm:loaded")]
(rx/ignore)) (ug/dispatch! event))))
(rx/empty)) (rx/ignore))
(rx/empty))
(->> stream (->> stream
(rx/filter (ptk/type? ::df/fonts-loaded)) (rx/filter (ptk/type? ::df/fonts-loaded))
(rx/take 1) (rx/take 1)
(rx/ignore)) (rx/ignore))
(rx/of (ntf/hide) (rx/of (ntf/hide)
(dcmt/retrieve-comment-threads file-id) (dcmt/retrieve-comment-threads file-id)
(dcmt/fetch-profiles) (dcmt/fetch-profiles)
(df/fetch-fonts team-id))) (df/fetch-fonts team-id))
;; Once the essential data is fetched, lets proceed to (when (contains? cf/flags :mcp)
;; fetch teh file bunldle (rx/of (du/fetch-access-tokens))))
(rx/of (fetch-bundle file-id features)))
(->> stream ;; Once the essential data is fetched, lets proceed to
(rx/filter (ptk/type? ::bundle-fetched)) ;; fetch teh file bunldle
(rx/take 1) (rx/of (fetch-bundle file-id features)))
(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))
(rx/of (dpj/initialize-project (:project-id file)) (->> stream
(dwn/initialize team-id file-id) (rx/filter (ptk/type? ::bundle-fetched))
(dwsl/initialize-shape-layout) (rx/take 1)
(fetch-libraries file-id features) (rx/map deref)
(-> (workspace-initialized file-id) (rx/mapcat
(with-meta {:team-id team-id (fn [{:keys [file]}]
:file-id file-id})))))) (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 (rx/of (dpj/initialize-project (:project-id file))
(when (contains? cf/flags :perf-logs) (dwn/initialize team-id file-id)
(->> stream (dwsl/initialize-shape-layout)
(rx/filter (ptk/type? ::workspace-initialized)) (fetch-libraries file-id features)
(rx/take 1) (-> (workspace-initialized file-id)
(rx/tap (fn [_] (perf/setup))))) (with-meta {:team-id team-id
:file-id file-id}))))))
(->> stream ;; Install dev perf observers once the workspace is ready
(rx/filter (ptk/type? ::dps/persistence-notification)) (when (contains? cf/flags :perf-logs)
(rx/take 1) (->> stream
(rx/map dwc/set-workspace-visited)) (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
(->> stream (rx/filter (ptk/type? ::dps/persistence-notification))
(rx/filter (ptk/type? ::workspace-initialized)) (rx/take 1)
(rx/observe-on :async) (rx/map dwc/set-workspace-visited))
(rx/take 1)
(rx/map #(dwl/go-to-local-component :id component-id :update-layout? (:update-layout rparams)))))
(when (:board-id rparams) (when-let [component-id (some-> rparams :component-id uuid/parse)]
(->> stream (->> stream
(rx/filter (ptk/type? ::dwv/initialize-viewport)) (rx/filter (ptk/type? ::workspace-initialized))
(rx/take 1) (rx/observe-on :async)
(rx/map zoom-to-frame))) (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)] (when (:board-id rparams)
(->> stream (->> stream
(rx/filter (ptk/type? ::workspace-initialized)) (rx/filter (ptk/type? ::dwv/initialize-viewport))
(rx/observe-on :async) (rx/take 1)
(rx/take 1) (rx/map zoom-to-frame)))
(rx/map #(dwcm/navigate-to-comment-id comment-id))))
(when render-wasm? (when-let [comment-id (some-> rparams :comment-id uuid/parse)]
(->> stream (->> stream
(rx/filter dch/commit?) (rx/filter (ptk/type? ::workspace-initialized))
(rx/map deref) (rx/observe-on :async)
(rx/mapcat (rx/take 1)
(fn [{:keys [redo-changes]}] (rx/map #(dwcm/navigate-to-comment-id comment-id))))
(let [added (->> redo-changes
(filter #(= (:type %) :add-obj))
(map :id))]
(->> (rx/from added)
(rx/map process-wasm-object)))))))
(when render-wasm? (when render-wasm?
(let [local-commits-s (->> stream
(->> stream (rx/filter dch/commit?)
(rx/filter dch/commit?) (rx/map deref)
(rx/map deref) (rx/mapcat
(rx/filter #(and (= :local (:source %)) (fn [{:keys [redo-changes]}]
(not (contains? (:tags %) :position-data)))) (let [added (->> redo-changes
(rx/filter (complement empty?))) (filter #(= (:type %) :add-obj))
(map :id))]
(->> (rx/from added)
(rx/map process-wasm-object)))))))
notifier-s (when render-wasm?
(rx/merge (let [local-commits-s
(->> local-commits-s (rx/debounce 1000)) (->> stream
(->> stream (rx/filter dps/force-persist?))) (rx/filter dch/commit?)
(rx/map deref)
(rx/filter #(and (= :local (:source %))
(not (contains? (:tags %) :position-data))))
(rx/filter (complement empty?)))
objects-s notifier-s
(rx/from-atom refs/workspace-page-objects {:emit-current-value? true}) (rx/merge
(->> local-commits-s (rx/debounce 1000))
(->> stream (rx/filter dps/force-persist?)))
current-page-id-s objects-s
(rx/from-atom refs/current-page-id {:emit-current-value? true})] (rx/from-atom refs/workspace-page-objects {:emit-current-value? true})
(->> local-commits-s current-page-id-s
(rx/buffer-until notifier-s) (rx/from-atom refs/current-page-id {:emit-current-value? true})]
(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 %))))))))
(rx/with-latest-from current-page-id-s) (->> local-commits-s
(rx/map (rx/buffer-until notifier-s)
(fn [[text-position-data page-id]] (rx/with-latest-from objects-s)
(let [changes (rx/map
(->> text-position-data (fn [[commits objects]]
(mapv (fn [[id position-data]] (->> commits
{:type :mod-obj (mapcat :redo-changes)
:id id (filter #(contains? #{:mod-obj :add-obj} (:type %)))
:page-id page-id (filter #(cfh/text-shape? objects (:id %)))
:operations (map #(vector
[{:type :set (:id %)
:attr :position-data (wasm.api/calculate-position-data (get objects (:id %))))))))
: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/with-latest-from current-page-id-s)
(rx/filter dch/commit?) (rx/map
(rx/map deref) (fn [[text-position-data page-id]]
(rx/mapcat (let [changes
(fn [{:keys [save-undo? undo-changes redo-changes undo-group tags stack-undo?]}] (->> text-position-data
(if (and save-undo? (seq undo-changes)) (mapv (fn [[id position-data]]
(let [entry {:undo-changes undo-changes {:type :mod-obj
:redo-changes redo-changes :id id
:undo-group undo-group :page-id page-id
:tags tags}] :operations
(rx/of (dwu/append-undo entry stack-undo?))) [{:type :set
(rx/empty)))))) :attr :position-data
(rx/take-until stoper-s)))) :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 ptk/EffectEvent
(effect [_ _ _] (effect [_ _ _]

View File

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

View File

@ -214,6 +214,7 @@
(update state :workspace-presence dissoc session-id) (update state :workspace-presence dissoc session-id)
(update state :workspace-presence update-presence)))))) (update state :workspace-presence update-presence))))))
(defn handle-pointer-update (defn handle-pointer-update
[{:keys [page-id session-id position zoom zoom-inverse vbox vport] :as msg}] [{:keys [page-id session-id position zoom zoom-inverse vbox vport] :as msg}]
(ptk/reify ::handle-pointer-update (ptk/reify ::handle-pointer-update

View File

@ -49,14 +49,14 @@
;; (note that dwsh/update-shapes function returns an event) ;; (note that dwsh/update-shapes function returns an event)
(defn update-shape-radius-all (defn update-shape-radius
([value shape-ids attributes] (update-shape-radius-all value shape-ids attributes nil)) ([value shape-ids attributes] (update-shape-radius 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 ([value shape-ids attributes page-id]
(when (number? value) (when (number? value)
(let [value (max 0 value)] (let [value (max 0 value)]
(dwsh/update-shapes shape-ids (dwsh/update-shapes shape-ids
(fn [shape] (fn [shape]
(ctsr/set-radius-to-all-corners shape value)) (ctsr/set-radius-for-corners shape attributes value))
{:reg-objects? true {:reg-objects? true
:ignore-touched true :ignore-touched true
:page-id page-id :page-id page-id
@ -531,7 +531,7 @@
(some attributes #{:r1 :r2 :r3 :r4}) (some attributes #{:r1 :r2 :r3 :r4})
(conj #(if (= 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 (update-shape-radius-for-corners
value shape-ids value shape-ids
(set (filter attributes #{:r1 :r2 :r3 :r4})) (set (filter attributes #{:r1 :r2 :r3 :r4}))
@ -607,6 +607,46 @@
:state state})] :state state})]
(apply rx/of (map #(%) actions))))))))) (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 ------------------------------------------------------------ ;; Events to apply / unapply tokens to shapes ------------------------------------------------------------
(defn apply-token (defn apply-token
@ -620,65 +660,73 @@
ptk/WatchEvent ptk/WatchEvent
(watch [_ state _] (watch [_ state _]
;; We do not allow to apply tokens while text editor is open. ;; We do not allow to apply tokens while text editor is open.
(if (empty? (get state :workspace-editor-state)) ;; The classic text editor sets :workspace-editor-state; the WASM text editor
(let [attributes-to-remove ;; does not, so we also check :workspace-local :edition for text shapes.
;; Remove atomic typography tokens when applying composite and vice-verca (let [edition (get-in state [:workspace-local :edition])
(cond objects (dsh/lookup-page-objects state)
(ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys) text-editing? (and (some? edition)
(ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys) (= :text (:type (get objects edition))))]
:else attributes-to-remove)] (if (and (empty? (get state :workspace-editor-state))
(when-let [tokens (some-> (dsh/lookup-file-data state) (not text-editing?))
(get :tokens-lib) (let [attributes-to-remove
(ctob/get-tokens-in-active-sets))] ;; Remove atomic typography tokens when applying composite and vice-verca
(->> (if (contains? cf/flags :tokenscript) (cond
(rx/of (ts/resolve-tokens tokens)) (ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys)
(sd/resolve-tokens tokens)) (ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys)
(rx/mapcat :else attributes-to-remove)]
(fn [resolved-tokens] (when-let [tokens (some-> (dsh/lookup-file-data state)
(let [undo-id (js/Symbol) (get :tokens-lib)
objects (dsh/lookup-page-objects state) (ctob/get-tokens-in-active-sets))]
selected-shapes (select-keys objects shape-ids) (->> (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 shapes (->> selected-shapes
(filter (fn [[_ shape]] (filter (fn [[_ shape]]
(or (or
(and (ctsl/any-layout-immediate-child? objects shape) (and (ctsl/any-layout-immediate-child? objects shape)
(some ctt/spacing-margin-keys attributes)) (some ctt/spacing-margin-keys attributes))
(and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape)) (and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape))
(all-attrs-appliable-for-token? attributes (:type token))))))) (all-attrs-appliable-for-token? attributes (:type token)))))))
shape-ids (d/nilv (keys shapes) []) shape-ids (d/nilv (keys shapes) [])
any-variant? (->> shapes vals (some ctk/is-variant?) boolean) any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value]) resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value])
resolved-value (if (contains? cf/flags :tokenscript) resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value) (ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value) resolved-value)
tokenized-attributes (cfo/attributes-map attributes token) tokenized-attributes (cfo/attributes-map attributes token)
type (:type token)] type (:type token)]
(rx/concat (rx/concat
(rx/of (rx/of
(st/emit! (ev/event {::ev/name "apply-tokens" (st/emit! (ev/event {::ev/name "apply-tokens"
:type type :type type
:applied-to attributes :applied-to attributes
:applied-to-variant any-variant?})) :applied-to-variant any-variant?}))
(dwu/start-undo-transaction undo-id) (dwu/start-undo-transaction undo-id)
(dwsh/update-shapes shape-ids (fn [shape] (dwsh/update-shapes shape-ids (fn [shape]
(cond-> shape (cond-> shape
attributes-to-remove attributes-to-remove
(update :applied-tokens #(apply (partial dissoc %) attributes-to-remove)) (update :applied-tokens #(apply (partial dissoc %) attributes-to-remove))
:always :always
(update :applied-tokens merge tokenized-attributes))))) (update :applied-tokens merge tokenized-attributes)))))
(when on-update-shape (when on-update-shape
(let [res (on-update-shape resolved-value shape-ids attributes)] (let [res (on-update-shape resolved-value shape-ids attributes)]
;; Composed updates return observables and need to be executed differently ;; Composed updates return observables and need to be executed differently
(if (rx/observable? res) (if (rx/observable? res)
res res
(rx/of res)))) (rx/of res))))
(rx/of (dwu/commit-undo-transaction undo-id))))))))) (rx/of (dwu/commit-undo-transaction undo-id)))))))))
(rx/of (ntf/show {:content (tr "workspace.tokens.error-text-edition")
:type :toast (rx/of (ntf/show {:content (tr "workspace.tokens.error-text-edition")
:level :warning :type :toast
:timeout 3000})))))) :level :warning
:timeout 3000})))))))
(defn apply-spacing-token-separated (defn apply-spacing-token-separated
"Handles edge-case for spacing token when applying token via toggle button. "Handles edge-case for spacing token when applying token via toggle button.
@ -744,10 +792,16 @@
{:keys [attributes all-attributes on-update-shape]} {:keys [attributes all-attributes on-update-shape]}
(get token-properties (:type token)) (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? unapply-tokens?
(cfo/shapes-token-applied? token shapes (or attrs all-attributes attributes)) (cfo/shapes-token-applied? token shapes (or attrs all-attributes attributes))
shape-ids (map :id shapes)] shape-ids
(map :id shapes)]
(if unapply-tokens? (if unapply-tokens?
(rx/of (rx/of
@ -808,7 +862,7 @@
:border-radius :border-radius
{:title "Border Radius" {:title "Border Radius"
:attributes ctt/border-radius-keys :attributes ctt/border-radius-keys
:on-update-shape update-shape-radius-all :on-update-shape update-shape-radius
:modal {:key :tokens/border-radius :modal {:key :tokens/border-radius
:fields [{:label "Border Radius" :fields [{:label "Border Radius"
:key :border-radius}]}} :key :border-radius}]}}

View File

@ -613,7 +613,7 @@
vec)) vec))
(defn combine-as-variants (defn combine-as-variants
[ids {:keys [page-id trigger]}] [ids {:keys [page-id trigger variant-id]}]
(ptk/reify ::combine-as-variants (ptk/reify ::combine-as-variants
ptk/WatchEvent ptk/WatchEvent
(watch [_ state stream] (watch [_ state stream]
@ -647,7 +647,7 @@
:shapes :shapes
count count
inc) inc)
variant-id (uuid/next) variant-id (or variant-id (uuid/next))
undo-id (js/Symbol)] undo-id (js/Symbol)]
(rx/concat (rx/concat

View File

@ -33,6 +33,16 @@
;; Will contain last uncaught exception ;; Will contain last uncaught exception
(def last-exception nil) (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. ;; Re-entrancy guard: prevents on-error from calling itself recursively.
;; If an error occurs while we are already handling an error (e.g. the ;; 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 ;; notification emit itself throws), we log it and bail out immediately
@ -206,6 +216,16 @@
(ex/print-throwable cause :prefix "Unexpected Error") (ex/print-throwable cause :prefix "Unexpected Error")
(flash :cause cause :type :unhandled)))) (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 ;; We receive a explicit authentication error; If the uri is for
;; workspace, dashboard, viewer or settings, then assign the exception ;; workspace, dashboard, viewer or settings, then assign the exception
;; for show the error page. Otherwise this explicitly clears all ;; for show the error page. Otherwise this explicitly clears all
@ -420,6 +440,15 @@
(and (string? stack) (and (string? stack)
(str/includes? stack "posthog")))) (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] (is-ignorable-exception? [cause]
(let [message (ex-message cause)] (let [message (ex-message cause)]
(or (from-extension? cause) (or (from-extension? cause)
@ -447,32 +476,55 @@
(on-unhandled-error [event] (on-unhandled-error [event]
(.preventDefault ^js event) (.preventDefault ^js event)
(when-let [cause (unchecked-get event "error")] (when-let [cause (unchecked-get event "error")]
(when-not (is-ignorable-exception? cause) (cond
(if (stale-asset-error? cause) (stale-asset-error? cause)
(cf/throttled-reload :reason (ex-message cause)) (cf/throttled-reload :reason (ex-message cause))
(let [data (ex-data cause)
type (get data :type)] ;; Plugin errors: log to console and ignore
(set! last-exception cause) (from-plugin? cause)
(if (= :wasm-error type) (ex/print-throwable cause :prefix "Plugin Error")
(on-error cause)
(do ;; Other ignorable exceptions: ignore silently
(ex/print-throwable cause :prefix "Uncaught Exception") (is-ignorable-exception? cause)
(ts/asap #(flash :cause cause :type :unhandled))))))))) 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] (on-unhandled-rejection [event]
(.preventDefault ^js event) (.preventDefault ^js event)
(when-let [cause (unchecked-get event "reason")] (when-let [cause (unchecked-get event "reason")]
(when-not (is-ignorable-exception? cause) (cond
(if (stale-asset-error? cause) (stale-asset-error? cause)
(cf/throttled-reload :reason (ex-message cause)) (cf/throttled-reload :reason (ex-message cause))
(let [data (ex-data cause)
type (get data :type)] ;; Plugin errors: log to console and ignore
(set! last-exception cause) (from-plugin? cause)
(if (= :wasm-error type) (ex/print-throwable cause :prefix "Plugin Error")
(on-error cause)
(do ;; Other ignorable exceptions: ignore silently
(ex/print-throwable cause :prefix "Uncaught Rejection") (is-ignorable-exception? cause)
(ts/asap #(flash :cause cause :type :unhandled)))))))))] 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 "error" on-unhandled-error)
(.addEventListener g/window "unhandledrejection" on-unhandled-rejection) (.addEventListener g/window "unhandledrejection" on-unhandled-rejection)

View File

@ -150,6 +150,9 @@
(def workspace-global (def workspace-global
(l/derived :workspace-global st/state)) (l/derived :workspace-global st/state))
(def mcp
(l/derived :mcp st/state))
(def workspace-drawing (def workspace-drawing
(l/derived :workspace-drawing st/state)) (l/derived :workspace-drawing st/state))

View File

@ -190,7 +190,7 @@
:settings-options :settings-options
:settings-feedback :settings-feedback
:settings-subscription :settings-subscription
:settings-access-tokens :settings-integrations
:settings-notifications) :settings-notifications)
(let [params (get params :query) (let [params (get params :query)
error-report-id (some-> params :error-report-id uuid/parse*)] error-report-id (some-> params :error-report-id uuid/parse*)]

View File

@ -9,14 +9,17 @@
(:require (:require
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
[app.main.store :as st] [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.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.icons :as deprecated-icon]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as k] [app.util.keyboard :as k]
[goog.events :as events] [goog.events :as events]
[rumext.v2 :as mf]) [rumext.v2 :as mf])
(:import goog.events.EventType)) (:import
goog.events.EventType))
(mf/defc confirm-dialog (mf/defc confirm-dialog
{::mf/register modal/components {::mf/register modal/components
@ -68,8 +71,11 @@
[:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-header)} [:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-title)} title] [:h2 {:class (stl/css :modal-title)} title]
[:button {:class (stl/css :modal-close-btn) [:div {:class (stl/css :modal-close-btn)}
:on-click cancel-fn} deprecated-icon/close]] [:> icon-button* {:variant "ghost"
:aria-label (tr "labels.close")
:on-click cancel-fn
:icon i/close}]]]
[:div {:class (stl/css :modal-content)} [:div {:class (stl/css :modal-content)}
(when (and (string? message) (not= message "")) (when (and (string? message) (not= message ""))
@ -87,24 +93,19 @@
[:ul {:class (stl/css :component-list)} [:ul {:class (stl/css :component-list)}
(for [item items] (for [item items]
[:li {:class (stl/css :modal-item-element)} [:li {:class (stl/css :modal-item-element)}
[:span {:class (stl/css :modal-component-icon)} [:> icon* {:icon-id i/component
deprecated-icon/component] :class (stl/css :modal-component-icon)
:size "s"}]
[:span {:class (stl/css :modal-component-name)} [:span {:class (stl/css :modal-component-name)}
(:name item)]])]])] (:name item)]])]])]
[:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)} [:div {:class (stl/css :action-buttons)}
(when-not (= cancel-label :omit) (when-not (= cancel-label :omit)
[:input [:> button* {:variant "secondary"
{:class (stl/css :cancel-button) :on-click cancel-fn}
:type "button" cancel-label])
:value cancel-label [:> button* {:variant (cond (= accept-style :danger) "destructive"
:on-click cancel-fn}]) (= accept-style :primary) "primary")
:on-click accept-fn}
[:input accept-label]]]]]))
{:class (stl/css-case :accept-btn true
:danger (= accept-style :danger)
:primary (= accept-style :primary))
:type "button"
:value accept-label
:on-click accept-fn}]]]]]))

View File

@ -15,10 +15,9 @@
.modal-container { .modal-container {
@extend .modal-container-base; @extend .modal-container-base;
} display: flex;
flex-direction: column;
.modal-header { gap: var(--sp-xxl);
margin-bottom: deprecated.$s-24;
} }
.modal-title { .modal-title {
@ -27,12 +26,13 @@
} }
.modal-close-btn { .modal-close-btn {
@extend .modal-close-btn-base; position: absolute;
top: var(--sp-m);
right: var(--sp-m);
} }
.modal-content { .modal-content {
@include deprecated.bodyLargeTypography; @include deprecated.bodyLargeTypography;
margin-bottom: deprecated.$s-24;
} }
.modal-item-element { .modal-item-element {
@ -41,32 +41,18 @@
.modal-component-icon { .modal-component-icon {
@include deprecated.flexCenter; @include deprecated.flexCenter;
height: deprecated.$s-16; color: var(--color-foreground-secondary);
width: deprecated.$s-16;
svg {
@extend .button-icon-small;
stroke: var(--color);
}
} }
.modal-component-name { .modal-component-name {
@include deprecated.bodyLargeTypography; @include deprecated.bodyLargeTypography;
color: var(--color-foreground-secondary);
} }
.action-buttons { .action-buttons {
@extend .modal-action-btns; @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-scd-msg,
.modal-subtitle, .modal-subtitle,
.modal-msg { .modal-msg {

View File

@ -18,6 +18,7 @@ $sz-32: px2rem(32);
$sz-36: px2rem(36); $sz-36: px2rem(36);
$sz-40: px2rem(40); $sz-40: px2rem(40);
$sz-48: px2rem(48); $sz-48: px2rem(48);
$sz-64: px2rem(64);
$sz-88: px2rem(88); $sz-88: px2rem(88);
$sz-96: px2rem(96); $sz-96: px2rem(96);
$sz-120: px2rem(120); $sz-120: px2rem(120);

View File

@ -8,7 +8,6 @@
(:require-macros [app.main.style :as stl]) (:require-macros [app.main.style :as stl])
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm]
[app.main.constants :refer [max-input-length]] [app.main.constants :refer [max-input-length]]
[app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]] [app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]]
[app.main.ui.ds.controls.utilities.input-field :refer [input-field*]] [app.main.ui.ds.controls.utilities.input-field :refer [input-field*]]
@ -52,10 +51,11 @@
:has-hint has-hint :has-hint has-hint
:hint-type hint-type :hint-type hint-type
:variant variant})] :variant variant})]
[:div {:class (dm/str class " " (stl/css-case :input-wrapper true
:variant-dense (= variant "dense") [:div {:class [class (stl/css-case :input-wrapper true
:variant-comfortable (= variant "comfortable") :variant-dense (= variant "dense")
:has-hint has-hint))} :variant-comfortable (= variant "comfortable")
:has-hint has-hint)]}
(when has-label (when has-label
[:> label* {:for id :is-optional is-optional} label]) [:> label* {:for id :is-optional is-optional} label])
[:> input-field* props] [:> input-field* props]
@ -64,4 +64,3 @@
:class hint-class :class hint-class
:message hint-message :message hint-message
:type hint-type}])])) :type hint-type}])]))

View File

@ -8,6 +8,7 @@
(:require (:require
[app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.controls.input :refer [input*]] [app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.controls.select :refer [select*]]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.forms :as fm] [app.util.forms :as fm]
[app.util.keyboard :as k] [app.util.keyboard :as k]
@ -47,6 +48,23 @@
[:> input* props])) [:> 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* (mf/defc form-submit*
[{:keys [disabled on-submit] :rest props}] [{:keys [disabled on-submit] :rest props}]
(let [form (mf/use-ctx context) (let [form (mf/use-ctx context)
@ -79,4 +97,4 @@
(when (fn? on-submit) (when (fn? on-submit)
(on-submit form event))))] (on-submit form event))))]
[:> (mf/provider context) {:value form} [:> (mf/provider context) {:value form}
[:form {:class class :on-submit on-submit'} children]])) [:form {:class class :on-submit on-submit'} children]]))

View File

@ -36,7 +36,7 @@
["/feedback" :settings-feedback] ["/feedback" :settings-feedback]
["/options" :settings-options] ["/options" :settings-options]
["/subscriptions" :settings-subscription] ["/subscriptions" :settings-subscription]
["/access-tokens" :settings-access-tokens] ["/integrations" :settings-integrations]
["/notifications" :settings-notifications]] ["/notifications" :settings-notifications]]
["/frame-preview" :frame-preview] ["/frame-preview" :frame-preview]

View File

@ -13,10 +13,10 @@
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.hooks :as hooks] [app.main.ui.hooks :as hooks]
[app.main.ui.modal :refer [modal-container*]] [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.change-email]
[app.main.ui.settings.delete-account] [app.main.ui.settings.delete-account]
[app.main.ui.settings.feedback :refer [feedback-page*]] [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.notifications :refer [notifications-page*]]
[app.main.ui.settings.options :refer [options-page]] [app.main.ui.settings.options :refer [options-page]]
[app.main.ui.settings.password :refer [password-page]] [app.main.ui.settings.password :refer [password-page]]
@ -73,8 +73,8 @@
:settings-subscription :settings-subscription
[:> subscription-page* {:profile profile}] [:> subscription-page* {:profile profile}]
:settings-access-tokens :settings-integrations
[:& access-tokens-page] [:> integrations-page*]
:settings-notifications :settings-notifications
[:& notifications-page* {:profile profile}])]]]])) [:& notifications-page* {:profile profile}])]]]]))

View File

@ -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)}])]])]))

View File

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

View File

@ -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*])])

View File

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

View File

@ -43,8 +43,8 @@
(def ^:private go-settings-subscription (def ^:private go-settings-subscription
#(st/emit! (rt/nav :settings-subscription))) #(st/emit! (rt/nav :settings-subscription)))
(def ^:private go-settings-access-tokens (def ^:private go-settings-integrations
#(st/emit! (rt/nav :settings-access-tokens))) #(st/emit! (rt/nav :settings-integrations)))
(def ^:private go-settings-notifications (def ^:private go-settings-notifications
#(st/emit! (rt/nav :settings-notifications))) #(st/emit! (rt/nav :settings-notifications)))
@ -66,7 +66,7 @@
options? (= section :settings-options) options? (= section :settings-options)
feedback? (= section :settings-feedback) feedback? (= section :settings-feedback)
subscription? (= section :settings-subscription) subscription? (= section :settings-subscription)
access-tokens? (= section :settings-access-tokens) integrations? (= section :settings-integrations)
notifications? (= section :settings-notifications) notifications? (= section :settings-notifications)
team-id (or (dtm/get-last-team-id) team-id (or (dtm/get-last-team-id)
(:default-team-id profile)) (:default-team-id profile))
@ -115,12 +115,13 @@
:data-testid "settings-subscription"} :data-testid "settings-subscription"}
[:span {:class (stl/css :element-title)} (tr "subscription.labels")]]) [:span {:class (stl/css :element-title)} (tr "subscription.labels")]])
(when (contains? cf/flags :access-tokens) (when (or (contains? cf/flags :access-tokens)
[:li {:class (stl/css-case :current access-tokens? (contains? cf/flags :mcp))
[:li {:class (stl/css-case :current integrations?
:settings-item true) :settings-item true)
:on-click go-settings-access-tokens :on-click go-settings-integrations
:data-testid "settings-access-tokens"} :data-testid "settings-integrations"}
[:span {:class (stl/css :element-title)} (tr "labels.access-tokens")]]) [:span {:class (stl/css :element-title)} (tr "labels.integrations")]])
[:hr {:class (stl/css :sidebar-separator)}] [:hr {:class (stl/css :sidebar-separator)}]

View File

@ -15,7 +15,6 @@
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.router :as rt] [app.main.router :as rt]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.context :as ctx]
[app.main.ui.icons :as deprecated-icon] [app.main.ui.icons :as deprecated-icon]
[app.main.ui.workspace.main-menu :as main-menu] [app.main.ui.workspace.main-menu :as main-menu]
[app.util.dom :as dom] [app.util.dom :as dom]
@ -27,12 +26,10 @@
;; --- Header Component ;; --- Header Component
(mf/defc left-header* (mf/defc left-header*
[{:keys [file layout project page-id class]}] [{:keys [file layout project class]}]
(let [profile (mf/deref refs/profile) (let [file-id (:id file)
file-id (:id file)
file-name (:name file) file-name (:name file)
project-id (:id project) project-id (:id project)
team-id (:team-id project)
shared? (:is-shared file) shared? (:is-shared file)
persistence persistence
(mf/deref refs/persistence) (mf/deref refs/persistence)
@ -40,8 +37,6 @@
persistence-status persistence-status
(get persistence :status) (get persistence :status)
read-only? (mf/use-ctx ctx/workspace-read-only?)
editing* (mf/use-state false) editing* (mf/use-state false)
editing? (deref editing*) editing? (deref editing*)
input-ref (mf/use-ref nil) input-ref (mf/use-ref nil)
@ -137,10 +132,5 @@
(when ^boolean shared? (when ^boolean shared?
[:span {:class (stl/css :shared-badge)} deprecated-icon/library]) [:span {:class (stl/css :shared-badge)} deprecated-icon/library])
[:div {:class (stl/css :menu-section)} [:div {:class (stl/css :menu-section)}
[:& main-menu/menu [:> main-menu/menu* {:layout layout
{:layout layout :file file}]]]))
:file file
:profile profile
:read-only? read-only?
:team-id team-id
:page-id page-id}]]]))

File diff suppressed because it is too large Load Diff

View File

@ -4,125 +4,178 @@
// //
// Copyright (c) KALEIDOS INC // 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 { .menu {
@extend .menu-dropdown; top: $sz-48;
top: deprecated.$s-48; left: calc(var(--right-sidebar-width) - $sz-40);
left: calc(var(--right-sidebar-width, deprecated.$s-256) - deprecated.$s-16); inline-size: $sz-192;
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;
} }
.sub-menu { .sub-menu {
@extend .menu-dropdown; left: calc(var(--right-sidebar-width) + $sz-154);
left: calc(var(--right-sidebar-width, deprecated.$s-256) + deprecated.$s-180); min-width: $sz-284;
width: deprecated.$s-192; width: 115%;
min-width: calc(deprecated.$s-272 - deprecated.$s-2);
width: 110%;
.submenu-item { &.pos-1 {
@extend .menu-item-base; top: calc($sz-16 + $sz-32);
&:hover {
color: var(--menu-foreground-color-hover);
.shortcut-key {
color: var(--menu-shortcut-foreground-color-hover);
}
}
} }
.menu-disabled { &.pos-2 {
color: var(--color-foreground-secondary); top: calc($sz-16 + (2 * $sz-32));
&:hover {
cursor: default;
color: var(--color-foreground-secondary);
background-color: var(--menu-background-color);
}
} }
&.file { &.pos-3 {
top: deprecated.$s-48; top: calc($sz-16 + (3 * $sz-32));
} }
&.edit { &.pos-4 {
top: deprecated.$s-76; top: calc($sz-16 + (4 * $sz-32));
} }
&.view { &.pos-5 {
top: deprecated.$s-116; top: calc($sz-16 + (5 * $sz-32));
} }
&.preferences { &.pos-6 {
top: deprecated.$s-148; 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 { &.plugins {
top: deprecated.$s-180; max-height: calc(100vh - $sz-200);
max-height: calc(100vh - deprecated.$s-180);
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
} }
}
&.help-info { .base-menu-item {
top: deprecated.$s-232; @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 { &.disabled {
top: deprecated.$s-192; --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 { .item-icon {
svg { color: var(--menu-icon-foreground-color);
@extend .button-icon; display: flex;
stroke: var(--icon-foreground); 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);
} }

View File

@ -119,7 +119,7 @@
(mf/defc left-sidebar* (mf/defc left-sidebar*
{::mf/memo true} {::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) (let [options-mode (mf/deref refs/options-mode-global)
project (mf/deref refs/project) project (mf/deref refs/project)
file-id (get file :id) file-id (get file :id)
@ -185,12 +185,10 @@
:class aside-class :class aside-class
:style {:--left-sidebar-width (dm/str width "px")}} :style {:--left-sidebar-width (dm/str width "px")}}
[:> left-header* [:> left-header* {:file file
{:file file :layout layout
:layout layout :project project
:project project :class (stl/css :left-header)}]
:page-id page-id
:class (stl/css :left-header)}]
[:div {:on-pointer-down on-pointer-down [:div {:on-pointer-down on-pointer-down
:on-lost-pointer-capture on-lost-pointer-capture :on-lost-pointer-capture on-lost-pointer-capture

View File

@ -291,7 +291,7 @@
:r4 "Bottom Left" :r4 "Bottom Left"
:r3 "Bottom Right"} :r3 "Bottom Right"}
:hint (tr "workspace.tokens.radius") :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}) :on-update-shape update-shape-radius-for-corners})
shadow (partial generic-attribute-actions #{:shadow} "Shadow")] shadow (partial generic-attribute-actions #{:shadow} "Shadow")]
{:border-radius border-radius {:border-radius border-radius

View File

@ -8,6 +8,7 @@
"RPC for plugins runtime." "RPC for plugins runtime."
(:require (:require
["@penpot/plugins-runtime" :as runtime] ["@penpot/plugins-runtime" :as runtime]
[app.main.errors :as errors]
[app.main.features :as features] [app.main.features :as features]
[app.main.store :as st] [app.main.store :as st]
[app.plugins.api :as api] [app.plugins.api :as api]
@ -30,6 +31,8 @@
(ptk/reify ::initialize (ptk/reify ::initialize
ptk/WatchEvent ptk/WatchEvent
(watch [_ _ stream] (watch [_ _ stream]
(set! errors/is-plugin-error? runtime/isPluginError)
(->> stream (->> stream
(rx/filter (ptk/type? ::features/initialize)) (rx/filter (ptk/type? ::features/initialize))
(rx/observe-on :async) (rx/observe-on :async)

View File

@ -14,9 +14,11 @@
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.types.color :as ctc] [app.common.types.color :as ctc]
[app.common.types.component :as ctk]
[app.common.types.shape :as cts] [app.common.types.shape :as cts]
[app.common.types.text :as txt] [app.common.types.text :as txt]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.changes :as ch] [app.main.data.changes :as ch]
[app.main.data.common :as dcm] [app.main.data.common :as dcm]
[app.main.data.helpers :as dsh] [app.main.data.helpers :as dsh]
@ -26,6 +28,7 @@
[app.main.data.workspace.groups :as dwg] [app.main.data.workspace.groups :as dwg]
[app.main.data.workspace.media :as dwm] [app.main.data.workspace.media :as dwm]
[app.main.data.workspace.selection :as dws] [app.main.data.workspace.selection :as dws]
[app.main.data.workspace.variants :as dwv]
[app.main.data.workspace.wasm-text :as dwwt] [app.main.data.workspace.wasm-text :as dwwt]
[app.main.features :as features] [app.main.features :as features]
[app.main.fonts :refer [fetch-font-css]] [app.main.fonts :refer [fetch-font-css]]
@ -82,6 +85,10 @@
:$plugin {:enumerable false :get (fn [] plugin-id)} :$plugin {:enumerable false :get (fn [] plugin-id)}
;; Public properties ;; Public properties
:version
{:this true
:get (constantly (:base cf/version))}
:root :root
{:this true {:this true
:get #(.getRoot ^js %)} :get #(.getRoot ^js %)}
@ -110,7 +117,7 @@
(fn [_ shapes] (fn [_ shapes]
(cond (cond
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) (or (not (array? shapes)) (not (every? shape/shape-proxy? shapes)))
(u/display-not-valid :selection shapes) (u/not-valid plugin-id :selection shapes)
:else :else
(let [ids (into (d/ordered-set) (map #(obj/get % "$id")) shapes)] (let [ids (into (d/ordered-set) (map #(obj/get % "$id")) shapes)]
@ -175,7 +182,7 @@
(fn [shapes] (fn [shapes]
(cond (cond
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) (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 :else
(let [objects (u/locate-objects) (let [objects (u/locate-objects)
@ -195,13 +202,13 @@
new-color (parser/parse-color-data new-color)] new-color (parser/parse-color-data new-color)]
(cond (cond
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) (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)) (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)) (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 :else
(let [file-id (:current-file-id @st/state) (let [file-id (:current-file-id @st/state)
@ -254,10 +261,10 @@
(fn [name url] (fn [name url]
(cond (cond
(not (string? name)) (not (string? name))
(u/display-not-valid :uploadMedia-name name) (u/not-valid plugin-id :uploadMedia-name name)
(not (string? url)) (not (string? url))
(u/display-not-valid :uploadMedia-url url) (u/not-valid plugin-id :uploadMedia-url url)
:else :else
(let [file-id (:current-file-id @st/state)] (let [file-id (:current-file-id @st/state)]
@ -288,7 +295,7 @@
(fn [shapes] (fn [shapes]
(cond (cond
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) (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 :else
(let [file-id (:current-file-id @st/state) (let [file-id (:current-file-id @st/state)
@ -303,10 +310,10 @@
(fn [group & rest] (fn [group & rest]
(cond (cond
(not (shape/shape-proxy? group)) (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))) (and (some? rest) (not (every? shape/shape-proxy? rest)))
(u/display-not-valid :ungroup rest) (u/not-valid plugin-id :ungroup rest)
:else :else
(let [shapes (concat [group] rest) (let [shapes (concat [group] rest)
@ -346,7 +353,7 @@
(fn [text] (fn [text]
(cond (cond
(or (not (string? text)) (empty? text)) (or (not (string? text)) (empty? text))
(u/display-not-valid :createText text) (u/not-valid plugin-id :createText text)
:else :else
(let [page (dsh/lookup-page @st/state) (let [page (dsh/lookup-page @st/state)
@ -377,7 +384,7 @@
(fn [svg-string] (fn [svg-string]
(cond (cond
(or (not (string? svg-string)) (empty? svg-string)) (or (not (string? svg-string)) (empty? svg-string))
(u/display-not-valid :createShapeFromSvg svg-string) (u/not-valid plugin-id :createShapeFromSvg svg-string)
:else :else
(let [id (uuid/next) (let [id (uuid/next)
@ -394,7 +401,7 @@
(cond (cond
(or (not (string? svg-string)) (empty? svg-string)) (or (not (string? svg-string)) (empty? svg-string))
(do (do
(u/display-not-valid :createShapeFromSvg "Svg not valid") (u/not-valid plugin-id :createShapeFromSvg "Svg not valid")
(reject "Svg not valid")) (reject "Svg not valid"))
:else :else
@ -412,10 +419,10 @@
(let [bool-type (keyword bool-type)] (let [bool-type (keyword bool-type)]
(cond (cond
(not (contains? cts/bool-types bool-type)) (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))) (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 :else
(let [ids (into #{} (map #(obj/get % "$id")) shapes) (let [ids (into #{} (map #(obj/get % "$id")) shapes)
@ -429,10 +436,10 @@
(let [type (d/nilv (obj/get options "type") "html")] (let [type (d/nilv (obj/get options "type") "html")]
(cond (cond
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) (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))) (and (some? type) (not (contains? #{"html" "svg"} type)))
(u/display-not-valid :generateMarkup-type type) (u/not-valid plugin-id :generateMarkup-type type)
:else :else
(let [resolved-code (let [resolved-code
@ -464,16 +471,16 @@
children? (d/nilv (obj/get options "includeChildren") true)] children? (d/nilv (obj/get options "includeChildren") true)]
(cond (cond
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) (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))) (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?))) (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?))) (and (some? children?) (not (boolean? children?)))
(u/display-not-valid :generateStyle-includeChildren children?) (u/not-valid plugin-id :generateStyle-includeChildren children?)
:else :else
(let [resolved-styles (let [resolved-styles
@ -546,7 +553,7 @@
:else nil) :else nil)
new-window (if (boolean? new-window) new-window false)] new-window (if (boolean? new-window) new-window false)]
(if (nil? id) (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))))) (st/emit! (dcm/go-to-workspace :page-id id ::rt/new-window new-window)))))
:alignHorizontal :alignHorizontal
@ -558,10 +565,10 @@
nil)] nil)]
(cond (cond
(nil? dir) (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))) (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 :else
(let [ids (into #{} (map #(obj/get % "$id")) shapes)] (let [ids (into #{} (map #(obj/get % "$id")) shapes)]
@ -576,10 +583,10 @@
nil)] nil)]
(cond (cond
(nil? dir) (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))) (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 :else
(let [ids (into #{} (map #(obj/get % "$id")) shapes)] (let [ids (into #{} (map #(obj/get % "$id")) shapes)]
@ -589,7 +596,7 @@
(fn [shapes] (fn [shapes]
(cond (cond
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) (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 :else
(let [ids (into #{} (map #(obj/get % "$id")) shapes)] (let [ids (into #{} (map #(obj/get % "$id")) shapes)]
@ -599,7 +606,7 @@
(fn [shapes] (fn [shapes]
(cond (cond
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) (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 :else
(let [ids (into #{} (map #(obj/get % "$id")) shapes)] (let [ids (into #{} (map #(obj/get % "$id")) shapes)]
@ -609,8 +616,41 @@
(fn [shapes] (fn [shapes]
(cond (cond
(or (not (array? shapes)) (not (every? shape/shape-proxy? shapes))) (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 :else
(let [ids (into #{} (map #(obj/get % "$id")) shapes)] (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")))))))

View File

@ -60,13 +60,13 @@
(let [profile (:profile @st/state)] (let [profile (:profile @st/state)]
(cond (cond
(or (not (string? content)) (empty? content)) (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)) (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")) (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 :else
(->> (rp/cmd! :update-comment {:id (:id data) :content content}) (->> (rp/cmd! :update-comment {:id (:id data) :content content})
@ -81,7 +81,7 @@
(cond (cond
(not (r/check-permission plugin-id "comment:write")) (not (r/check-permission plugin-id "comment:write"))
(do (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")) (reject "Plugin doesn't have 'comment:write' permission"))
:else :else
@ -120,10 +120,10 @@
(cond (cond
(or (not (sm/valid-safe-number? (:x position))) (or (not (sm/valid-safe-number? (:x position)))
(not (sm/valid-safe-number? (:y 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")) (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 :else
(do (st/emit! (dwc/update-comment-thread-position @data* [(:x position) (:y position)])) (do (st/emit! (dwc/update-comment-thread-position @data* [(:x position) (:y position)]))
@ -137,10 +137,10 @@
(fn [is-resolved] (fn [is-resolved]
(cond (cond
(not (boolean? is-resolved)) (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")) (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 :else
(do (st/emit! (dc/update-comment-thread (assoc @data* :is-resolved is-resolved))) (do (st/emit! (dc/update-comment-thread (assoc @data* :is-resolved is-resolved)))
@ -153,7 +153,7 @@
(cond (cond
(not (r/check-permission plugin-id "comment:read")) (not (r/check-permission plugin-id "comment:read"))
(do (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")) (reject "Plugin doesn't have 'comment:read' permission"))
:else :else
@ -169,10 +169,10 @@
(fn [content] (fn [content]
(cond (cond
(not (r/check-permission plugin-id "comment:write")) (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)) (or (not (string? content)) (empty? content))
(u/display-not-valid :reply "Not valid") (u/not-valid plugin-id :reply "Not valid")
:else :else
(js/Promise. (js/Promise.
@ -186,10 +186,10 @@
owner (dsh/lookup-profile @st/state (:owner-id data))] owner (dsh/lookup-profile @st/state (:owner-id data))]
(cond (cond
(not (r/check-permission plugin-id "comment:write")) (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) (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 :else
(js/Promise. (js/Promise.

View File

@ -45,10 +45,10 @@
(fn [value] (fn [value]
(cond (cond
(not (r/check-permission plugin-id "content:write")) (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)) (or (not (string? value)) (empty? value))
(u/display-not-valid :label value) (u/not-valid plugin-id :label value)
:else :else
(do (swap! data assoc :label value :created-by "user") (do (swap! data assoc :label value :created-by "user")
@ -145,7 +145,7 @@
(fn [key] (fn [key]
(cond (cond
(not (string? key)) (not (string? key))
(u/display-not-valid :getPluginData-key key) (u/not-valid plugin-id :getPluginData-key key)
:else :else
(let [file (u/locate-file id)] (let [file (u/locate-file id)]
@ -155,13 +155,13 @@
(fn [key value] (fn [key value]
(cond (cond
(or (not (string? key)) (empty? key)) (or (not (string? key)) (empty? key))
(u/display-not-valid :setPluginData-key key) (u/not-valid plugin-id :setPluginData-key key)
(not (string? 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")) (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 :else
(st/emit! (dp/set-plugin-data id :file (keyword "plugin" (str plugin-id)) key value)))) (st/emit! (dp/set-plugin-data id :file (keyword "plugin" (str plugin-id)) key value))))
@ -175,10 +175,10 @@
(fn [namespace key] (fn [namespace key]
(cond (cond
(not (string? namespace)) (not (string? namespace))
(u/display-not-valid :getSharedPluginData-namespace namespace) (u/not-valid plugin-id :getSharedPluginData-namespace namespace)
(not (string? key)) (not (string? key))
(u/display-not-valid :getSharedPluginData-key key) (u/not-valid plugin-id :getSharedPluginData-key key)
:else :else
(let [file (u/locate-file id)] (let [file (u/locate-file id)]
@ -188,16 +188,16 @@
(fn [namespace key value] (fn [namespace key value]
(cond (cond
(or (not (string? namespace)) (empty? namespace)) (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)) (or (not (string? key)) (empty? key))
(u/display-not-valid :setSharedPluginData-key key) (u/not-valid plugin-id :setSharedPluginData-key key)
(not (string? 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")) (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 :else
(st/emit! (dp/set-plugin-data id :file (keyword "shared" namespace) key value)))) (st/emit! (dp/set-plugin-data id :file (keyword "shared" namespace) key value))))
@ -206,7 +206,7 @@
(fn [namespace] (fn [namespace]
(cond (cond
(not (string? namespace)) (not (string? namespace))
(u/display-not-valid :getSharedPluginDataKeys namespace) (u/not-valid plugin-id :getSharedPluginDataKeys namespace)
:else :else
(let [file (u/locate-file id)] (let [file (u/locate-file id)]
@ -216,7 +216,7 @@
(fn [] (fn []
(cond (cond
(not (r/check-permission plugin-id "content:write")) (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 :else
(let [page-id (uuid/next)] (let [page-id (uuid/next)]

View File

@ -6,17 +6,11 @@
(ns app.plugins.flags (ns app.plugins.flags
(:require (:require
[app.common.data.macros :as dm]
[app.main.store :as st] [app.main.store :as st]
[app.plugins.utils :as u] [app.plugins.utils :as u]
[app.util.object :as obj] [app.util.object :as obj]
[potok.v2.core :as ptk])) [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 (defn clear
[id] [id]
(ptk/reify ::reset (ptk/reify ::reset
@ -37,13 +31,27 @@
:naturalChildOrdering :naturalChildOrdering
{:this false {:this false
:get :get
(fn [] (natural-child-ordering? plugin-id)) (fn [] (u/natural-child-ordering? plugin-id))
:set :set
(fn [value] (fn [value]
(cond (cond
(not (boolean? value)) (not (boolean? value))
(u/display-not-valid :naturalChildOrdering value) (u/not-valid plugin-id :naturalChildOrdering value)
:else :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))))}))

View File

@ -12,7 +12,6 @@
[app.main.data.workspace.shape-layout :as dwsl] [app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.shapes :as dwsh]
[app.main.store :as st] [app.main.store :as st]
[app.plugins.flags :refer [natural-child-ordering?]]
[app.plugins.register :as r] [app.plugins.register :as r]
[app.plugins.utils :as u] [app.plugins.utils :as u]
[app.util.object :as obj])) [app.util.object :as obj]))
@ -39,10 +38,10 @@
(let [value (keyword value)] (let [value (keyword value)]
(cond (cond
(not (contains? ctl/flex-direction-types value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-flex-dir value})))))} (st/emit! (dwsl/update-layout #{id} {:layout-flex-dir value})))))}
@ -55,10 +54,10 @@
(let [value (keyword value)] (let [value (keyword value)]
(cond (cond
(not (contains? ctl/wrap-types value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-wrap-type value})))))} (st/emit! (dwsl/update-layout #{id} {:layout-wrap-type value})))))}
@ -71,10 +70,10 @@
(let [value (keyword value)] (let [value (keyword value)]
(cond (cond
(not (contains? ctl/align-items-types value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-align-items value})))))} (st/emit! (dwsl/update-layout #{id} {:layout-align-items value})))))}
@ -87,10 +86,10 @@
(let [value (keyword value)] (let [value (keyword value)]
(cond (cond
(not (contains? ctl/align-content-types value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-align-content value})))))} (st/emit! (dwsl/update-layout #{id} {:layout-align-content value})))))}
@ -103,10 +102,10 @@
(let [value (keyword value)] (let [value (keyword value)]
(cond (cond
(not (contains? ctl/justify-items-types value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-justify-items value})))))} (st/emit! (dwsl/update-layout #{id} {:layout-justify-items value})))))}
@ -119,10 +118,10 @@
(let [value (keyword value)] (let [value (keyword value)]
(cond (cond
(not (contains? ctl/justify-content-types value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-justify-content value})))))} (st/emit! (dwsl/update-layout #{id} {:layout-justify-content value})))))}
@ -134,10 +133,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-gap {:row-gap value}}))))} (st/emit! (dwsl/update-layout #{id} {:layout-gap {:row-gap value}}))))}
@ -149,10 +148,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-gap {:column-gap value}}))))} (st/emit! (dwsl/update-layout #{id} {:layout-gap {:column-gap value}}))))}
@ -164,10 +163,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value :p3 value}}))))} (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value :p3 value}}))))}
@ -179,10 +178,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value :p4 value}}))))} (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value :p4 value}}))))}
@ -195,10 +194,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value}}))))} (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value}}))))}
@ -210,10 +209,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value}}))))} (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value}}))))}
@ -225,10 +224,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p3 value}}))))} (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p3 value}}))))}
@ -240,10 +239,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p4 value}}))))} (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p4 value}}))))}
@ -256,13 +255,13 @@
(fn [child] (fn [child]
(cond (cond
(not (shape-proxy? child)) (not (shape-proxy? child))
(u/display-not-valid :appendChild child) (u/not-valid plugin-id :appendChild child)
:else :else
(let [child-id (obj/get child "$id") (let [child-id (obj/get child "$id")
shape (u/locate-shape file-id page-id id) shape (u/locate-shape file-id page-id id)
index 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 0
(count (:shapes shape)))] (count (:shapes shape)))]
(st/emit! (dwsh/relocate-shapes #{child-id} id index))))) (st/emit! (dwsh/relocate-shapes #{child-id} id index)))))
@ -275,10 +274,10 @@
(let [value (keyword value)] (let [value (keyword value)]
(cond (cond
(not (contains? ctl/item-h-sizing-types value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-item-h-sizing value})))))} (st/emit! (dwsl/update-layout #{id} {:layout-item-h-sizing value})))))}
@ -291,10 +290,10 @@
(let [value (keyword value)] (let [value (keyword value)]
(cond (cond
(not (contains? ctl/item-v-sizing-types value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-item-v-sizing value})))))})) (st/emit! (dwsl/update-layout #{id} {:layout-item-v-sizing value})))))}))
@ -317,10 +316,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (boolean? value)) (not (boolean? value))
(u/display-not-valid :absolute value) (u/not-valid plugin-id :absolute value)
(not (r/check-permission plugin-id "content:write")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-item-absolute value}))))} (st/emit! (dwsl/update-layout #{id} {:layout-item-absolute value}))))}
@ -332,10 +331,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(sm/valid-safe-int? value) (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")) (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 :else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-z-index value}))))} (st/emit! (dwsl/update-layout-child #{id} {:layout-item-z-index value}))))}
@ -348,10 +347,10 @@
(let [value (keyword value)] (let [value (keyword value)]
(cond (cond
(not (contains? ctl/item-h-sizing-types value)) (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")) (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 :else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-h-sizing value})))))} (st/emit! (dwsl/update-layout-child #{id} {:layout-item-h-sizing value})))))}
@ -364,10 +363,10 @@
(let [value (keyword value)] (let [value (keyword value)]
(cond (cond
(not (contains? ctl/item-v-sizing-types value)) (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")) (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 :else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-v-sizing value})))))} (st/emit! (dwsl/update-layout-child #{id} {:layout-item-v-sizing value})))))}
@ -380,10 +379,10 @@
(let [value (keyword value)] (let [value (keyword value)]
(cond (cond
(not (contains? ctl/item-align-self-types value)) (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")) (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 :else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-align-self value})))))} (st/emit! (dwsl/update-layout-child #{id} {:layout-item-align-self value})))))}
@ -395,10 +394,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-number? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m1 value :m3 value}}))))} (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m1 value :m3 value}}))))}
@ -410,10 +409,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-number? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m2 value :m4 value}}))))} (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m2 value :m4 value}}))))}
@ -425,10 +424,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-number? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m1 value}}))))} (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m1 value}}))))}
@ -440,10 +439,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-number? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m2 value}}))))} (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m2 value}}))))}
@ -455,10 +454,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-number? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m3 value}}))))} (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m3 value}}))))}
@ -470,10 +469,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-number? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m4 value}}))))} (st/emit! (dwsl/update-layout-child #{id} {:layout-item-margin {:m4 value}}))))}
@ -485,10 +484,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-number? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-max-w value}))))} (st/emit! (dwsl/update-layout-child #{id} {:layout-item-max-w value}))))}
@ -500,10 +499,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-number? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-min-w value}))))} (st/emit! (dwsl/update-layout-child #{id} {:layout-item-min-w value}))))}
@ -515,10 +514,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-number? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-max-h value}))))} (st/emit! (dwsl/update-layout-child #{id} {:layout-item-max-h value}))))}
@ -530,10 +529,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-number? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout-child #{id} {:layout-item-min-h value}))))})) (st/emit! (dwsl/update-layout-child #{id} {:layout-item-min-h value}))))}))

View File

@ -32,7 +32,7 @@
(obj/type-of? p "FontProxy")) (obj/type-of? p "FontProxy"))
(defn font-proxy (defn font-proxy
[{:keys [id family name variants] :as font}] [plugin-id {:keys [id family name variants] :as font}]
(when (some? font) (when (some? font)
(let [default-variant (fonts/get-default-variant font)] (let [default-variant (fonts/get-default-variant font)]
(obj/reify {:name "FontProxy"} (obj/reify {:name "FontProxy"}
@ -55,10 +55,10 @@
(fn [text variant] (fn [text variant]
(cond (cond
(not (shape/shape-proxy? text)) (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")) (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 :else
(let [id (obj/get text "$id") (let [id (obj/get text "$id")
@ -73,10 +73,10 @@
(fn [range variant] (fn [range variant]
(cond (cond
(not (text/text-range-proxy? range)) (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")) (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 :else
(let [id (obj/get range "$id") (let [id (obj/get range "$id")
@ -98,53 +98,53 @@
{:get {:get
(fn [] (fn []
(format/format-array (format/format-array
font-proxy (partial font-proxy plugin-id)
(vals @fonts/fontsdb)))} (vals @fonts/fontsdb)))}
:findById :findById
(fn [id] (fn [id]
(cond (cond
(not (string? id)) (not (string? id))
(u/display-not-valid :findbyId id) (u/not-valid plugin-id :findbyId id)
:else :else
(->> (vals @fonts/fontsdb) (->> (vals @fonts/fontsdb)
(d/seek #(str/includes? (str/lower (:id %)) (str/lower id))) (d/seek #(str/includes? (str/lower (:id %)) (str/lower id)))
(font-proxy)))) (font-proxy plugin-id))))
:findByName :findByName
(fn [name] (fn [name]
(cond (cond
(not (string? name)) (not (string? name))
(u/display-not-valid :findByName name) (u/not-valid plugin-id :findByName name)
:else :else
(->> (vals @fonts/fontsdb) (->> (vals @fonts/fontsdb)
(d/seek #(str/includes? (str/lower (:name %)) (str/lower name))) (d/seek #(str/includes? (str/lower (:name %)) (str/lower name)))
(font-proxy)))) (font-proxy plugin-id))))
:findAllById :findAllById
(fn [id] (fn [id]
(cond (cond
(not (string? id)) (not (string? id))
(u/display-not-valid :findAllById name) (u/not-valid plugin-id :findAllById name)
:else :else
(format/format-array (format/format-array
(fn [font] (fn [font]
(when (str/includes? (str/lower (:id font)) (str/lower id)) (when (str/includes? (str/lower (:id font)) (str/lower id))
(font-proxy font))) (font-proxy plugin-id font)))
(vals @fonts/fontsdb)))) (vals @fonts/fontsdb))))
:findAllByName :findAllByName
(fn [name] (fn [name]
(cond (cond
(not (string? name)) (not (string? name))
(u/display-not-valid :findAllByName name) (u/not-valid plugin-id :findAllByName name)
:else :else
(format/format-array (format/format-array
(fn [font] (fn [font]
(when (str/includes? (str/lower (:name font)) (str/lower name)) (when (str/includes? (str/lower (:name font)) (str/lower name))
(font-proxy font))) (font-proxy plugin-id font)))
(vals @fonts/fontsdb)))))) (vals @fonts/fontsdb))))))

View File

@ -598,3 +598,10 @@
(case axis (case axis
:y "horizontal" :y "horizontal"
:x "vertical")) :x "vertical"))
(defn format-geom-rect
[{:keys [x y width height]}]
#js {:x x
:y y
:width width
:height height})

View File

@ -40,10 +40,10 @@
(let [value (keyword value)] (let [value (keyword value)]
(cond (cond
(not (contains? ctl/grid-direction-types value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-grid-dir value})))))} (st/emit! (dwsl/update-layout #{id} {:layout-grid-dir value})))))}
@ -64,10 +64,10 @@
(let [value (keyword value)] (let [value (keyword value)]
(cond (cond
(not (contains? ctl/align-items-types value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-align-items value})))))} (st/emit! (dwsl/update-layout #{id} {:layout-align-items value})))))}
@ -80,10 +80,10 @@
(let [value (keyword value)] (let [value (keyword value)]
(cond (cond
(not (contains? ctl/align-content-types value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-align-content value})))))} (st/emit! (dwsl/update-layout #{id} {:layout-align-content value})))))}
@ -96,10 +96,10 @@
(let [value (keyword value)] (let [value (keyword value)]
(cond (cond
(not (contains? ctl/justify-items-types value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-justify-items value})))))} (st/emit! (dwsl/update-layout #{id} {:layout-justify-items value})))))}
@ -112,10 +112,10 @@
(let [value (keyword value)] (let [value (keyword value)]
(cond (cond
(not (contains? ctl/justify-content-types value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-justify-content value})))))} (st/emit! (dwsl/update-layout #{id} {:layout-justify-content value})))))}
@ -127,10 +127,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-gap {:row-gap value}}))))} (st/emit! (dwsl/update-layout #{id} {:layout-gap {:row-gap value}}))))}
@ -142,10 +142,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-gap {:column-gap value}}))))} (st/emit! (dwsl/update-layout #{id} {:layout-gap {:column-gap value}}))))}
@ -157,10 +157,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value :p3 value}}))))} (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value :p3 value}}))))}
@ -172,10 +172,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value :p4 value}}))))} (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value :p4 value}}))))}
@ -187,10 +187,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value}}))))} (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value}}))))}
@ -202,10 +202,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value}}))))} (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value}}))))}
@ -217,10 +217,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p3 value}}))))} (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p3 value}}))))}
@ -232,10 +232,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p4 value}}))))} (st/emit! (dwsl/update-layout #{id} {:layout-padding {:p4 value}}))))}
@ -245,14 +245,14 @@
(let [type (keyword type)] (let [type (keyword type)]
(cond (cond
(not (contains? ctl/grid-track-types type)) (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)) (and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value))) (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")) (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 :else
(st/emit! (dwsl/add-layout-track #{id} :row {:type type :value value}))))) (st/emit! (dwsl/add-layout-track #{id} :row {:type type :value value})))))
@ -262,17 +262,17 @@
(let [type (keyword type)] (let [type (keyword type)]
(cond (cond
(not (sm/valid-safe-int? index)) (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)) (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)) (and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value))) (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")) (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 :else
(st/emit! (dwsl/add-layout-track #{id} :row {:type type :value value} index))))) (st/emit! (dwsl/add-layout-track #{id} :row {:type type :value value} index)))))
@ -282,14 +282,14 @@
(let [type (keyword type)] (let [type (keyword type)]
(cond (cond
(not (contains? ctl/grid-track-types type)) (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)) (and (or (= :percent type) (= :flex type) (= :lex type))
(not (sm/valid-safe-number? value))) (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")) (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 :else
(st/emit! (dwsl/add-layout-track #{id} :column {:type type :value value}))))) (st/emit! (dwsl/add-layout-track #{id} :column {:type type :value value})))))
@ -298,17 +298,17 @@
(fn [index type value] (fn [index type value]
(cond (cond
(not (sm/valid-safe-int? index)) (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)) (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)) (and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value))) (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")) (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 :else
(let [type (keyword type)] (let [type (keyword type)]
@ -318,10 +318,10 @@
(fn [index] (fn [index]
(cond (cond
(not (sm/valid-safe-int? index)) (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")) (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 :else
(st/emit! (dwsl/remove-layout-track #{id} :row index)))) (st/emit! (dwsl/remove-layout-track #{id} :row index))))
@ -330,10 +330,10 @@
(fn [index] (fn [index]
(cond (cond
(not (sm/valid-safe-int? index)) (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")) (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 :else
(st/emit! (dwsl/remove-layout-track #{id} :column index)))) (st/emit! (dwsl/remove-layout-track #{id} :column index))))
@ -343,17 +343,17 @@
(let [type (keyword type)] (let [type (keyword type)]
(cond (cond
(not (sm/valid-safe-int? index)) (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)) (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)) (and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value))) (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")) (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 :else
(st/emit! (dwsl/change-layout-track #{id} :column index (d/without-nils {:type type :value value})))))) (st/emit! (dwsl/change-layout-track #{id} :column index (d/without-nils {:type type :value value}))))))
@ -363,17 +363,17 @@
(let [type (keyword type)] (let [type (keyword type)]
(cond (cond
(not (sm/valid-safe-int? index)) (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)) (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)) (and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value))) (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")) (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 :else
(st/emit! (dwsl/change-layout-track #{id} :row index (d/without-nils {:type type :value value})))))) (st/emit! (dwsl/change-layout-track #{id} :row index (d/without-nils {:type type :value value}))))))
@ -382,7 +382,7 @@
(fn [] (fn []
(cond (cond
(not (r/check-permission plugin-id "content:write")) (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 :else
(st/emit! (dwsl/remove-layout #{id})))) (st/emit! (dwsl/remove-layout #{id}))))
@ -391,16 +391,16 @@
(fn [child row column] (fn [child row column]
(cond (cond
(not (shape-proxy? child)) (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))) (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))) (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")) (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 :else
(let [child-id (obj/get child "$id")] (let [child-id (obj/get child "$id")]
@ -432,13 +432,13 @@
shape (u/proxy->shape self)] shape (u/proxy->shape self)]
(cond (cond
(not (sm/valid-safe-int? value)) (not (sm/valid-safe-int? value))
(u/display-not-valid :row-value value) (u/not-valid plugin-id :row-value value)
(nil? cell) (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")) (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 :else
(st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:row value})))))} (st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:row value})))))}
@ -452,13 +452,13 @@
cell (locate-cell self)] cell (locate-cell self)]
(cond (cond
(not (sm/valid-safe-int? value)) (not (sm/valid-safe-int? value))
(u/display-not-valid :rowSpan-value value) (u/not-valid plugin-id :rowSpan-value value)
(nil? cell) (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")) (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 :else
(st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:row-span value})))))} (st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:row-span value})))))}
@ -472,13 +472,13 @@
cell (locate-cell self)] cell (locate-cell self)]
(cond (cond
(not (sm/valid-safe-int? value)) (not (sm/valid-safe-int? value))
(u/display-not-valid :column-value value) (u/not-valid plugin-id :column-value value)
(nil? cell) (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")) (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 :else
(st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:column value})))))} (st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:column value})))))}
@ -492,13 +492,13 @@
cell (locate-cell self)] cell (locate-cell self)]
(cond (cond
(not (sm/valid-safe-int? value)) (not (sm/valid-safe-int? value))
(u/display-not-valid :columnSpan-value value) (u/not-valid plugin-id :columnSpan-value value)
(nil? cell) (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")) (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 :else
(st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:column-span value})))))} (st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:column-span value})))))}
@ -512,13 +512,13 @@
cell (locate-cell self)] cell (locate-cell self)]
(cond (cond
(not (string? value)) (not (string? value))
(u/display-not-valid :areaName-value value) (u/not-valid plugin-id :areaName-value value)
(nil? cell) (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")) (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 :else
(st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:area-name value})))))} (st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:area-name value})))))}
@ -533,13 +533,13 @@
value (keyword value)] value (keyword value)]
(cond (cond
(not (contains? ctl/grid-position-types value)) (not (contains? ctl/grid-position-types value))
(u/display-not-valid :position-value value) (u/not-valid plugin-id :position-value value)
(nil? cell) (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")) (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 :else
(st/emit! (dwsl/change-cells-mode (:parent-id shape) #{(:id cell)} value)))))} (st/emit! (dwsl/change-cells-mode (:parent-id shape) #{(:id cell)} value)))))}
@ -554,13 +554,13 @@
cell (locate-cell self)] cell (locate-cell self)]
(cond (cond
(not (contains? ctl/grid-cell-align-self-types value)) (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) (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")) (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 :else
(st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:align-self value})))))} (st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:align-self value})))))}
@ -575,13 +575,13 @@
cell (locate-cell self)] cell (locate-cell self)]
(cond (cond
(not (contains? ctl/grid-cell-justify-self-types value)) (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) (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")) (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 :else
(st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:justify-self value})))))}))) (st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:justify-self value})))))})))

View File

@ -24,7 +24,7 @@
(fn [] (fn []
(cond (cond
(not (r/check-permission plugin-id "content:write")) (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 :else
(let [id (js/Symbol)] (let [id (js/Symbol)]
@ -35,10 +35,10 @@
(fn [block-id] (fn [block-id]
(cond (cond
(not (r/check-permission plugin-id "content:write")) (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) (not block-id)
(u/display-not-valid :undoBlockFinish block-id) (u/not-valid plugin-id :undoBlockFinish block-id)
:else :else
(st/emit! (dwu/commit-undo-transaction block-id)))))) (st/emit! (dwu/commit-undo-transaction block-id))))))

View File

@ -60,10 +60,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (string? value)) (not (string? value))
(u/display-not-valid :name value) (u/not-valid plugin-id :name value)
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [color (u/proxy->library-color self) (let [color (u/proxy->library-color self)
@ -77,10 +77,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (string? value)) (not (string? value))
(u/display-not-valid :path value) (u/not-valid plugin-id :path value)
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [color (-> (u/proxy->library-color self) (let [color (-> (u/proxy->library-color self)
@ -94,10 +94,10 @@
(fn [self value] (fn [self value]
(cond (cond
(or (not (string? value)) (not (clr/valid-hex-color? value))) (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")) (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 :else
(let [color (-> (u/proxy->library-color self) (let [color (-> (u/proxy->library-color self)
@ -111,10 +111,10 @@
(fn [self value] (fn [self value]
(cond (cond
(or (not (number? value)) (< value 0) (> value 1)) (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")) (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 :else
(let [color (-> (u/proxy->library-color self) (let [color (-> (u/proxy->library-color self)
@ -129,10 +129,10 @@
(let [value (parser/parse-gradient value)] (let [value (parser/parse-gradient value)]
(cond (cond
(not (sm/validate clr/schema:gradient value)) (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")) (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 :else
(let [color (-> (u/proxy->library-color self) (let [color (-> (u/proxy->library-color self)
@ -147,10 +147,10 @@
(let [value (parser/parse-image-data value)] (let [value (parser/parse-image-data value)]
(cond (cond
(not (sm/validate clr/schema:image value)) (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")) (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 :else
(let [color (-> (u/proxy->library-color self) (let [color (-> (u/proxy->library-color self)
@ -161,7 +161,7 @@
(fn [] (fn []
(cond (cond
(not (r/check-permission plugin-id "library:write")) (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 :else
(st/emit! (dwl/delete-color {:id id})))) (st/emit! (dwl/delete-color {:id id}))))
@ -170,7 +170,7 @@
(fn [] (fn []
(cond (cond
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [color-id (uuid/next) (let [color-id (uuid/next)
@ -207,7 +207,7 @@
(fn [key] (fn [key]
(cond (cond
(not (string? key)) (not (string? key))
(u/display-not-valid :getPluginData-key key) (u/not-valid plugin-id :getPluginData-key key)
:else :else
(let [color (u/locate-library-color file-id id)] (let [color (u/locate-library-color file-id id)]
@ -217,16 +217,16 @@
(fn [key value] (fn [key value]
(cond (cond
(not= file-id (:current-file-id @st/state)) (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)) (not (string? key))
(u/display-not-valid :setPluginData-key key) (u/not-valid plugin-id :setPluginData-key key)
(and (some? value) (not (string? value))) (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")) (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 :else
(st/emit! (dp/set-plugin-data file-id :color id (keyword "plugin" (str plugin-id)) key value)))) (st/emit! (dp/set-plugin-data file-id :color id (keyword "plugin" (str plugin-id)) key value))))
@ -240,10 +240,10 @@
(fn [namespace key] (fn [namespace key]
(cond (cond
(not (string? namespace)) (not (string? namespace))
(u/display-not-valid :getSharedPluginData-namespace namespace) (u/not-valid plugin-id :getSharedPluginData-namespace namespace)
(not (string? key)) (not (string? key))
(u/display-not-valid :getSharedPluginData-key key) (u/not-valid plugin-id :getSharedPluginData-key key)
:else :else
(let [color (u/locate-library-color file-id id)] (let [color (u/locate-library-color file-id id)]
@ -253,19 +253,19 @@
(fn [namespace key value] (fn [namespace key value]
(cond (cond
(not= file-id (:current-file-id @st/state)) (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)) (not (string? namespace))
(u/display-not-valid :setSharedPluginData-namespace namespace) (u/not-valid plugin-id :setSharedPluginData-namespace namespace)
(not (string? key)) (not (string? key))
(u/display-not-valid :setSharedPluginData-key key) (u/not-valid plugin-id :setSharedPluginData-key key)
(and (some? value) (not (string? value))) (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")) (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 :else
(st/emit! (dp/set-plugin-data file-id :color id (keyword "shared" namespace) key value)))) (st/emit! (dp/set-plugin-data file-id :color id (keyword "shared" namespace) key value))))
@ -274,7 +274,7 @@
(fn [namespace] (fn [namespace]
(cond (cond
(not (string? namespace)) (not (string? namespace))
(u/display-not-valid :getSharedPluginDataKeys-namespace namespace) (u/not-valid plugin-id :getSharedPluginDataKeys-namespace namespace)
:else :else
(let [color (u/locate-library-color file-id id)] (let [color (u/locate-library-color file-id id)]
@ -301,10 +301,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (string? value)) (not (string? value))
(u/display-not-valid :name value) (u/not-valid plugin-id :name value)
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [typo (u/proxy->library-typography self) (let [typo (u/proxy->library-typography self)
@ -318,10 +318,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (string? value)) (not (string? value))
(u/display-not-valid :path value) (u/not-valid plugin-id :path value)
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [typo (-> (u/proxy->library-typography self) (let [typo (-> (u/proxy->library-typography self)
@ -335,10 +335,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (string? value)) (not (string? value))
(u/display-not-valid :fontId value) (u/not-valid plugin-id :fontId value)
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [typo (-> (u/proxy->library-typography self) (let [typo (-> (u/proxy->library-typography self)
@ -352,10 +352,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (string? value)) (not (string? value))
(u/display-not-valid :fontFamily value) (u/not-valid plugin-id :fontFamily value)
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [typo (-> (u/proxy->library-typography self) (let [typo (-> (u/proxy->library-typography self)
@ -369,10 +369,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (string? value)) (not (string? value))
(u/display-not-valid :fontVariantId value) (u/not-valid plugin-id :fontVariantId value)
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [typo (-> (u/proxy->library-typography self) (let [typo (-> (u/proxy->library-typography self)
@ -386,10 +386,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (string? value)) (not (string? value))
(u/display-not-valid :fontSize value) (u/not-valid plugin-id :fontSize value)
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [typo (-> (u/proxy->library-typography self) (let [typo (-> (u/proxy->library-typography self)
@ -403,10 +403,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (string? value)) (not (string? value))
(u/display-not-valid :fontWeight value) (u/not-valid plugin-id :fontWeight value)
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [typo (-> (u/proxy->library-typography self) (let [typo (-> (u/proxy->library-typography self)
@ -420,10 +420,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (string? value)) (not (string? value))
(u/display-not-valid :fontStyle value) (u/not-valid plugin-id :fontStyle value)
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [typo (-> (u/proxy->library-typography self) (let [typo (-> (u/proxy->library-typography self)
@ -437,10 +437,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (string? value)) (not (string? value))
(u/display-not-valid :lineHeight value) (u/not-valid plugin-id :lineHeight value)
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [typo (-> (u/proxy->library-typography self) (let [typo (-> (u/proxy->library-typography self)
@ -454,10 +454,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (string? value)) (not (string? value))
(u/display-not-valid :letterSpacing value) (u/not-valid plugin-id :letterSpacing value)
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [typo (-> (u/proxy->library-typography self) (let [typo (-> (u/proxy->library-typography self)
@ -471,10 +471,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (string? value)) (not (string? value))
(u/display-not-valid :textTransform value) (u/not-valid plugin-id :textTransform value)
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [typo (-> (u/proxy->library-typography self) (let [typo (-> (u/proxy->library-typography self)
@ -485,7 +485,7 @@
(fn [] (fn []
(cond (cond
(not (r/check-permission plugin-id "library:write")) (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 :else
(st/emit! (dwl/delete-typography {:id id})))) (st/emit! (dwl/delete-typography {:id id}))))
@ -494,7 +494,7 @@
(fn [] (fn []
(cond (cond
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [typo-id (uuid/next) (let [typo-id (uuid/next)
@ -507,10 +507,10 @@
(fn [shape] (fn [shape]
(cond (cond
(not (shape/shape-proxy? shape)) (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")) (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 :else
(let [shape-id (obj/get shape "$id") (let [shape-id (obj/get shape "$id")
@ -521,10 +521,10 @@
(fn [range] (fn [range]
(cond (cond
(not (text/text-range-proxy? range)) (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")) (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 :else
(let [shape-id (obj/get range "$id") (let [shape-id (obj/get range "$id")
@ -542,7 +542,7 @@
(fn [key] (fn [key]
(cond (cond
(not (string? key)) (not (string? key))
(u/display-not-valid :typography-plugin-data-key key) (u/not-valid plugin-id :typography-plugin-data-key key)
:else :else
(let [typography (u/locate-library-typography file-id id)] (let [typography (u/locate-library-typography file-id id)]
@ -552,16 +552,16 @@
(fn [key value] (fn [key value]
(cond (cond
(not= file-id (:current-file-id @st/state)) (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)) (not (string? key))
(u/display-not-valid :setPluginData-key key) (u/not-valid plugin-id :setPluginData-key key)
(and (some? value) (not (string? value))) (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")) (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 :else
(st/emit! (dp/set-plugin-data file-id :typography id (keyword "plugin" (str plugin-id)) key value)))) (st/emit! (dp/set-plugin-data file-id :typography id (keyword "plugin" (str plugin-id)) key value))))
@ -575,10 +575,10 @@
(fn [namespace key] (fn [namespace key]
(cond (cond
(not (string? namespace)) (not (string? namespace))
(u/display-not-valid :getSharedPluginData-namespace namespace) (u/not-valid plugin-id :getSharedPluginData-namespace namespace)
(not (string? key)) (not (string? key))
(u/display-not-valid :getSharedPluginData-key key) (u/not-valid plugin-id :getSharedPluginData-key key)
:else :else
(let [typography (u/locate-library-typography file-id id)] (let [typography (u/locate-library-typography file-id id)]
@ -588,19 +588,19 @@
(fn [namespace key value] (fn [namespace key value]
(cond (cond
(not= file-id (:current-file-id @st/state)) (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)) (not (string? namespace))
(u/display-not-valid :setSharedPluginData-namespace namespace) (u/not-valid plugin-id :setSharedPluginData-namespace namespace)
(not (string? key)) (not (string? key))
(u/display-not-valid :setSharedPluginData-key key) (u/not-valid plugin-id :setSharedPluginData-key key)
(and (some? value) (not (string? value))) (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")) (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 :else
(st/emit! (dp/set-plugin-data file-id :typography id (keyword "shared" namespace) key value)))) (st/emit! (dp/set-plugin-data file-id :typography id (keyword "shared" namespace) key value))))
@ -609,7 +609,7 @@
(fn [namespace] (fn [namespace]
(cond (cond
(not (string? namespace)) (not (string? namespace))
(u/display-not-valid :getSharedPluginDataKeys-namespace namespace) (u/not-valid plugin-id :getSharedPluginDataKeys-namespace namespace)
:else :else
(let [typography (u/locate-library-typography file-id id)] (let [typography (u/locate-library-typography file-id id)]
@ -674,7 +674,7 @@
:removeProperty :removeProperty
(fn [pos] (fn [pos]
(if (not (nat-int? pos)) (if (not (nat-int? pos))
(u/display-not-valid :pos pos) (u/not-valid plugin-id :pos pos)
(st/emit! (st/emit!
(ev/event {::ev/name "remove-property" ::ev/origin "plugin:remove-property"}) (ev/event {::ev/name "remove-property" ::ev/origin "plugin:remove-property"})
(dwv/remove-property id pos)))) (dwv/remove-property id pos))))
@ -683,10 +683,10 @@
(fn [pos name] (fn [pos name]
(cond (cond
(not (nat-int? pos)) (not (nat-int? pos))
(u/display-not-valid :pos pos) (u/not-valid plugin-id :pos pos)
(not (string? name)) (not (string? name))
(u/display-not-valid :name name) (u/not-valid plugin-id :name name)
:else :else
(st/emit! (st/emit!
@ -715,10 +715,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (string? value)) (not (string? value))
(u/display-not-valid :name value) (u/not-valid plugin-id :name value)
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [component (u/proxy->library-component self) (let [component (u/proxy->library-component self)
@ -732,10 +732,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (string? value)) (not (string? value))
(u/display-not-valid :path value) (u/not-valid plugin-id :path value)
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [component (u/proxy->library-component self) (let [component (u/proxy->library-component self)
@ -746,7 +746,7 @@
(fn [] (fn []
(cond (cond
(not (r/check-permission plugin-id "library:write")) (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 :else
(st/emit! (dwl/delete-component {:id id})))) (st/emit! (dwl/delete-component {:id id}))))
@ -755,7 +755,7 @@
(fn [] (fn []
(cond (cond
(not (r/check-permission plugin-id "content:write")) (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 :else
(let [id-ref (atom nil)] (let [id-ref (atom nil)]
@ -766,7 +766,7 @@
(fn [key] (fn [key]
(cond (cond
(not (string? key)) (not (string? key))
(u/display-not-valid :component-plugin-data-key key) (u/not-valid plugin-id :component-plugin-data-key key)
:else :else
(let [component (u/locate-library-component file-id id)] (let [component (u/locate-library-component file-id id)]
@ -776,16 +776,16 @@
(fn [key value] (fn [key value]
(cond (cond
(not= file-id (:current-file-id @st/state)) (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)) (not (string? key))
(u/display-not-valid :setPluginData-key key) (u/not-valid plugin-id :setPluginData-key key)
(and (some? value) (not (string? value))) (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")) (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 :else
(st/emit! (dp/set-plugin-data file-id :component id (keyword "plugin" (str plugin-id)) key value)))) (st/emit! (dp/set-plugin-data file-id :component id (keyword "plugin" (str plugin-id)) key value))))
@ -799,10 +799,10 @@
(fn [namespace key] (fn [namespace key]
(cond (cond
(not (string? namespace)) (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)) (not (string? key))
(u/display-not-valid :component-plugin-data-key key) (u/not-valid plugin-id :component-plugin-data-key key)
:else :else
(let [component (u/locate-library-component file-id id)] (let [component (u/locate-library-component file-id id)]
@ -812,19 +812,19 @@
(fn [namespace key value] (fn [namespace key value]
(cond (cond
(not= file-id (:current-file-id @st/state)) (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)) (not (string? namespace))
(u/display-not-valid :setSharedPluginData-namespace namespace) (u/not-valid plugin-id :setSharedPluginData-namespace namespace)
(not (string? key)) (not (string? key))
(u/display-not-valid :setSharedPluginData-key key) (u/not-valid plugin-id :setSharedPluginData-key key)
(and (some? value) (not (string? value))) (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")) (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 :else
(st/emit! (dp/set-plugin-data file-id :component id (keyword "shared" namespace) key value)))) (st/emit! (dp/set-plugin-data file-id :component id (keyword "shared" namespace) key value))))
@ -833,7 +833,7 @@
(fn [namespace] (fn [namespace]
(cond (cond
(not (string? namespace)) (not (string? namespace))
(u/display-not-valid :component-plugin-data-namespace namespace) (u/not-valid plugin-id :component-plugin-data-namespace namespace)
:else :else
(let [component (u/locate-library-component file-id id)] (let [component (u/locate-library-component file-id id)]
@ -901,10 +901,10 @@
(fn [pos value] (fn [pos value]
(cond (cond
(not (nat-int? pos)) (not (nat-int? pos))
(u/display-not-valid :pos (str pos)) (u/not-valid plugin-id :pos (str pos))
(not (string? value)) (not (string? value))
(u/display-not-valid :name value) (u/not-valid plugin-id :name value)
:else :else
(st/emit! (st/emit!
@ -970,7 +970,7 @@
(fn [] (fn []
(cond (cond
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [color-id (uuid/next)] (let [color-id (uuid/next)]
@ -981,7 +981,7 @@
(fn [] (fn []
(cond (cond
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [typography-id (uuid/next)] (let [typography-id (uuid/next)]
@ -992,7 +992,7 @@
(fn [shapes] (fn [shapes]
(cond (cond
(not (r/check-permission plugin-id "library:write")) (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 :else
(let [id-ref (atom nil) (let [id-ref (atom nil)
@ -1005,7 +1005,7 @@
(fn [key] (fn [key]
(cond (cond
(not (string? key)) (not (string? key))
(u/display-not-valid :file-plugin-data-key key) (u/not-valid plugin-id :file-plugin-data-key key)
:else :else
(let [file (u/locate-file file-id)] (let [file (u/locate-file file-id)]
@ -1015,13 +1015,13 @@
(fn [key value] (fn [key value]
(cond (cond
(not (string? key)) (not (string? key))
(u/display-not-valid :setPluginData-key key) (u/not-valid plugin-id :setPluginData-key key)
(and (some? value) (not (string? value))) (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")) (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 :else
(st/emit! (dp/set-plugin-data file-id :file (keyword "plugin" (str plugin-id)) key value)))) (st/emit! (dp/set-plugin-data file-id :file (keyword "plugin" (str plugin-id)) key value))))
@ -1035,10 +1035,10 @@
(fn [namespace key] (fn [namespace key]
(cond (cond
(not (string? namespace)) (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)) (not (string? key))
(u/display-not-valid :file-plugin-data-key key) (u/not-valid plugin-id :file-plugin-data-key key)
:else :else
(let [file (u/locate-file file-id)] (let [file (u/locate-file file-id)]
@ -1048,16 +1048,16 @@
(fn [namespace key value] (fn [namespace key value]
(cond (cond
(not (string? namespace)) (not (string? namespace))
(u/display-not-valid :setSharedPluginData-namespace namespace) (u/not-valid plugin-id :setSharedPluginData-namespace namespace)
(not (string? key)) (not (string? key))
(u/display-not-valid :setSharedPluginData-key key) (u/not-valid plugin-id :setSharedPluginData-key key)
(and (some? value) (not (string? value))) (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")) (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 :else
(st/emit! (dp/set-plugin-data file-id :file (keyword "shared" namespace) key value)))) (st/emit! (dp/set-plugin-data file-id :file (keyword "shared" namespace) key value))))
@ -1066,7 +1066,7 @@
(fn [namespace] (fn [namespace]
(cond (cond
(not (string? namespace)) (not (string? namespace))
(u/display-not-valid :namespace namespace) (u/not-valid plugin-id :namespace namespace)
:else :else
(let [file (u/locate-file file-id)] (let [file (u/locate-file file-id)]
@ -1110,14 +1110,14 @@
(fn [library-id] (fn [library-id]
(cond (cond
(not (r/check-permission plugin-id "library:write")) (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 :else
(js/Promise. (js/Promise.
(fn [resolve reject] (fn [resolve reject]
(cond (cond
(not (string? library-id)) (not (string? library-id))
(do (u/display-not-valid :connectLibrary library-id) (do (u/not-valid plugin-id :connectLibrary library-id)
(reject nil)) (reject nil))
:else :else

View File

@ -30,10 +30,10 @@
(fn [key] (fn [key]
(cond (cond
(not (r/check-permission plugin-id "allow:localstorage")) (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)) (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 :else
(.getItem ^js local-storage (prefix-key plugin-id key)))) (.getItem ^js local-storage (prefix-key plugin-id key))))
@ -42,10 +42,10 @@
(fn [key value] (fn [key value]
(cond (cond
(not (r/check-permission plugin-id "allow:localstorage")) (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)) (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 :else
(.setItem ^js local-storage (prefix-key plugin-id key) value))) (.setItem ^js local-storage (prefix-key plugin-id key) value)))
@ -54,10 +54,10 @@
(fn [key] (fn [key]
(cond (cond
(not (r/check-permission plugin-id "allow:localstorage")) (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)) (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 :else
(.getItem ^js local-storage (prefix-key plugin-id key)))) (.getItem ^js local-storage (prefix-key plugin-id key))))

View File

@ -59,7 +59,7 @@
(fn [_ value] (fn [_ value]
(cond (cond
(or (not (string? value)) (empty? value)) (or (not (string? value)) (empty? value))
(u/display-not-valid :name value) (u/not-valid plugin-id :name value)
:else :else
(st/emit! (dwi/update-flow page-id id #(assoc % :name value)))))} (st/emit! (dwi/update-flow page-id id #(assoc % :name value)))))}
@ -74,7 +74,7 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (shape/shape-proxy? value)) (not (shape/shape-proxy? value))
(u/display-not-valid :startingBoard value) (u/not-valid plugin-id :startingBoard value)
:else :else
(st/emit! (dwi/update-flow page-id id #(assoc % :starting-frame (obj/get value "$id"))))))} (st/emit! (dwi/update-flow page-id id #(assoc % :starting-frame (obj/get value "$id"))))))}
@ -103,10 +103,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (string? value)) (not (string? value))
(u/display-not-valid :name value) (u/not-valid plugin-id :name value)
(not (r/check-permission plugin-id "content:write")) (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 :else
(st/emit! (dw/rename-page id value))))} (st/emit! (dw/rename-page id value))))}
@ -127,10 +127,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(or (not (string? value)) (not (cc/valid-hex-color? value))) (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")) (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 :else
(st/emit! (dw/change-canvas-color id {:color value}))))} (st/emit! (dw/change-canvas-color id {:color value}))))}
@ -158,7 +158,7 @@
(fn [shape-id] (fn [shape-id]
(cond (cond
(not (string? shape-id)) (not (string? shape-id))
(u/display-not-valid :getShapeById shape-id) (u/not-valid plugin-id :getShapeById shape-id)
:else :else
(let [shape-id (uuid/parse shape-id) (let [shape-id (uuid/parse shape-id)
@ -195,7 +195,7 @@
(fn [key] (fn [key]
(cond (cond
(not (string? key)) (not (string? key))
(u/display-not-valid :page-plugin-data-key key) (u/not-valid plugin-id :page-plugin-data-key key)
:else :else
(let [page (u/locate-page file-id id)] (let [page (u/locate-page file-id id)]
@ -205,13 +205,13 @@
(fn [key value] (fn [key value]
(cond (cond
(not (string? key)) (not (string? key))
(u/display-not-valid :setPluginData-key key) (u/not-valid plugin-id :setPluginData-key key)
(and (some? value) (not (string? value))) (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")) (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 :else
(st/emit! (dp/set-plugin-data file-id :page id (keyword "plugin" (str plugin-id)) key value)))) (st/emit! (dp/set-plugin-data file-id :page id (keyword "plugin" (str plugin-id)) key value))))
@ -225,10 +225,10 @@
(fn [namespace key] (fn [namespace key]
(cond (cond
(not (string? namespace)) (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)) (not (string? key))
(u/display-not-valid :page-plugin-data-key key) (u/not-valid plugin-id :page-plugin-data-key key)
:else :else
(let [page (u/locate-page file-id id)] (let [page (u/locate-page file-id id)]
@ -238,16 +238,16 @@
(fn [namespace key value] (fn [namespace key value]
(cond (cond
(not (string? namespace)) (not (string? namespace))
(u/display-not-valid :setSharedPluginData-namespace namespace) (u/not-valid plugin-id :setSharedPluginData-namespace namespace)
(not (string? key)) (not (string? key))
(u/display-not-valid :setSharedPluginData-key key) (u/not-valid plugin-id :setSharedPluginData-key key)
(and (some? value) (not (string? value))) (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")) (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 :else
(st/emit! (dp/set-plugin-data file-id :page id (keyword "shared" namespace) key value)))) (st/emit! (dp/set-plugin-data file-id :page id (keyword "shared" namespace) key value))))
@ -256,7 +256,7 @@
(fn [self namespace] (fn [self namespace]
(cond (cond
(not (string? namespace)) (not (string? namespace))
(u/display-not-valid :page-plugin-data-namespace namespace) (u/not-valid plugin-id :page-plugin-data-namespace namespace)
:else :else
(let [page (u/proxy->page self)] (let [page (u/proxy->page self)]
@ -266,7 +266,7 @@
(fn [new-window] (fn [new-window]
(cond (cond
(not (r/check-permission plugin-id "content:read")) (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 :else
(let [new-window (if (boolean? new-window) new-window false)] (let [new-window (if (boolean? new-window) new-window false)]
@ -276,10 +276,10 @@
(fn [name frame] (fn [name frame]
(cond (cond
(or (not (string? name)) (empty? name)) (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)) (not (shape/shape-proxy? frame))
(u/display-not-valid :createFlow-frame frame) (u/not-valid plugin-id :createFlow-frame frame)
:else :else
(let [flow-id (uuid/next)] (let [flow-id (uuid/next)]
@ -290,7 +290,7 @@
(fn [flow] (fn [flow]
(cond (cond
(not (flow-proxy? flow)) (not (flow-proxy? flow))
(u/display-not-valid :removeFlow-flow flow) (u/not-valid plugin-id :removeFlow-flow flow)
:else :else
(st/emit! (dwi/remove-flow id (obj/get flow "$id"))))) (st/emit! (dwi/remove-flow id (obj/get flow "$id")))))
@ -300,18 +300,18 @@
(let [shape (u/proxy->shape board)] (let [shape (u/proxy->shape board)]
(cond (cond
(not (sm/valid-safe-number? value)) (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)) (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) (and (some? shape)
(or (not (shape/shape-proxy? board)) (or (not (shape/shape-proxy? board))
(not (cfh/frame-shape? shape)))) (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")) (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 :else
(let [ruler-id (uuid/next)] (let [ruler-id (uuid/next)]
@ -328,10 +328,10 @@
(fn [value] (fn [value]
(cond (cond
(not (rg/ruler-guide-proxy? value)) (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")) (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 :else
(let [guide (u/proxy->ruler-guide value)] (let [guide (u/proxy->ruler-guide value)]
@ -343,17 +343,17 @@
position (parser/parse-point position)] position (parser/parse-point position)]
(cond (cond
(or (not (string? content)) (empty? content)) (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))) (or (not (sm/valid-safe-number? (:x position)))
(not (sm/valid-safe-number? (:y 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)))) (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")) (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 :else
(let [position (let [position
@ -378,10 +378,10 @@
(fn [thread] (fn [thread]
(cond (cond
(not (pc/comment-thread-proxy? thread)) (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")) (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 :else
(js/Promise. (js/Promise.
@ -400,7 +400,7 @@
(cond (cond
(not (r/check-permission plugin-id "comment:read")) (not (r/check-permission plugin-id "comment:read"))
(do (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")) (reject "Plugin doesn't have 'comment:read' permission"))
:else :else

View File

@ -14,10 +14,10 @@
[app.plugins.utils :as u])) [app.plugins.utils :as u]))
(defn ^:export centerShapes (defn ^:export centerShapes
[shapes] [plugin-id shapes]
(cond (cond
(not (every? shape/shape-proxy? shapes)) (not (every? shape/shape-proxy? shapes))
(u/display-not-valid :centerShapes shapes) (u/not-valid plugin-id :centerShapes shapes)
:else :else
(let [shapes (->> shapes (map u/proxy->shape))] (let [shapes (->> shapes (map u/proxy->shape))]

View File

@ -17,6 +17,10 @@
[app.util.object :as obj] [app.util.object :as obj]
[beicon.v2.core :as rx])) [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 ;; Stores the installed plugins information
(defonce ^:private registry (atom {})) (defonce ^:private registry (atom {}))
@ -78,6 +82,7 @@
(d/without-nils (d/without-nils
{:plugin-id plugin-id {:plugin-id plugin-id
:url (str plugin-url) :url (str plugin-url)
:version vers
:name name :name name
:description desc :description desc
:host origin :host origin
@ -127,5 +132,6 @@
(defn check-permission (defn check-permission
[plugin-id permission] [plugin-id permission]
(or (= plugin-id "00000000-0000-0000-0000-000000000000") (or (= plugin-id "00000000-0000-0000-0000-000000000000")
(= plugin-id mcp-plugin-id)
(let [{:keys [permissions]} (dm/get-in @registry [:data plugin-id])] (let [{:keys [permissions]} (dm/get-in @registry [:data plugin-id])]
(contains? permissions permission)))) (contains? permissions permission))))

View File

@ -44,13 +44,13 @@
(let [shape (u/locate-shape file-id page-id (obj/get value "$id"))] (let [shape (u/locate-shape file-id page-id (obj/get value "$id"))]
(cond (cond
(not (shape-proxy? value)) (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)) (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")) (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 :else
(let [board-id (when value (obj/get value "$id")) (let [board-id (when value (obj/get value "$id"))
@ -78,10 +78,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (sm/valid-safe-number? value)) (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")) (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 :else
(let [guide (u/proxy->ruler-guide self) (let [guide (u/proxy->ruler-guide self)

View File

@ -31,8 +31,8 @@
[app.common.types.shape.radius :as ctsr] [app.common.types.shape.radius :as ctsr]
[app.common.types.shape.shadow :as ctss] [app.common.types.shape.shadow :as ctss]
[app.common.types.text :as txt] [app.common.types.text :as txt]
[app.common.types.token :as cto]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.main.data.plugins :as dp] [app.main.data.plugins :as dp]
[app.main.data.workspace :as dw] [app.main.data.workspace :as dw]
[app.main.data.workspace.groups :as dwg] [app.main.data.workspace.groups :as dwg]
@ -47,7 +47,6 @@
[app.main.data.workspace.variants :as dwv] [app.main.data.workspace.variants :as dwv]
[app.main.repo :as rp] [app.main.repo :as rp]
[app.main.store :as st] [app.main.store :as st]
[app.plugins.flags :refer [natural-child-ordering?]]
[app.plugins.flex :as flex] [app.plugins.flex :as flex]
[app.plugins.format :as format] [app.plugins.format :as format]
[app.plugins.grid :as grid] [app.plugins.grid :as grid]
@ -55,6 +54,7 @@
[app.plugins.register :as r] [app.plugins.register :as r]
[app.plugins.ruler-guides :as rg] [app.plugins.ruler-guides :as rg]
[app.plugins.text :as text] [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.plugins.utils :as u]
[app.util.http :as http] [app.util.http :as http]
[app.util.object :as obj] [app.util.object :as obj]
@ -91,7 +91,7 @@
(let [value (parser/parse-keyword value)] (let [value (parser/parse-keyword value)]
(cond (cond
(not (contains? ctsi/event-types value)) (not (contains? ctsi/event-types value))
(u/display-not-valid :trigger value) (u/not-valid plugin-id :trigger value)
:else :else
(st/emit! (dwi/update-interaction (st/emit! (dwi/update-interaction
@ -107,7 +107,7 @@
(fn [_ value] (fn [_ value]
(cond (cond
(or (not (number? value)) (not (pos? value))) (or (not (number? value)) (not (pos? value)))
(u/display-not-valid :delay value) (u/not-valid plugin-id :delay value)
:else :else
(st/emit! (dwi/update-interaction (st/emit! (dwi/update-interaction
@ -127,7 +127,7 @@
(d/patch-object params))] (d/patch-object params))]
(cond (cond
(not (sm/validate ctsi/schema:interaction interaction)) (not (sm/validate ctsi/schema:interaction interaction))
(u/display-not-valid :action interaction) (u/not-valid plugin-id :action interaction)
:else :else
(st/emit! (dwi/update-interaction (st/emit! (dwi/update-interaction
@ -192,7 +192,8 @@
(assert (uuid? id)) (assert (uuid? id))
(let [data (u/locate-shape file-id page-id 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)} :$plugin {:enumerable false :get (fn [] plugin-id)}
:$id {:enumerable false :get (fn [] id)} :$id {:enumerable false :get (fn [] id)}
:$file {:enumerable false :get (fn [] file-id)} :$file {:enumerable false :get (fn [] file-id)}
@ -218,10 +219,10 @@
(not (str/blank? value)))] (not (str/blank? value)))]
(cond (cond
(not (r/check-permission plugin-id "content:write")) (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?) (not valid?)
(u/display-not-valid :name value) (u/not-valid plugin-id :name value)
:else :else
(st/emit! (dw/rename-shape-or-variant file-id page-id id value)))))} (st/emit! (dw/rename-shape-or-variant file-id page-id id value)))))}
@ -233,10 +234,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (boolean? value)) (not (boolean? value))
(u/display-not-valid :blocked value) (u/not-valid plugin-id :blocked value)
(not (r/check-permission plugin-id "content:write")) (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 :else
(let [id (obj/get self "$id")] (let [id (obj/get self "$id")]
@ -249,10 +250,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (boolean? value)) (not (boolean? value))
(u/display-not-valid :hidden value) (u/not-valid plugin-id :hidden value)
(not (r/check-permission plugin-id "content:write")) (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 :else
(let [id (obj/get self "$id")] (let [id (obj/get self "$id")]
@ -265,10 +266,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (boolean? value)) (not (boolean? value))
(u/display-not-valid :visible value) (u/not-valid plugin-id :visible value)
(not (r/check-permission plugin-id "content:write")) (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 :else
(let [id (obj/get self "$id")] (let [id (obj/get self "$id")]
@ -281,10 +282,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (boolean? value)) (not (boolean? value))
(u/display-not-valid :proportionLock value) (u/not-valid plugin-id :proportionLock value)
(not (r/check-permission plugin-id "content:write")) (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 :else
(let [id (obj/get self "$id")] (let [id (obj/get self "$id")]
@ -299,10 +300,10 @@
value (keyword value)] value (keyword value)]
(cond (cond
(not (contains? cts/horizontal-constraint-types value)) (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")) (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 :else
(st/emit! (dwsh/update-shapes [id] #(assoc % :constraints-h value))))))} (st/emit! (dwsh/update-shapes [id] #(assoc % :constraints-h value))))))}
@ -316,10 +317,10 @@
value (keyword value)] value (keyword value)]
(cond (cond
(not (contains? cts/vertical-constraint-types value)) (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")) (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 :else
(st/emit! (dwsh/update-shapes [id] #(assoc % :constraints-v value))))))} (st/emit! (dwsh/update-shapes [id] #(assoc % :constraints-v value))))))}
@ -332,10 +333,10 @@
(let [id (obj/get self "$id")] (let [id (obj/get self "$id")]
(cond (cond
(or (not (sm/valid-safe-int? value)) (< value 0)) (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")) (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 :else
(st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-all-corners % value))))))} (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-all-corners % value))))))}
@ -348,10 +349,10 @@
(let [id (obj/get self "$id")] (let [id (obj/get self "$id")]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r1 value))))))} (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r1 value))))))}
@ -364,10 +365,10 @@
(let [id (obj/get self "$id")] (let [id (obj/get self "$id")]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r2 value))))))} (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r2 value))))))}
@ -380,10 +381,10 @@
(let [id (obj/get self "$id")] (let [id (obj/get self "$id")]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r3 value))))))} (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r3 value))))))}
@ -396,10 +397,10 @@
(let [id (obj/get self "$id")] (let [id (obj/get self "$id")]
(cond (cond
(not (sm/valid-safe-int? value)) (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")) (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 :else
(st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r4 value))))))} (st/emit! (dwsh/update-shapes [id] #(ctsr/set-radius-to-single-corner % :r4 value))))))}
@ -412,10 +413,10 @@
(let [id (obj/get self "$id")] (let [id (obj/get self "$id")]
(cond (cond
(or (not (sm/valid-safe-number? value)) (< value 0) (> value 1)) (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")) (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 :else
(st/emit! (dwsh/update-shapes [id] #(assoc % :opacity value))))))} (st/emit! (dwsh/update-shapes [id] #(assoc % :opacity value))))))}
@ -429,10 +430,10 @@
value (keyword value)] value (keyword value)]
(cond (cond
(not (contains? cts/blend-modes value)) (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")) (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 :else
(st/emit! (dwsh/update-shapes [id] #(assoc % :blend-mode value))))))} (st/emit! (dwsh/update-shapes [id] #(assoc % :blend-mode value))))))}
@ -446,10 +447,10 @@
value (mapv #(shadow-defaults (parser/parse-shadow %)) value)] value (mapv #(shadow-defaults (parser/parse-shadow %)) value)]
(cond (cond
(not (sm/validate [:vector ctss/schema:shadow] value)) (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")) (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 :else
(st/emit! (dwsh/update-shapes [id] #(assoc % :shadow value))))))} (st/emit! (dwsh/update-shapes [id] #(assoc % :shadow value))))))}
@ -465,10 +466,10 @@
value (blur-defaults (parser/parse-blur value))] value (blur-defaults (parser/parse-blur value))]
(cond (cond
(not (sm/validate ctsb/schema:blur value)) (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")) (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 :else
(st/emit! (dwsh/update-shapes [id] #(assoc % :blur value)))))))} (st/emit! (dwsh/update-shapes [id] #(assoc % :blur value)))))))}
@ -482,10 +483,10 @@
value (parser/parse-exports value)] value (parser/parse-exports value)]
(cond (cond
(not (sm/validate [:vector ctse/schema:export] value)) (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")) (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 :else
(st/emit! (dwsh/update-shapes [id] #(assoc % :exports value))))))} (st/emit! (dwsh/update-shapes [id] #(assoc % :exports value))))))}
@ -499,10 +500,10 @@
(let [id (obj/get self "$id")] (let [id (obj/get self "$id")]
(cond (cond
(not (sm/valid-safe-number? value)) (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")) (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 :else
(st/emit! (dw/update-position id (st/emit! (dw/update-position id
@ -517,10 +518,10 @@
(let [id (obj/get self "$id")] (let [id (obj/get self "$id")]
(cond (cond
(not (sm/valid-safe-number? value)) (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")) (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 :else
(st/emit! (dw/update-position id (st/emit! (dw/update-position id
@ -562,10 +563,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (sm/valid-safe-number? value)) (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")) (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 :else
(let [id (obj/get self "$id") (let [id (obj/get self "$id")
@ -589,10 +590,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (sm/valid-safe-number? value)) (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")) (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 :else
(let [id (obj/get self "$id") (let [id (obj/get self "$id")
@ -616,10 +617,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (sm/valid-safe-number? value)) (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")) (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 :else
(let [id (obj/get self "$id") (let [id (obj/get self "$id")
@ -643,10 +644,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (sm/valid-safe-number? value)) (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")) (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 :else
(let [id (obj/get self "$id") (let [id (obj/get self "$id")
@ -680,10 +681,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (number? value)) (not (number? value))
(u/display-not-valid :rotation value) (u/not-valid plugin-id :rotation value)
(not (r/check-permission plugin-id "content:write")) (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 :else
(let [shape (u/proxy->shape self)] (let [shape (u/proxy->shape self)]
@ -696,10 +697,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (boolean? value)) (not (boolean? value))
(u/display-not-valid :flipX value) (u/not-valid plugin-id :flipX value)
(not (r/check-permission plugin-id "content:write")) (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 :else
(let [id (obj/get self "$id")] (let [id (obj/get self "$id")]
@ -712,10 +713,10 @@
(fn [self value] (fn [self value]
(cond (cond
(not (boolean? value)) (not (boolean? value))
(u/display-not-valid :flipY value) (u/not-valid plugin-id :flipY value)
(not (r/check-permission plugin-id "content:write")) (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 :else
(let [id (obj/get self "$id")] (let [id (obj/get self "$id")]
@ -734,13 +735,13 @@
value (parser/parse-fills value)] value (parser/parse-fills value)]
(cond (cond
(not (sm/validate [:vector types.fills/schema:fill] value)) (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) (cfh/text-shape? shape)
(st/emit! (dwt/update-attrs id {:fills value})) (st/emit! (dwt/update-attrs id {:fills value}))
(not (r/check-permission plugin-id "content:write")) (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 :else
(st/emit! (dwsh/update-shapes [id] #(assoc % :fills value))))))} (st/emit! (dwsh/update-shapes [id] #(assoc % :fills value))))))}
@ -754,10 +755,10 @@
value (parser/parse-strokes value)] value (parser/parse-strokes value)]
(cond (cond
(not (sm/validate [:vector cts/schema:stroke] value)) (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")) (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 :else
(st/emit! (dwsh/update-shapes [id] #(assoc % :strokes value))))))} (st/emit! (dwsh/update-shapes [id] #(assoc % :strokes value))))))}
@ -802,13 +803,13 @@
(fn [width height] (fn [width height]
(cond (cond
(or (not (sm/valid-safe-number? width)) (<= width 0)) (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)) (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")) (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 :else
(st/emit! (dw/update-dimensions [id] :width width) (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")})] (let [center (when center {:x (obj/get center "x") :y (obj/get center "y")})]
(cond (cond
(not (number? angle)) (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))))) (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")) (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 :else
(st/emit! (dw/increase-rotation [id] angle {:center center :delta? true}))))) (st/emit! (dw/increase-rotation [id] angle {:center center :delta? true})))))
@ -835,7 +836,7 @@
(let [ret-v (atom nil)] (let [ret-v (atom nil)]
(cond (cond
(not (r/check-permission plugin-id "content:write")) (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 :else
(do (st/emit! (dws/duplicate-shapes #{id} :change-selection? false :return-ref ret-v)) (do (st/emit! (dws/duplicate-shapes #{id} :change-selection? false :return-ref ret-v))
@ -845,7 +846,7 @@
(fn [] (fn []
(cond (cond
(not (r/check-permission plugin-id "content:write")) (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 :else
(st/emit! (dwsh/delete-shapes #{id})))) (st/emit! (dwsh/delete-shapes #{id}))))
@ -855,7 +856,7 @@
(fn [key] (fn [key]
(cond (cond
(not (string? key)) (not (string? key))
(u/display-not-valid :getPluginData key) (u/not-valid plugin-id :getPluginData key)
:else :else
(let [shape (u/locate-shape file-id page-id id)] (let [shape (u/locate-shape file-id page-id id)]
@ -865,13 +866,13 @@
(fn [key value] (fn [key value]
(cond (cond
(not (string? key)) (not (string? key))
(u/display-not-valid :setPluginData-key key) (u/not-valid plugin-id :setPluginData-key key)
(and (some? value) (not (string? value))) (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")) (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 :else
(st/emit! (dp/set-plugin-data file-id :shape id page-id (keyword "plugin" (str plugin-id)) key value)))) (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] (fn [namespace key]
(cond (cond
(not (string? namespace)) (not (string? namespace))
(u/display-not-valid :getSharedPluginData-namespace namespace) (u/not-valid plugin-id :getSharedPluginData-namespace namespace)
(not (string? key)) (not (string? key))
(u/display-not-valid :getSharedPluginData-key key) (u/not-valid plugin-id :getSharedPluginData-key key)
:else :else
(let [shape (u/locate-shape file-id page-id id)] (let [shape (u/locate-shape file-id page-id id)]
@ -898,16 +899,16 @@
(fn [namespace key value] (fn [namespace key value]
(cond (cond
(not (string? namespace)) (not (string? namespace))
(u/display-not-valid :setSharedPluginData-namespace namespace) (u/not-valid plugin-id :setSharedPluginData-namespace namespace)
(not (string? key)) (not (string? key))
(u/display-not-valid :setSharedPluginData-key key) (u/not-valid plugin-id :setSharedPluginData-key key)
(and (some? value) (not (string? value))) (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")) (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 :else
(st/emit! (dp/set-plugin-data file-id :shape id page-id (keyword "shared" namespace) key value)))) (st/emit! (dp/set-plugin-data file-id :shape id page-id (keyword "shared" namespace) key value))))
@ -916,7 +917,7 @@
(fn [namespace] (fn [namespace]
(cond (cond
(not (string? namespace)) (not (string? namespace))
(u/display-not-valid :getSharedPluginDataKeys namespace) (u/not-valid plugin-id :getSharedPluginDataKeys namespace)
:else :else
(let [shape (u/locate-shape file-id page-id id)] (let [shape (u/locate-shape file-id page-id id)]
@ -931,12 +932,12 @@
(not (cfh/group-shape? shape)) (not (cfh/group-shape? shape))
(not (cfh/svg-raw-shape? shape)) (not (cfh/svg-raw-shape? shape))
(not (cfh/bool-shape? shape))) (not (cfh/bool-shape? shape)))
(u/display-not-valid :getChildren (:type shape)) (u/not-valid plugin-id :getChildren (:type shape))
:else :else
(let [is-reversed? (ctl/flex-layout? shape) (let [is-reversed? (ctl/flex-layout? shape)
reverse-fn reverse-fn
(if (and (natural-child-ordering? plugin-id) is-reversed?) (if (and (u/natural-child-ordering? plugin-id) is-reversed?)
reverse identity)] reverse identity)]
(->> (u/locate-shape file-id page-id id) (->> (u/locate-shape file-id page-id id)
(:shapes) (:shapes)
@ -951,19 +952,19 @@
(not (cfh/group-shape? shape)) (not (cfh/group-shape? shape))
(not (cfh/svg-raw-shape? shape)) (not (cfh/svg-raw-shape? shape))
(not (cfh/bool-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)) (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")) (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 :else
(let [child-id (obj/get child "$id") (let [child-id (obj/get child "$id")
is-reversed? (ctl/flex-layout? shape) is-reversed? (ctl/flex-layout? shape)
index index
(if (or (not (natural-child-ordering? plugin-id)) is-reversed?) (if (or (not (u/natural-child-ordering? plugin-id)) is-reversed?)
0 0
(count (:shapes shape)))] (count (:shapes shape)))]
(st/emit! (dwsh/relocate-shapes #{child-id} id index)))))) (st/emit! (dwsh/relocate-shapes #{child-id} id index))))))
@ -976,19 +977,19 @@
(not (cfh/group-shape? shape)) (not (cfh/group-shape? shape))
(not (cfh/svg-raw-shape? shape)) (not (cfh/svg-raw-shape? shape))
(not (cfh/bool-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)) (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")) (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 :else
(let [child-id (obj/get child "$id") (let [child-id (obj/get child "$id")
is-reversed? (ctl/flex-layout? shape) is-reversed? (ctl/flex-layout? shape)
index index
(if (or (not (natural-child-ordering? plugin-id)) is-reversed?) (if (or (not (u/natural-child-ordering? plugin-id)) is-reversed?)
(- (count (:shapes shape)) index) (- (count (:shapes shape)) index)
index)] index)]
(st/emit! (dwsh/relocate-shapes #{child-id} id index)))))) (st/emit! (dwsh/relocate-shapes #{child-id} id index))))))
@ -999,10 +1000,10 @@
(let [shape (u/locate-shape file-id page-id id)] (let [shape (u/locate-shape file-id page-id id)]
(cond (cond
(not (cfh/frame-shape? shape)) (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")) (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 :else
(do (st/emit! (dwsl/create-layout-from-id id :flex :from-frame? true :calculate-params? false)) (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)] (let [shape (u/locate-shape file-id page-id id)]
(cond (cond
(not (cfh/frame-shape? shape)) (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")) (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 :else
(do (st/emit! (dwsl/create-layout-from-id id :grid :from-frame? true :calculate-params? false)) (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)] (let [shape (u/locate-shape file-id page-id id)]
(cond (cond
(not (cfh/group-shape? shape)) (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")) (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 :else
(st/emit! (dwg/mask-group #{id}))))) (st/emit! (dwg/mask-group #{id})))))
@ -1041,10 +1042,10 @@
(let [shape (u/locate-shape file-id page-id id)] (let [shape (u/locate-shape file-id page-id id)]
(cond (cond
(not (cfh/mask-shape? shape)) (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")) (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 :else
(st/emit! (dwg/unmask-group #{id}))))) (st/emit! (dwg/unmask-group #{id})))))
@ -1055,7 +1056,7 @@
(let [shape (u/locate-shape file-id page-id id)] (let [shape (u/locate-shape file-id page-id id)]
(cond (cond
(and (not (cfh/path-shape? shape)) (not (cfh/bool-shape? shape))) (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 :else
(.toString (:content shape))))) (.toString (:content shape)))))
@ -1066,13 +1067,13 @@
(let [shape (u/locate-shape file-id page-id id)] (let [shape (u/locate-shape file-id page-id id)]
(cond (cond
(not (cfh/text-shape? shape)) (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)) (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)) (not (sm/valid-safe-int? end))
(u/display-not-valid :getRange-end end) (u/not-valid plugin-id :getRange-end end)
:else :else
(text/text-range-proxy plugin-id file-id page-id id start end)))) (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)] (let [shape (u/locate-shape file-id page-id id)]
(cond (cond
(not (lib-typography-proxy? typography)) (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)) (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")) (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 :else
(let [typography (u/proxy->library-typography typography)] (let [typography (u/proxy->library-typography typography)]
@ -1099,10 +1100,10 @@
(fn [index] (fn [index]
(cond (cond
(not (sm/valid-safe-int? index)) (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")) (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 :else
(st/emit! (dw/set-shape-index file-id page-id id index)))) (st/emit! (dw/set-shape-index file-id page-id id index))))
@ -1197,7 +1198,7 @@
(let [value (parser/parse-export value)] (let [value (parser/parse-export value)]
(cond (cond
(not (sm/validate ctse/schema:export value)) (not (sm/validate ctse/schema:export value))
(u/display-not-valid :export value) (u/not-valid plugin-id :export value)
:else :else
(let [shape (u/locate-shape file-id page-id id) (let [shape (u/locate-shape file-id page-id id)
@ -1233,7 +1234,7 @@
(d/patch-object (parser/parse-interaction trigger action delay)))] (d/patch-object (parser/parse-interaction trigger action delay)))]
(cond (cond
(not (sm/validate ctsi/schema:interaction interaction)) (not (sm/validate ctsi/schema:interaction interaction))
(u/display-not-valid :addInteraction interaction) (u/not-valid plugin-id :addInteraction interaction)
:else :else
(let [index (-> (u/locate-shape file-id page-id id) (:interactions []) count)] (let [index (-> (u/locate-shape file-id page-id id) (:interactions []) count)]
@ -1244,7 +1245,7 @@
(fn [interaction] (fn [interaction]
(cond (cond
(not (interaction-proxy? interaction)) (not (interaction-proxy? interaction))
(u/display-not-valid :removeInteraction interaction) (u/not-valid plugin-id :removeInteraction interaction)
:else :else
(st/emit! (dwi/remove-interaction {:id id} (obj/get interaction "$index"))))) (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)] (let [shape (u/locate-shape file-id page-id id)]
(cond (cond
(not (sm/valid-safe-number? value)) (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)) (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)) (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")) (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 :else
(let [id (uuid/next) (let [id (uuid/next)
@ -1285,10 +1286,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (rg/ruler-guide-proxy? value)) (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")) (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 :else
(let [guide (u/proxy->ruler-guide value)] (let [guide (u/proxy->ruler-guide value)]
@ -1298,25 +1299,26 @@
{:this true {:this true
:get :get
(fn [_] (fn [_]
(let [tokens (let [applied-tokens
(-> (u/locate-shape file-id page-id id) (-> (u/locate-shape file-id page-id id)
(get :applied-tokens))] (get :applied-tokens)
(applied-tokens-plugin->applied-tokens))]
(reduce (reduce
(fn [acc [prop name]] (fn [acc [prop name]]
(obj/set! acc (json/write-camel-key prop) name)) (obj/set! acc (json/write-camel-key prop) name))
#js {} #js {}
tokens)))} applied-tokens)))}
:applyToken :applyToken
{:enumerable false {:enumerable false
:schema [:tuple :schema [:tuple
[:fn token-proxy?] [:fn token-proxy?]
[:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]] [:maybe [:set [:and ::sm/keyword [:fn token-attr?]]]]]
:fn (fn [token attrs] :fn (fn [token attrs]
(let [token (u/locate-token file-id (obj/get token "$set-id") (obj/get token "$id")) (let [token (u/locate-token file-id (obj/get token "$set-id") (obj/get token "$id"))
kw-attrs (into #{} (map keyword attrs))] kw-attrs (into #{} (map token-attr-plugin->token-attr attrs))]
(if (some #(not (cto/token-attr? %)) kw-attrs) (if (some #(not (token-attr? %)) kw-attrs)
(u/display-not-valid :applyToken attrs) (u/not-valid plugin-id :applyToken attrs)
(st/emit! (st/emit!
(dwta/toggle-token {:token token (dwta/toggle-token {:token token
:attrs kw-attrs :attrs kw-attrs
@ -1338,10 +1340,10 @@
(fn [pos value] (fn [pos value]
(cond (cond
(not (nat-int? pos)) (not (nat-int? pos))
(u/display-not-valid :pos pos) (u/not-valid plugin-id :pos pos)
(not (string? value)) (not (string? value))
(u/display-not-valid :value value) (u/not-valid plugin-id :value value)
:else :else
(let [shape (u/locate-shape file-id page-id id) (let [shape (u/locate-shape file-id page-id id)
@ -1351,16 +1353,30 @@
:combineAsVariants :combineAsVariants
(fn [ids] (fn [ids]
(if (or (not (seq ids)) (not (every? uuid/parse* ids))) (cond
(u/display-not-valid :ids ids) (or (not (seq ids)) (not (every? uuid/parse* ids)))
(let [shape (u/locate-shape file-id page-id id) (u/not-valid plugin-id :ids ids)
component (u/locate-library-component file-id (:component-id shape))
ids (->> ids :else
(let [ids (->> ids
(map uuid/uuid) (map uuid/uuid)
(into #{id}))] (into #{id}))
(when (and component (not (ctk/is-variant? component))) valid?
(st/emit! (every?
(dwv/combine-as-variants ids {:trigger "plugin:combine-as-variants"}))))))) (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)) (cond-> (or (cfh/frame-shape? data) (cfh/group-shape? data) (cfh/svg-raw-shape? data) (cfh/bool-shape? data))
(crc/add-properties! (crc/add-properties!
@ -1375,21 +1391,21 @@
(fn [^js self children] (fn [^js self children]
(cond (cond
(not (r/check-permission plugin-id "content:write")) (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)) (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 :else
(let [shape (u/proxy->shape self) (let [shape (u/proxy->shape self)
file-id (obj/get self "$file") file-id (obj/get self "$file")
page-id (obj/get self "$page") 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")))] ids (->> children reverse-fn (map #(obj/get % "$id")))]
(cond (cond
(not= (set ids) (set (:shapes shape))) (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 :else
(st/emit! (dw/reorder-children file-id page-id (:id shape) ids))))))})) (st/emit! (dw/reorder-children file-id page-id (:id shape) ids))))))}))
@ -1405,10 +1421,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (boolean? value)) (not (boolean? value))
(u/display-not-valid :clipContent value) (u/not-valid plugin-id :clipContent value)
(not (r/check-permission plugin-id "content:write")) (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 :else
(st/emit! (dwsh/update-shapes [id] #(assoc % :show-content (not value))))))} (st/emit! (dwsh/update-shapes [id] #(assoc % :show-content (not value))))))}
@ -1421,10 +1437,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(not (boolean? value)) (not (boolean? value))
(u/display-not-valid :showInViewMode value) (u/not-valid plugin-id :showInViewMode value)
(not (r/check-permission plugin-id "content:write")) (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 :else
(st/emit! (dwsh/update-shapes [id] #(assoc % :hide-in-viewer (not value))))))} (st/emit! (dwsh/update-shapes [id] #(assoc % :hide-in-viewer (not value))))))}
@ -1456,10 +1472,10 @@
value (parser/parse-frame-guides value)] value (parser/parse-frame-guides value)]
(cond (cond
(not (sm/validate [:vector ::ctg/grid] value)) (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")) (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 :else
(st/emit! (dwsh/update-shapes [id] #(assoc % :grids value))))))} (st/emit! (dwsh/update-shapes [id] #(assoc % :grids value))))))}
@ -1481,10 +1497,10 @@
value (keyword value)] value (keyword value)]
(cond (cond
(not (contains? #{:fix :auto} value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-item-h-sizing value})))))} (st/emit! (dwsl/update-layout #{id} {:layout-item-h-sizing value})))))}
@ -1497,10 +1513,10 @@
value (keyword value)] value (keyword value)]
(cond (cond
(not (contains? #{:fix :auto} value)) (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")) (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 :else
(st/emit! (dwsl/update-layout #{id} {:layout-item-v-sizing value})))))} (st/emit! (dwsl/update-layout #{id} {:layout-item-v-sizing value})))))}
@ -1524,10 +1540,10 @@
(let [segments (parser/parse-commands value)] (let [segments (parser/parse-commands value)]
(cond (cond
(not (r/check-permission plugin-id "content:write")) (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)) (not (sm/validate path/schema:segments segments))
(u/display-not-valid :content segments) (u/not-valid plugin-id :content segments)
:else :else
(let [selrect (path/calc-selrect segments) (let [selrect (path/calc-selrect segments)
@ -1550,13 +1566,13 @@
value)] value)]
(cond (cond
(not (r/check-permission plugin-id "content:write")) (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)) (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)) (not (sm/validate path/schema:segments segments))
(u/display-not-valid :content segments) (u/not-valid plugin-id :content segments)
:else :else
(let [selrect (path/calc-selrect segments) (let [selrect (path/calc-selrect segments)

View File

@ -8,9 +8,10 @@
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.geom.shapes.text :as gst]
[app.common.record :as crc] [app.common.record :as crc]
[app.common.schema :as sm] [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.common.types.text :as txt]
[app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.texts :as dwt] [app.main.data.workspace.texts :as dwt]
@ -118,10 +119,10 @@
variant (fonts/get-default-variant font)] variant (fonts/get-default-variant font)]
(cond (cond
(not font) (not font)
(u/display-not-valid :fontId value) (u/not-valid plugin-id :fontId value)
(not (r/check-permission plugin-id "content:write")) (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 :else
(st/emit! (dwt/update-text-range id start end (font-data font variant))))))} (st/emit! (dwt/update-text-range id start end (font-data font variant))))))}
@ -140,10 +141,10 @@
variant (fonts/get-default-variant font)] variant (fonts/get-default-variant font)]
(cond (cond
(not (string? value)) (not (string? value))
(u/display-not-valid :fontFamily value) (u/not-valid plugin-id :fontFamily value)
(not (r/check-permission plugin-id "content:write")) (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 :else
(st/emit! (dwt/update-text-range id start end (font-data font variant))))))} (st/emit! (dwt/update-text-range id start end (font-data font variant))))))}
@ -161,10 +162,10 @@
variant (fonts/get-variant font value)] variant (fonts/get-variant font value)]
(cond (cond
(not (string? value)) (not (string? value))
(u/display-not-valid :fontVariantId value) (u/not-valid plugin-id :fontVariantId value)
(not (r/check-permission plugin-id "content:write")) (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 :else
(st/emit! (dwt/update-text-range id start end (variant-data variant))))))} (st/emit! (dwt/update-text-range id start end (variant-data variant))))))}
@ -181,10 +182,10 @@
(let [value (str/trim (dm/str value))] (let [value (str/trim (dm/str value))]
(cond (cond
(or (empty? value) (not (re-matches font-size-re value))) (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")) (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 :else
(st/emit! (dwt/update-text-range id start end {:font-size value})))))} (st/emit! (dwt/update-text-range id start end {:font-size value})))))}
@ -208,10 +209,10 @@
(fonts/find-variant font {:weight weight}))] (fonts/find-variant font {:weight weight}))]
(cond (cond
(nil? variant) (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")) (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 :else
(st/emit! (dwt/update-text-range id start end (variant-data variant))))))} (st/emit! (dwt/update-text-range id start end (variant-data variant))))))}
@ -234,10 +235,10 @@
(fonts/find-variant font {:style style}))] (fonts/find-variant font {:style style}))]
(cond (cond
(nil? variant) (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")) (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 :else
(st/emit! (dwt/update-text-range id start end (variant-data variant))))))} (st/emit! (dwt/update-text-range id start end (variant-data variant))))))}
@ -254,10 +255,10 @@
(let [value (str/trim (dm/str value))] (let [value (str/trim (dm/str value))]
(cond (cond
(or (empty? value) (not (re-matches line-height-re value))) (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")) (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 :else
(st/emit! (dwt/update-text-range id start end {:line-height value})))))} (st/emit! (dwt/update-text-range id start end {:line-height value})))))}
@ -274,10 +275,10 @@
(let [value (str/trim (dm/str value))] (let [value (str/trim (dm/str value))]
(cond (cond
(or (empty? value) (re-matches letter-spacing-re value)) (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")) (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 :else
(st/emit! (dwt/update-text-range id start end {:letter-spacing value})))))} (st/emit! (dwt/update-text-range id start end {:letter-spacing value})))))}
@ -293,10 +294,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(and (string? value) (not (re-matches text-transform-re value))) (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")) (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 :else
(st/emit! (dwt/update-text-range id start end {:text-transform value}))))} (st/emit! (dwt/update-text-range id start end {:text-transform value}))))}
@ -312,10 +313,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(and (string? value) (re-matches text-decoration-re value)) (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")) (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 :else
(st/emit! (dwt/update-text-range id start end {:text-decoration value}))))} (st/emit! (dwt/update-text-range id start end {:text-decoration value}))))}
@ -331,10 +332,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(and (string? value) (re-matches text-direction-re value)) (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")) (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 :else
(st/emit! (dwt/update-text-range id start end {:direction value}))))} (st/emit! (dwt/update-text-range id start end {:direction value}))))}
@ -350,10 +351,10 @@
(fn [_ value] (fn [_ value]
(cond (cond
(and (string? value) (re-matches text-align-re value)) (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")) (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 :else
(st/emit! (dwt/update-text-range id start end {:text-align value}))))} (st/emit! (dwt/update-text-range id start end {:text-align value}))))}
@ -369,11 +370,11 @@
(fn [_ value] (fn [_ value]
(let [value (parser/parse-fills value)] (let [value (parser/parse-fills value)]
(cond (cond
(not (sm/validate [:vector ::cts/fill] value)) (not (sm/validate [:vector types.fills/schema:fill] value))
(u/display-not-valid :fills value) (u/not-valid plugin-id :fills value)
(not (r/check-permission plugin-id "content:write")) (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 :else
(st/emit! (dwt/update-text-range id start end {:fills value})))))} (st/emit! (dwt/update-text-range id start end {:fills value})))))}
@ -400,10 +401,10 @@
;; editor as well ;; editor as well
(cond (cond
(or (not (string? value)) (empty? value)) (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")) (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) (contains? (:workspace-editor-state @st/state) id)
(let [shape (u/proxy->shape self) (let [shape (u/proxy->shape self)
@ -427,10 +428,10 @@
value (keyword value)] value (keyword value)]
(cond (cond
(not (contains? #{:auto-width :auto-height :fixed} value)) (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")) (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 :else
(st/emit! (dwsh/update-shapes [id] #(assoc % :grow-type value))))))} (st/emit! (dwsh/update-shapes [id] #(assoc % :grow-type value))))))}
@ -444,10 +445,10 @@
variant (fonts/get-default-variant font)] variant (fonts/get-default-variant font)]
(cond (cond
(not font) (not font)
(u/display-not-valid :fontId value) (u/not-valid plugin-id :fontId value)
(not (r/check-permission plugin-id "content:write")) (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 :else
(st/emit! (dwt/update-attrs id (font-data font variant))))))} (st/emit! (dwt/update-attrs id (font-data font variant))))))}
@ -461,10 +462,10 @@
variant (fonts/get-default-variant font)] variant (fonts/get-default-variant font)]
(cond (cond
(not font) (not font)
(u/display-not-valid :fontFamily value) (u/not-valid plugin-id :fontFamily value)
(not (r/check-permission plugin-id "content:write")) (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 :else
(st/emit! (dwt/update-attrs id (font-data font variant))))))} (st/emit! (dwt/update-attrs id (font-data font variant))))))}
@ -478,10 +479,10 @@
variant (fonts/get-variant font value)] variant (fonts/get-variant font value)]
(cond (cond
(not variant) (not variant)
(u/display-not-valid :fontVariantId value) (u/not-valid plugin-id :fontVariantId value)
(not (r/check-permission plugin-id "content:write")) (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 :else
(st/emit! (dwt/update-attrs id (variant-data variant))))))} (st/emit! (dwt/update-attrs id (variant-data variant))))))}
@ -494,10 +495,10 @@
value (str/trim (dm/str value))] value (str/trim (dm/str value))]
(cond (cond
(or (empty? value) (not (re-matches font-size-re value))) (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")) (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 :else
(st/emit! (dwt/update-attrs id {:font-size value})))))} (st/emit! (dwt/update-attrs id {:font-size value})))))}
@ -516,10 +517,10 @@
(fonts/find-variant font {:weight weight}))] (fonts/find-variant font {:weight weight}))]
(cond (cond
(nil? variant) (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")) (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 :else
(st/emit! (dwt/update-attrs id (variant-data variant))))))} (st/emit! (dwt/update-attrs id (variant-data variant))))))}
@ -538,10 +539,10 @@
(fonts/find-variant font {:style style}))] (fonts/find-variant font {:style style}))]
(cond (cond
(nil? variant) (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")) (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 :else
(st/emit! (dwt/update-attrs id (variant-data variant))))))} (st/emit! (dwt/update-attrs id (variant-data variant))))))}
@ -554,10 +555,10 @@
value (str/trim (dm/str value))] value (str/trim (dm/str value))]
(cond (cond
(or (empty? value) (not (re-matches line-height-re value))) (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")) (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 :else
(st/emit! (dwt/update-attrs id {:line-height value})))))} (st/emit! (dwt/update-attrs id {:line-height value})))))}
@ -570,10 +571,10 @@
value (str/trim (dm/str value))] value (str/trim (dm/str value))]
(cond (cond
(or (not (string? value)) (not (re-matches letter-spacing-re value))) (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")) (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 :else
(st/emit! (dwt/update-attrs id {:letter-spacing value})))))} (st/emit! (dwt/update-attrs id {:letter-spacing value})))))}
@ -585,10 +586,10 @@
(let [id (obj/get self "$id")] (let [id (obj/get self "$id")]
(cond (cond
(or (not (string? value)) (not (re-matches text-transform-re value))) (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")) (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 :else
(st/emit! (dwt/update-attrs id {:text-transform value})))))} (st/emit! (dwt/update-attrs id {:text-transform value})))))}
@ -600,10 +601,10 @@
(let [id (obj/get self "$id")] (let [id (obj/get self "$id")]
(cond (cond
(or (not (string? value)) (not (re-matches text-decoration-re value))) (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")) (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 :else
(st/emit! (dwt/update-attrs id {:text-decoration value})))))} (st/emit! (dwt/update-attrs id {:text-decoration value})))))}
@ -615,10 +616,10 @@
(let [id (obj/get self "$id")] (let [id (obj/get self "$id")]
(cond (cond
(or (not (string? value)) (not (re-matches text-direction-re value))) (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")) (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 :else
(st/emit! (dwt/update-attrs id {:text-direction value})))))} (st/emit! (dwt/update-attrs id {:text-direction value})))))}
@ -630,10 +631,10 @@
(let [id (obj/get self "$id")] (let [id (obj/get self "$id")]
(cond (cond
(or (not (string? value)) (not (re-matches text-align-re value))) (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")) (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 :else
(st/emit! (dwt/update-attrs id {:text-align value})))))} (st/emit! (dwt/update-attrs id {:text-align value})))))}
@ -645,10 +646,13 @@
(let [id (obj/get self "$id")] (let [id (obj/get self "$id")]
(cond (cond
(or (not (string? value)) (not (re-matches vertical-align-re value))) (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")) (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 :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)}))

View File

@ -19,18 +19,58 @@
[app.main.store :as st] [app.main.store :as st]
[app.plugins.utils :as u] [app.plugins.utils :as u]
[app.util.object :as obj] [app.util.object :as obj]
[clojure.datafy :refer [datafy]])) [clojure.datafy :refer [datafy]]
[clojure.set :refer [map-invert]]))
;; === Token ;; === 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 (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)] (let [token (u/locate-token file-id set-id id)]
(if (some #(not (cto/token-attr? %)) attrs) (if (some #(not (token-attr? %)) attrs)
(u/display-not-valid :applyToSelected attrs) (u/not-valid plugin-id :applyToSelected attrs)
(st/emit! (st/emit!
(dwta/toggle-token {:token token (dwta/toggle-token {:token token
:attrs attrs :attrs (into #{} (map token-attr-plugin->token-attr) attrs)
:shape-ids shape-ids :shape-ids shape-ids
:expand-with-children false}))))) :expand-with-children false})))))
@ -52,7 +92,7 @@
(defn token-proxy (defn token-proxy
[plugin-id file-id set-id id] [plugin-id file-id set-id id]
(obj/reify {:name "TokenProxy" (obj/reify {:name "TokenProxy"
:on-error u/handle-error} :on-error (u/handle-error plugin-id)}
:$plugin {:enumerable false :get (constantly plugin-id)} :$plugin {:enumerable false :get (constantly plugin-id)}
:$file-id {:enumerable false :get (constantly file-id)} :$file-id {:enumerable false :get (constantly file-id)}
:$set-id {:enumerable false :get (constantly set-id)} :$set-id {:enumerable false :get (constantly set-id)}
@ -146,16 +186,16 @@
{:enumerable false {:enumerable false
:schema [:tuple :schema [:tuple
[:vector [:fn shape-proxy?]] [: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] :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 :applyToSelected
{:enumerable false {: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] :fn (fn [attrs]
(let [selected (get-in @st/state [:workspace-local :selected])] (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 ;; === Token Set
@ -165,7 +205,7 @@
(defn token-set-proxy (defn token-set-proxy
[plugin-id file-id id] [plugin-id file-id id]
(obj/reify {:name "TokenSetProxy" (obj/reify {:name "TokenSetProxy"
:on-error u/handle-error} :on-error (u/handle-error plugin-id)}
:$plugin {:enumerable false :get (constantly plugin-id)} :$plugin {:enumerable false :get (constantly plugin-id)}
:$file-id {:enumerable false :get (constantly file-id)} :$file-id {:enumerable false :get (constantly file-id)}
:$id {:enumerable false :get (constantly id)} :$id {:enumerable false :get (constantly id)}
@ -247,15 +287,19 @@
:addToken :addToken
{:enumerable false {:enumerable false
:schema (fn [args] :schema (fn [args]
[:tuple (-> (cfo/make-token-schema (let [tokens-tree (-> (u/locate-tokens-lib file-id)
(-> (u/locate-tokens-lib file-id) (ctob/get-tokens id)) (ctob/get-tokens id)
(cto/dtcg-token-type->token-type (-> args (first) (get "type")))) ;; Convert to the adecuate format for schema
;; Don't allow plugins to set the id (ctob/tokens-tree))]
(sm/dissoc-key :id) [:tuple (-> (cfo/make-token-schema
;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below) tokens-tree
;; and set a converter that changes DTCG types to internal types (:decode/json). (cto/dtcg-token-type->token-type (-> args (first) (get "type"))))
;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width ;; Don't allow plugins to set the id
(sm/update-properties assoc :decode/json cfo/convert-dtcg-token))]) (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} :decode/options {:key-fn identity}
:fn (fn [attrs] :fn (fn [attrs]
(let [tokens-lib (u/locate-tokens-lib file-id) (let [tokens-lib (u/locate-tokens-lib file-id)
@ -270,7 +314,7 @@
(if resolved-value (if resolved-value
(do (st/emit! (dwtl/create-token id token)) (do (st/emit! (dwtl/create-token id token))
(token-proxy plugin-id file-id id (: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))))} nil))))}
:duplicate :duplicate
@ -287,7 +331,7 @@
(defn token-theme-proxy (defn token-theme-proxy
[plugin-id file-id id] [plugin-id file-id id]
(obj/reify {:name "TokenThemeProxy" (obj/reify {:name "TokenThemeProxy"
:on-error u/handle-error} :on-error (u/handle-error plugin-id)}
:$plugin {:enumerable false :get (constantly plugin-id)} :$plugin {:enumerable false :get (constantly plugin-id)}
:$file-id {:enumerable false :get (constantly file-id)} :$file-id {:enumerable false :get (constantly file-id)}
:$id {:enumerable false :get (constantly id)} :$id {:enumerable false :get (constantly id)}
@ -394,7 +438,7 @@
(defn tokens-catalog (defn tokens-catalog
[plugin-id file-id] [plugin-id file-id]
(obj/reify {:name "TokensCatalog" (obj/reify {:name "TokensCatalog"
:on-error u/handle-error} :on-error (u/handle-error plugin-id)}
:$plugin {:enumerable false :get (constantly plugin-id)} :$plugin {:enumerable false :get (constantly plugin-id)}
:$id {:enumerable false :get (constantly file-id)} :$id {:enumerable false :get (constantly file-id)}

View File

@ -9,14 +9,17 @@
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [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 :as sm]
[app.common.schema.messages :as csm]
[app.common.types.component :as ctk]
[app.common.types.container :as ctn] [app.common.types.container :as ctn]
[app.common.types.file :as ctf] [app.common.types.file :as ctf]
[app.common.types.tokens-lib :as ctob] [app.common.types.tokens-lib :as ctob]
[app.main.data.helpers :as dsh] [app.main.data.helpers :as dsh]
[app.main.store :as st] [app.main.store :as st]
[app.util.object :as obj])) [app.util.object :as obj]
[cuerdas.core :as str]))
(defn locate-file (defn locate-file
[id] [id]
@ -221,6 +224,16 @@
(resolve value)))))] (resolve value)))))]
[ret-v ret-p])) [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 (defn display-not-valid
[code value] [code value]
(if (some? value) (if (some? value)
@ -228,34 +241,54 @@
(.error js/console (dm/str "[PENPOT PLUGIN] Value not valid. Code: " code))) (.error js/console (dm/str "[PENPOT PLUGIN] Value not valid. Code: " code)))
nil) 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 (defn reject-not-valid
[reject code value] [reject code value]
(let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)] (let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)]
(.error js/console msg) (.error js/console msg)
(reject 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 (defn mixed-value
[values] [values]
(let [s (set values)] (let [s (set values)]
(if (= (count s) 1) (first s) "mixed"))) (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 (defn handle-error
"Function to be used in plugin proxies methods to handle errors and print a readable "Function to be used in plugin proxies methods to handle errors and print a readable
message to the console." message to the console."
[cause] [plugin-id]
(display-not-valid (ex-message cause) nil) (fn [cause]
(if-let [explain (-> cause ex-data ::sm/explain)] (let [message
(println (sm/humanize-explain explain)) (if-let [explain (-> cause ex-data ::sm/explain)]
(js/console.log (ex-data cause))) (do
(js/console.log (.-stack cause))) (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)))

View File

@ -38,10 +38,10 @@
new-y (obj/get value "y")] new-y (obj/get value "y")]
(cond (cond
(not (sm/valid-safe-number? new-x)) (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)) (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 :else
(let [vb (dm/get-in @st/state [:workspace-local :vbox]) (let [vb (dm/get-in @st/state [:workspace-local :vbox])
@ -63,7 +63,7 @@
(fn [value] (fn [value]
(cond (cond
(not (sm/valid-safe-number? value)) (not (sm/valid-safe-number? value))
(u/display-not-valid :zoom value) (u/not-valid plugin-id :zoom value)
:else :else
(let [z (dm/get-in @st/state [:workspace-local :zoom])] (let [z (dm/get-in @st/state [:workspace-local :zoom])]
@ -87,7 +87,7 @@
(fn [shapes] (fn [shapes]
(cond (cond
(not (every? ps/shape-proxy? shapes)) (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 :else
(let [ids (->> shapes (let [ids (->> shapes

View File

@ -10,84 +10,10 @@
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.util.i18n :as i18n :refer [tr]] [app.common.schema.messages :as csm]
[cuerdas.core :as str] [cuerdas.core :as str]
[malli.core :as m]
[rumext.v2 :as mf])) [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 (defn- use-rerender-fn
[] []
(let [state (mf/useState 0) (let [state (mf/useState 0)
@ -97,24 +23,6 @@
(fn [] (fn []
(render-fn inc))))) (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 (defn- wrap-update-schema-fn
[f {:keys [schema validators]}] [f {:keys [schema validators]}]
(fn [& args] (fn [& args]
@ -124,7 +32,7 @@
errors errors
(when-not valid? (when-not valid?
(collect-schema-errors schema validators state)) (csm/collect-schema-errors schema validators state))
extra-errors extra-errors
(not-empty (:extra-errors state))] (not-empty (:extra-errors state))]

View File

@ -462,3 +462,9 @@
(defn print-last-exception (defn print-last-exception
[] []
(some-> errors/last-exception ex/print-throwable)) (some-> errors/last-exception ex/print-throwable))
(defn ^:export dbg
[o]
(app.common.pprint/pprint o {:level 100 :length 100}))

View File

@ -338,77 +338,6 @@ msgstr "You're going to restore %s."
msgid "dashboard-restore-file-confirmation.title" msgid "dashboard-restore-file-confirmation.title"
msgstr "Restore file" 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 #: src/app/main/ui/dashboard/placeholder.cljs:41
msgid "dashboard.add-file" msgid "dashboard.add-file"
msgstr "Add file" msgstr "Add file"
@ -2138,6 +2067,237 @@ msgstr "Resolved value:"
msgid "inspect.tabs.styles.variants-panel" msgid "inspect.tabs.styles.variants-panel"
msgstr "Variant Properties" 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 #: src/app/main/ui/dashboard/comments.cljs:96
msgid "label.mark-all-as-read" msgid "label.mark-all-as-read"
msgstr "Mark all as read" msgstr "Mark all as read"
@ -2354,6 +2514,9 @@ msgstr "Director"
msgid "labels.discard" msgid "labels.discard"
msgstr "Discard" msgstr "Discard"
msgid "labels.dismiss"
msgstr "Dismiss"
#: src/app/main/ui/settings/feedback.cljs:134, src/app/main/ui/static.cljs:409 #: src/app/main/ui/settings/feedback.cljs:134, src/app/main/ui/static.cljs:409
msgid "labels.download" msgid "labels.download"
msgstr "Download %s" msgstr "Download %s"
@ -2489,6 +2652,10 @@ msgstr "Info"
msgid "labels.installed-fonts" msgid "labels.installed-fonts"
msgstr "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 #: src/app/main/ui/static.cljs:405
msgid "labels.internal-error.desc-message-first" msgid "labels.internal-error.desc-message-first"
msgstr "Something bad happened." msgstr "Something bad happened."
@ -3148,30 +3315,6 @@ msgstr "Change email"
msgid "modals.change-email.title" msgid "modals.change-email.title"
msgstr "Change your email" 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 #: src/app/main/ui/dashboard/team.cljs:1127
msgid "modals.create-webhook.submit-label" msgid "modals.create-webhook.submit-label"
msgstr "Create webhook" msgstr "Create webhook"
@ -3188,18 +3331,6 @@ msgstr "Payload URL"
msgid "modals.create-webhook.url.placeholder" msgid "modals.create-webhook.url.placeholder"
msgstr "https://example.com/postreceive" 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 #: src/app/main/ui/settings/delete_account.cljs:56
msgid "modals.delete-account.cancel" msgid "modals.delete-account.cancel"
msgstr "Cancel and keep my account" msgstr "Cancel and keep my account"
@ -3699,6 +3830,12 @@ msgstr "Invitation sent successfully"
msgid "notifications.invitation-link-copied" msgid "notifications.invitation-link-copied"
msgstr "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 #: src/app/main/ui/settings/delete_account.cljs:24
msgid "notifications.profile-deletion-not-allowed" msgid "notifications.profile-deletion-not-allowed"
msgstr "You can't delete your profile. Reassign your teams before proceed." msgstr "You can't delete your profile. Reassign your teams before proceed."
@ -5093,14 +5230,14 @@ msgstr "Shared Libraries - %s - Penpot"
msgid "title.default" msgid "title.default"
msgstr "Penpot - Design Freedom for Teams" 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 #: src/app/main/ui/settings/feedback.cljs:161
msgid "title.settings.feedback" msgid "title.settings.feedback"
msgstr "Give feedback - Penpot" 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 #: src/app/main/ui/settings/notifications.cljs:45
msgid "title.settings.notifications" msgid "title.settings.notifications"
msgstr "Notifications - Penpot" msgstr "Notifications - Penpot"
@ -5594,6 +5731,18 @@ msgstr "Hide rulers"
msgid "workspace.header.menu.hide-textpalette" msgid "workspace.header.menu.hide-textpalette"
msgstr "Hide fonts palette" 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 #: src/app/main/ui/workspace/main_menu.cljs:884
msgid "workspace.header.menu.option.edit" msgid "workspace.header.menu.option.edit"
msgstr "Edit" msgstr "Edit"
@ -5606,6 +5755,9 @@ msgstr "File"
msgid "workspace.header.menu.option.help-info" msgid "workspace.header.menu.option.help-info"
msgstr "Help & info" msgstr "Help & info"
msgid "workspace.header.menu.option.mcp"
msgstr "MCP server"
#: src/app/main/ui/workspace/main_menu.cljs:916 #: src/app/main/ui/workspace/main_menu.cljs:916
#, unused #, unused
msgid "workspace.header.menu.option.power-up" msgid "workspace.header.menu.option.power-up"
@ -7234,11 +7386,14 @@ msgid "workspace.plugins.empty-plugins"
msgstr "No plugins installed yet" msgstr "No plugins installed yet"
#: src/app/main/ui/workspace/plugins.cljs:193 #: src/app/main/ui/workspace/plugins.cljs:193
msgid "workspace.plugins.error.manifest" msgid "workspace.plugins.error.manifest"
msgstr "The plugin manifest is incorrect." 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 #: 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" msgstr "You need to be an editor to use this plugin"
#: src/app/main/ui/workspace/plugins.cljs:189 #: src/app/main/ui/workspace/plugins.cljs:189

View File

@ -347,77 +347,6 @@ msgstr "Vas a restaurar %s."
msgid "dashboard-restore-file-confirmation.title" msgid "dashboard-restore-file-confirmation.title"
msgstr "Restaurar archivo" 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 #: src/app/main/ui/dashboard/placeholder.cljs:41
msgid "dashboard.add-file" msgid "dashboard.add-file"
msgstr "Añadir archivo" msgstr "Añadir archivo"
@ -2105,6 +2034,237 @@ msgstr "Valor resuelto:"
msgid "inspect.tabs.styles.variants-panel" msgid "inspect.tabs.styles.variants-panel"
msgstr "Propiedades de las variantes" 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 #: src/app/main/ui/dashboard/comments.cljs:96
msgid "label.mark-all-as-read" msgid "label.mark-all-as-read"
msgstr "Marcar todo como leído" msgstr "Marcar todo como leído"
@ -2321,6 +2481,9 @@ msgstr "Director"
msgid "labels.discard" msgid "labels.discard"
msgstr "Descartar" msgstr "Descartar"
msgid "labels.dismiss"
msgstr "Cancelar"
#: src/app/main/ui/settings/feedback.cljs:134, src/app/main/ui/static.cljs:409 #: src/app/main/ui/settings/feedback.cljs:134, src/app/main/ui/static.cljs:409
msgid "labels.download" msgid "labels.download"
msgstr "Descargar %s" msgstr "Descargar %s"
@ -2456,6 +2619,10 @@ msgstr "Información"
msgid "labels.installed-fonts" msgid "labels.installed-fonts"
msgstr "Fuentes instaladas" msgstr "Fuentes instaladas"
#: src/app/main/ui/settings/sidebar.cljs:123
msgid "labels.integrations"
msgstr "Integraciones"
#: src/app/main/ui/static.cljs:405 #: src/app/main/ui/static.cljs:405
msgid "labels.internal-error.desc-message-first" msgid "labels.internal-error.desc-message-first"
msgstr "Ha ocurrido algo extraño." msgstr "Ha ocurrido algo extraño."
@ -3111,30 +3278,6 @@ msgstr "Cambiar correo"
msgid "modals.change-email.title" msgid "modals.change-email.title"
msgstr "Cambiar tu correo" 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 #: src/app/main/ui/dashboard/team.cljs:1127
msgid "modals.create-webhook.submit-label" msgid "modals.create-webhook.submit-label"
msgstr "Crear webhook" msgstr "Crear webhook"
@ -3151,18 +3294,6 @@ msgstr "Payload URL"
msgid "modals.create-webhook.url.placeholder" msgid "modals.create-webhook.url.placeholder"
msgstr "https://example.com/postreceive" 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 #: src/app/main/ui/settings/delete_account.cljs:56
msgid "modals.delete-account.cancel" msgid "modals.delete-account.cancel"
msgstr "Cancelar y mantener mi cuenta" msgstr "Cancelar y mantener mi cuenta"
@ -3666,6 +3797,12 @@ msgstr "Invitación enviada con éxito"
msgid "notifications.invitation-link-copied" msgid "notifications.invitation-link-copied"
msgstr "Enlace de invitacion copiado" 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 #: src/app/main/ui/settings/delete_account.cljs:24
msgid "notifications.profile-deletion-not-allowed" msgid "notifications.profile-deletion-not-allowed"
msgstr "No puedes borrar tu perfil. Reasigna tus equipos antes de seguir." msgstr "No puedes borrar tu perfil. Reasigna tus equipos antes de seguir."
@ -5068,14 +5205,14 @@ msgstr "Bibliotecas Compartidas - %s - Penpot"
msgid "title.default" msgid "title.default"
msgstr "Penpot - Diseño Libre para Equipos" 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 #: src/app/main/ui/settings/feedback.cljs:161
msgid "title.settings.feedback" msgid "title.settings.feedback"
msgstr "Danos tu opinión - Penpot" 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 #: src/app/main/ui/settings/notifications.cljs:45
msgid "title.settings.notifications" msgid "title.settings.notifications"
msgstr "Notificaciones - Penpot" msgstr "Notificaciones - Penpot"
@ -5571,6 +5708,18 @@ msgstr "Ocultar reglas"
msgid "workspace.header.menu.hide-textpalette" msgid "workspace.header.menu.hide-textpalette"
msgstr "Ocultar paleta de textos" 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 #: src/app/main/ui/workspace/main_menu.cljs:884
msgid "workspace.header.menu.option.edit" msgid "workspace.header.menu.option.edit"
msgstr "Editar" msgstr "Editar"
@ -5583,6 +5732,9 @@ msgstr "Archivo"
msgid "workspace.header.menu.option.help-info" msgid "workspace.header.menu.option.help-info"
msgstr "Ayuda e información" msgstr "Ayuda e información"
msgid "workspace.header.menu.option.mcp"
msgstr "Servidor MCP"
#: src/app/main/ui/workspace/main_menu.cljs:906 #: src/app/main/ui/workspace/main_menu.cljs:906
msgid "workspace.header.menu.option.preferences" msgid "workspace.header.menu.option.preferences"
msgstr "Preferencias" msgstr "Preferencias"

2
mcp/.gitignore vendored
View File

@ -1,6 +1,8 @@
.idea .idea
.claude
node_modules node_modules
dist dist
*.tgz
*.bak *.bak
*.orig *.orig
temp temp

View File

@ -1,7 +1,10 @@
# Penpot MCP Project Overview - Updated # Penpot MCP Project Overview - Updated
## Purpose ## Purpose
This project is a Model Context Protocol (MCP) server for Penpot integration. It provides a TypeScript-based server that can be used to extend Penpot's functionality through custom tools with bidirectional WebSocket communication. This project is a Model Context Protocol (MCP) server for Penpot integration.
The MCP server communicates with a Penpot plugin via WebSockets, allowing
the MCP server to send tasks to the plugin and receive results,
enabling advanced AI-driven features in Penpot.
## Tech Stack ## Tech Stack
- **Language**: TypeScript - **Language**: TypeScript
@ -13,21 +16,22 @@ This project is a Model Context Protocol (MCP) server for Penpot integration. It
## Project Structure ## Project Structure
``` ```
penpot-mcp/ / (project root)
├── common/ # Shared type definitions ├── packages/common/ # Shared type definitions
│ ├── src/ │ ├── src/
│ │ ├── index.ts # Exports for shared types │ │ ├── index.ts # Exports for shared types
│ │ └── types.ts # PluginTaskResult, request/response interfaces │ │ └── types.ts # PluginTaskResult, request/response interfaces
│ └── package.json # @penpot-mcp/common package │ └── package.json # @penpot-mcp/common package
├── mcp-server/ # Main MCP server implementation ├── packages/server/ # Main MCP server implementation
│ ├── src/ │ ├── src/
│ │ ├── index.ts # Main server entry point │ │ ├── index.ts # Main server entry point
│ │ ├── PenpotMcpServer.ts # Enhanced with request/response correlation │ │ ├── PenpotMcpServer.ts # Enhanced with request/response correlation
│ │ ├── PluginTask.ts # Now supports result promises │ │ ├── PluginTask.ts # Now supports result promises
│ │ ├── tasks/ # PluginTask implementations │ │ ├── tasks/ # PluginTask implementations
│ │ └── tools/ # Tool implementations │ │ └── tools/ # Tool implementations
| ├── data/ # Contains resources, such as API info and prompts
│ └── package.json # Includes @penpot-mcp/common dependency │ └── package.json # Includes @penpot-mcp/common dependency
├── penpot-plugin/ # Penpot plugin with response capability ├── packages/plugin/ # Penpot plugin with response capability
│ ├── src/ │ ├── src/
│ │ ├── main.ts # Enhanced WebSocket handling with response forwarding │ │ ├── main.ts # Enhanced WebSocket handling with response forwarding
│ │ └── plugin.ts # Now sends task responses back to server │ │ └── plugin.ts # Now sends task responses back to server
@ -37,55 +41,24 @@ penpot-mcp/
## Key Tasks ## Key Tasks
### Adjusting the System Prompt
The system prompt file is located in `packages/server/data/initial_instructions.md`.
### Adding a new Tool ### Adding a new Tool
1. Implement the tool class in `mcp-server/src/tools/` following the `Tool` interface. 1. Implement the tool class in `packages/server/src/tools/` following the `Tool` interface.
IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally. IMPORTANT: Do not catch any exceptions in the `executeCore` method. Let them propagate to be handled centrally.
2. Register the tool in `PenpotMcpServer`. 2. Register the tool in `PenpotMcpServer`.
Look at `PrintTextTool` as an example. Tools can be associated with a `PluginTask` that is executed in the plugin.
Many tools build on `ExecuteCodePluginTask`, as many operations can be reduced to code execution.
Many tools are linked to tasks that are handled in the plugin, i.e. they have an associated `PluginTask` implementation in `mcp-server/src/tasks/`.
### Adding a new PluginTask ### Adding a new PluginTask
1. Implement the input data interface for the task in `common/src/types.ts`. 1. Implement the input data interface for the task in `packages/common/src/types.ts`.
2. Implement the `PluginTask` class in `mcp-server/src/tasks/`. 2. Implement the `PluginTask` class in `packages/server/src/tasks/`.
3. Implement the corresponding task handler class in the plugin (`penpot-plugin/src/task-handlers/`). 3. Implement the corresponding task handler class in the plugin (`packages/plugin/src/task-handlers/`).
* In the success case, call `task.sendSuccess`. * In the success case, call `task.sendSuccess`.
* In the failure case, just throw an exception, which will be handled centrally! * In the failure case, just throw an exception, which will be handled centrally!
* Look at `PrintTextTaskHandler` as an example. 4. Register the task handler in `packages/plugin/src/plugin.ts` in the `taskHandlers` list.
4. Register the task handler in `penpot-plugin/src/plugin.ts` in the `taskHandlers` list.
## Key Components
### Enhanced WebSocket Protocol
- **Request Format**: `{id: string, task: string, params: any}`
- **Response Format**: `{id: string, result: {success: boolean, error?: string, data?: any}}`
- **Request/Response Correlation**: Using unique UUIDs for task tracking
- **Timeout Handling**: 30-second timeout with automatic cleanup
- **Type Safety**: Shared definitions via @penpot-mcp/common package
### Core Classes
- **PenpotMcpServer**: Enhanced with pending task tracking and response handling
- **PluginTask**: Now creates result promises that resolve when plugin responds
- **Tool implementations**: Now properly await task completion and report results
- **Plugin handlers**: Send structured responses back to server
### New Features
1. **Bidirectional Communication**: Plugin now responds with success/failure status
2. **Task Result Promises**: Every executePluginTask() sets and returns a promise
3. **Error Reporting**: Failed tasks properly report error messages to tools
4. **Shared Type Safety**: Common package ensures consistency across projects
5. **Timeout Protection**: Tasks don't hang indefinitely (30s limit)
6. **Request Correlation**: Unique IDs match requests to responses
## Task Flow
```
LLM Tool Call → MCP Server → WebSocket (Request) → Plugin → Penpot API
↑ ↓
Tool Response ← MCP Server ← WebSocket (Response) ← Plugin Result
```

View File

@ -1,5 +1,3 @@
# whether to use the project's gitignore file to ignore files # whether to use the project's gitignore file to ignore files
# Added on 2025-04-07 # Added on 2025-04-07
ignore_all_files_in_gitignore: true ignore_all_files_in_gitignore: true
@ -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. # 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. # 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`. # execute `uv run scripts/print_tool_overview.py`.
# #
# * `activate_project`: Activates a project by name. # * `activate_project`: Activates a project by name.
@ -62,15 +60,17 @@ excluded_tools: []
# (contrary to the memories, which are loaded on demand). # (contrary to the memories, which are loaded on demand).
initial_prompt: | initial_prompt: |
IMPORTANT: You use an idiomatic, object-oriented style. 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). 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 When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase
clearly defines *what* it is. Any details then follow in subsequent sentences. clearly defines *what* it is. Any details then follow in subsequent sentences.
When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless 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). required for sentences).
# the name by which the project can be referenced within Serena # the name by which the project can be referenced within Serena
project_name: "penpot-mcp" 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. # Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages: languages:
- typescript - typescript
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# 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 readonly.
# 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: {}

View File

@ -50,31 +50,65 @@ Follow the steps below to enable the integration.
### Prerequisites ### Prerequisites
The project requires [Node.js](https://nodejs.org/) (tested with v22.x The project requires [Node.js](https://nodejs.org/) (tested with v22.x).
with corepack).
Following the installation of Node.js, the tools `pnpm` and `npx` ### 1. Starting the MCP Server and the Plugin Server
should be available in your terminal. For ensure corepack installed
and enabled correctly, just execute the `./scripts/setup`.
It is also required to have `caddy` executeable in the path, it is #### Running a Released Version via npx
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).
You should probably be using penpot devenv, where all this The easiest way to launch the servers is to use `npx` to run the appropriate
dependencies are already present and correctly setup. But nothing version that matches your Penpot version.
prevents you execute this outside of devenv if you satisfy the
specified dependencies.
* 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 ```shell
cd mcp/
./scripts/setup ./scripts/setup
``` ```
@ -86,9 +120,9 @@ pnpm run bootstrap
This bootstrap command will: This bootstrap command will:
* install dependencies for all components (`pnpm -r run install`) * install dependencies for all components
* build all components (`pnpm -r run build`) * build all components
* start all components (`pnpm -r --parallel run start`) * start all components
### 2. Load the Plugin in Penpot and Establish the Connection ### 2. Load the Plugin in Penpot and Establish the Connection
@ -123,6 +157,19 @@ This bootstrap command will:
### 3. Connect an MCP Client ### 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: By default, the server runs on port 4401 and provides:
- **Modern Streamable HTTP endpoint**: `http://localhost:4401/mcp` - **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, The `mcp-remote` package can proxy stdio transport to HTTP/SSE,
allowing clients that support only stdio to connect to the MCP server indirectly. 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: npx -y mcp-remote http://localhost:4401/mcp --allow-http
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
#### Example: Claude Desktop #### Example: Claude Desktop
@ -170,7 +212,7 @@ Add a `penpot` entry under `mcpServers` with the following content:
"mcpServers": { "mcpServers": {
"penpot": { "penpot": {
"command": "npx", "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: 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 - Shared TypeScript definitions for request/response protocol
- Ensures type safety across server and plugin components - 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 - Provides MCP tools to LLMs for Penpot interaction
- Runs a WebSocket server accepting connections from the Penpot MCP plugin - Runs a WebSocket server accepting connections from the Penpot MCP plugin
- Implements request/response correlation with unique task IDs - Implements request/response correlation with unique task IDs
- Handles task timeouts and proper error reporting - 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 - Connects to the MCP server via WebSocket
- Executes tasks in Penpot using the Plugin API - Executes tasks in Penpot using the Plugin API
- Sends structured responses back to the server# - Sends structured responses back to the server#
4. **Helper Scripts** (`python-scripts/`): 4. **Types Generator** (`types-generator/`):
- Python scripts that prepare data for the MCP server (development use) - Generates data on API types for the MCP server (development use)
The core components are written in TypeScript, rendering interactions with the The core components are written in TypeScript, rendering interactions with the
Penpot Plugin API both natural and type-safe. Penpot Plugin API both natural and type-safe.
## Configuration ## Configuration
The Penpot MCP server can be configured using environment variables. All configuration The Penpot MCP server can be configured using environment variables.
options use the `PENPOT_MCP_` prefix for consistency.
### Server Configuration ### Server Configuration
| Environment Variable | Description | Default | | 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_SERVER_PORT` | Port for the HTTP/SSE server | `4401` |
| `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` | | `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` |
| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` | | `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 | | 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 ## Beyond Local Execution
@ -263,3 +304,17 @@ you may set the following environment variables to configure the two servers
* `PENPOT_MCP_SERVER_ADDRESS=<your-address>`: This sets the hostname or IP address * `PENPOT_MCP_SERVER_ADDRESS=<your-address>`: This sets the hostname or IP address
where the MCP server can be reached. The Penpot MCP Plugin uses this to construct where the MCP server can be reached. The Penpot MCP Plugin uses this to construct
the WebSocket URL as `ws://<your-address>:<port>` (default port: `4402`). the WebSocket URL as `ws://<your-address>:<port>` (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-<version>.tgz` for publishing)
3. Publish to npm: `npm publish penpot-mcp-<version>.tgz --access public`

31
mcp/bin/mcp-local.js Normal file
View File

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

View File

@ -1,15 +1,18 @@
{ {
"name": "mcp-meta", "name": "@penpot/mcp",
"version": "1.0.0", "version": "2.15.0-rc.1.153",
"description": "", "description": "MCP server for Penpot integration",
"bin": {
"penpot-mcp": "./bin/mcp-local.js"
},
"scripts": { "scripts": {
"build": "pnpm -r run build", "build": "pnpm -r run build",
"build:multi-user": "pnpm -r run build:multi-user", "build:multi-user": "pnpm -r run build:multi-user",
"build:types": "bash ./scripts/build-types", "build:types": "bash ./scripts/build-types",
"start": "pnpm -r --parallel run start", "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": "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": "prettier --write packages/",
"fmt:check": "prettier --check packages/" "fmt:check": "prettier --check packages/"
}, },
@ -17,8 +20,7 @@
"type": "git", "type": "git",
"url": "https://github.com/penpot/penpot.git" "url": "https://github.com/penpot/penpot.git"
}, },
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
"private": true,
"devDependencies": { "devDependencies": {
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"prettier": "^3.0.0" "prettier": "^3.0.0"

View File

@ -4,7 +4,7 @@
"description": "Shared type definitions and interfaces for Penpot MCP", "description": "Shared type definitions and interfaces for Penpot MCP",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
"scripts": { "scripts": {
"build": "tsc --build --clean && tsc --build", "build": "tsc --build --clean && tsc --build",
"watch": "tsc --watch", "watch": "tsc --watch",

View File

@ -3,12 +3,87 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Penpot plugin example</title> <title>Penpot MCP Plugin</title>
</head> </head>
<body> <body>
<button type="button" data-appearance="secondary" data-handler="connect-mcp">Connect to MCP server</button> <div class="plugin-container">
<div id="version-warning" class="version-warning" hidden>
<span id="version-warning-text" class="body-s"></span>
</div>
<div id="connection-status" style="margin-top: 10px; font-size: 12px; color: #666">Not connected</div> <div id="connection-status" class="status-pill" data-status="idle">
<span class="status-dot"></span>
<span id="status-text" class="body-s">Not connected</span>
</div>
<button type="button" id="connect-btn" data-appearance="primary" data-handler="connect-mcp">
Connect MCP Server
</button>
<button type="button" id="disconnect-btn" data-appearance="secondary" data-handler="disconnect-mcp" hidden>
Disconnect MCP Server
</button>
<details class="collapsible-section" id="execution-status">
<summary class="collapsible-header">
<svg
class="collapsible-arrow"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="12"
height="12"
fill="currentColor"
aria-hidden="true"
>
<path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z" />
</svg>
<span class="body-s">Execution status</span>
</summary>
<div class="collapsible-body">
<span class="body-s tool-label">Current task</span>
<div class="tool-display">
<svg
class="tool-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="14"
height="14"
fill="currentColor"
aria-hidden="true"
>
<path
d="M22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9-2-2-5-2.4-7.4-1.3L9 6 6 9 1.6 4.7C.4 7.1.9 10.1 2.9 12.1c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.5-.4.5-1.1.1-1.4z"
/>
</svg>
<span id="current-task" class="body-s">---</span>
</div>
<div class="code-section-header">
<span class="body-s tool-label">Executed code</span>
<button type="button" id="copy-code-btn" class="copy-btn" title="Copy code" disabled>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="14"
height="14"
fill="currentColor"
aria-hidden="true"
>
<path
d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"
/>
</svg>
</button>
</div>
<textarea
id="executed-code"
class="code-textarea"
readonly
placeholder="No code executed yet..."
></textarea>
</div>
</details>
</div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>

View File

@ -5,9 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "vite build --watch --config vite.config.ts", "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": "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", "types:check": "tsc --noEmit",
"clean": "rm -rf dist/" "clean": "rm -rf dist/"
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@ -1,6 +1,7 @@
{ {
"name": "Penpot MCP Plugin", "name": "Penpot MCP Plugin",
"code": "plugin.js", "code": "plugin.js",
"icon": "icon.jpg",
"version": 2, "version": 2,
"description": "This plugin enables interaction with the Penpot MCP server", "description": "This plugin enables interaction with the Penpot MCP server",
"permissions": ["content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write"] "permissions": ["content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write"]

View File

@ -1,4 +1,4 @@
import { Board, Fill, FlexLayout, GridLayout, Page, Rectangle, Shape } from "@penpot/plugin-types"; import { Board, Bounds, Fill, FlexLayout, GridLayout, Page, Rectangle, Shape, Text } from "@penpot/plugin-types";
export class PenpotUtils { export class PenpotUtils {
/** /**
@ -189,6 +189,24 @@ export class PenpotUtils {
return penpot.generateStyle([shape], { type: "css", includeChildren: true }); return penpot.generateStyle([shape], { type: "css", includeChildren: true });
} }
/**
* Gets the actual rendering bounds of a shape. For most shapes, this is simply the `bounds` property.
* However, for Text shapes, the `bounds` may not reflect the true size of the rendered text content,
* so we use the `textBounds` property instead.
*
* @param shape - The shape to get the bounds for
*/
public static getBounds(shape: Shape): Bounds {
if (shape.type === "text") {
const text = shape as Text;
// TODO: Remove ts-ignore once type definitions are updated
// @ts-ignore
return text.textBounds;
} else {
return shape.bounds;
}
}
/** /**
* Checks if a child shape is fully contained within its parent's bounds. * Checks if a child shape is fully contained within its parent's bounds.
* Visual containment means all edges of the child are within the parent's bounding box. * Visual containment means all edges of the child are within the parent's bounding box.
@ -198,11 +216,13 @@ export class PenpotUtils {
* @returns true if child is fully contained within parent bounds, false otherwise * @returns true if child is fully contained within parent bounds, false otherwise
*/ */
public static isContainedIn(child: Shape, parent: Shape): boolean { public static isContainedIn(child: Shape, parent: Shape): boolean {
const childBounds = this.getBounds(child);
const parentBounds = this.getBounds(parent);
return ( return (
child.x >= parent.x && childBounds.x >= parentBounds.x &&
child.y >= parent.y && childBounds.y >= parentBounds.y &&
child.x + child.width <= parent.x + parent.width && childBounds.x + childBounds.width <= parentBounds.x + parentBounds.width &&
child.y + child.height <= parent.y + parent.height childBounds.y + childBounds.height <= parentBounds.y + parentBounds.height
); );
} }
@ -298,39 +318,16 @@ export class PenpotUtils {
/** /**
* Decodes a base64 string to a Uint8Array. * Decodes a base64 string to a Uint8Array.
* This is required because the Penpot plugin environment does not provide the atob function.
* *
* @param base64 - The base64-encoded string to decode * @param base64 - The base64-encoded string to decode
* @returns The decoded data as a Uint8Array * @returns The decoded data as a Uint8Array
*/ */
public static atob(base64: string): Uint8Array { public static base64ToByteArray(base64: string): Uint8Array {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; const binary = atob(base64);
const lookup = new Uint8Array(256); const bytes = new Uint8Array(binary.length);
for (let i = 0; i < chars.length; i++) { for (let i = 0; i < binary.length; i++) {
lookup[chars.charCodeAt(i)] = i; bytes[i] = binary.charCodeAt(i);
} }
let bufferLength = base64.length * 0.75;
if (base64[base64.length - 1] === "=") {
bufferLength--;
if (base64[base64.length - 2] === "=") {
bufferLength--;
}
}
const bytes = new Uint8Array(bufferLength);
let p = 0;
for (let i = 0; i < base64.length; i += 4) {
const encoded1 = lookup[base64.charCodeAt(i)];
const encoded2 = lookup[base64.charCodeAt(i + 1)];
const encoded3 = lookup[base64.charCodeAt(i + 2)];
const encoded4 = lookup[base64.charCodeAt(i + 3)];
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
}
return bytes; return bytes;
} }
@ -360,7 +357,7 @@ export class PenpotUtils {
height: number | undefined height: number | undefined
): Promise<Rectangle> { ): Promise<Rectangle> {
// convert base64 to Uint8Array // convert base64 to Uint8Array
const bytes = PenpotUtils.atob(base64); const bytes = PenpotUtils.base64ToByteArray(base64);
// upload the image data to Penpot // upload the image data to Penpot
const imageData = await penpot.uploadMediaData(name, bytes, mimeType); const imageData = await penpot.uploadMediaData(name, bytes, mimeType);
@ -423,6 +420,11 @@ export class PenpotUtils {
* - For mode="fill", it will be whatever format the fill image is stored in. * - 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<Uint8Array> { public static async exportImage(shape: Shape, mode: "shape" | "fill", asSVG: boolean): Promise<Uint8Array> {
// 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) { switch (mode) {
case "shape": case "shape":
return shape.export({ type: asSVG ? "svg" : "png" }); return shape.export({ type: asSVG ? "svg" : "png" });

21
mcp/packages/plugin/src/index.d.ts vendored Normal file
View File

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

View File

@ -1,29 +1,74 @@
import "./style.css"; import "./style.css";
// get the current theme from the URL // 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"; 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 // WebSocket connection management
let ws: WebSocket | null = null; 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 code - the connection state code ("idle" | "connecting" | "connected" | "disconnected" | "error")
* @param isConnectedState - whether the connection is in a connected state (affects color) * @param label - human-readable label to display inside the pill
* @param message - optional additional message to append to the status
*/ */
function updateConnectionStatus(status: string, isConnectedState: boolean, message?: string): void { function updateConnectionStatus(code: string, label: string): void {
if (statusElement) { if (statusPill) {
const displayText = message ? `${status}: ${message}` : status; statusPill.dataset.status = code;
statusElement.textContent = displayText; }
statusElement.style.color = isConnectedState ? "var(--accent-primary)" : "var(--error-700)"; 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. * Establishes a WebSocket connection to the MCP server.
*/ */
function connectToMcpServer(): void { function connectToMcpServer(baseUrl?: string, token?: string): void {
if (ws?.readyState === WebSocket.OPEN) { if (ws?.readyState === WebSocket.OPEN) {
updateConnectionStatus("Already connected", true); updateConnectionStatus("connected", "Connected");
return; return;
} }
try { try {
let wsUrl = PENPOT_MCP_WEBSOCKET_URL; let wsUrl = baseUrl || PENPOT_MCP_WEBSOCKET_URL;
if (isMultiUserMode) { let wsError: unknown | undefined;
// TODO obtain proper userToken from penpot
const userToken = "dummyToken"; if (token) {
wsUrl += `?userToken=${encodeURIComponent(userToken)}`; wsUrl += `?userToken=${encodeURIComponent(token)}`;
} }
ws = new WebSocket(wsUrl); ws = new WebSocket(wsUrl);
updateConnectionStatus("Connecting...", false); updateConnectionStatus("connecting", "Connecting...");
ws.onopen = () => { ws.onopen = () => {
console.log("Connected to MCP server"); setTimeout(() => {
updateConnectionStatus("Connected to MCP server", true); if (ws) {
console.log("Connected to MCP server");
updateConnectionStatus("connected", "Connected");
}
}, 100);
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
console.log("Received from MCP server:", event.data);
try { try {
console.log("Received from MCP server:", event.data);
const request = JSON.parse(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 // Forward the task request to the plugin for execution
parent.postMessage(request, "*"); parent.postMessage(request, "*");
} catch (error) { } catch (error) {
@ -77,34 +132,70 @@ function connectToMcpServer(): void {
}; };
ws.onclose = (event: CloseEvent) => { ws.onclose = (event: CloseEvent) => {
console.log("Disconnected from MCP server"); // If we've send the error update we don't send the disconnect as well
const message = event.reason || undefined; if (!wsError) {
updateConnectionStatus("Disconnected", false, message); console.log("Disconnected from MCP server");
const label = event.reason ? `Disconnected: ${event.reason}` : "Disconnected";
updateConnectionStatus("disconnected", label);
updateCurrentTask(null);
}
ws = null; ws = null;
}; };
ws.onerror = (error) => { ws.onerror = (error) => {
console.error("WebSocket error:", error); console.error("WebSocket error:", error);
wsError = error;
// note: WebSocket error events typically don't contain detailed error messages // note: WebSocket error events typically don't contain detailed error messages
updateConnectionStatus("Connection error", false); updateConnectionStatus("error", "Connection error");
}; };
} catch (error) { } catch (error) {
console.error("Failed to connect to MCP server:", error); console.error("Failed to connect to MCP server:", error);
const message = error instanceof Error ? error.message : undefined; const reason = error instanceof Error ? error.message : undefined;
updateConnectionStatus("Connection failed", false, message); 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(); connectToMcpServer();
}); });
disconnectBtn?.addEventListener("click", () => {
ws?.close();
});
// Listen plugin.ts messages // Listen plugin.ts messages
window.addEventListener("message", (event) => { 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 =
`<b>Version mismatch detected</b>: 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; document.body.dataset.theme = event.data.theme;
} else if (event.data.type === "task-response") { } else if (event.data.type === "task-response") {
// Forward task response back to MCP server // Forward task response back to MCP server
sendTaskResponse(event.data.response); sendTaskResponse(event.data.response);
} }
}); });
parent.postMessage({ type: "ui-initialized" }, "*");

View File

@ -1,22 +1,58 @@
import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler"; import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler";
import { Task, TaskHandler } from "./TaskHandler"; 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. * Registry of all available task handlers.
*/ */
const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()]; 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) // 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 // Register message handlers
penpot.ui.onMessage<string | { id: string; task: string; params: any }>((message) => { penpot.ui.onMessage<string | { id: string; type?: string; status?: string; task: string; params: any }>((message) => {
// Handle plugin task requests if (typeof message === "object" && message.type === "ui-initialized") {
if (typeof message === "object" && message.task && message.id) { // 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) => { handlePluginTaskRequest(message).catch((error) => {
console.error("Error in handlePluginTaskRequest:", 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 // Handle theme change in the iframe
penpot.on("themechange", (theme) => { penpot.on("themechange", (theme) => {
penpot.ui.sendMessage({ penpot.ui.sendMessage({

View File

@ -1,10 +1,190 @@
@import "@penpot/plugin-styles/styles.css"; @import "@penpot/plugin-styles/styles.css";
body { body {
line-height: 1.5; margin: 0;
padding: 10px; padding: 0;
} }
p { .plugin-container {
margin-block-end: 0.75rem; 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);
} }

View File

@ -195,10 +195,44 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
const context = this.context; const context = this.context;
const code = task.params.code; const code = task.params.code;
let result: any = await (async (ctx) => { // set the flags naturalChildOrdering and throwValidationErrors to true during code execution.
const fn = new Function(...Object.keys(ctx), `return (async () => { ${code} })();`); // TODO: Remove all ts-ignore once Penpot types have been updated
return fn(...Object.values(ctx)); let originalNaturalChildOrdering: any, originalThrowValidationErrors: any;
})(context); // @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); console.log("Code execution result:", result);

View File

@ -2,3 +2,4 @@
declare const IS_MULTI_USER_MODE: boolean; declare const IS_MULTI_USER_MODE: boolean;
declare const PENPOT_MCP_WEBSOCKET_URL: string; declare const PENPOT_MCP_WEBSOCKET_URL: string;
declare const PENPOT_MCP_VERSION: string;

View File

@ -1,11 +1,16 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import livePreview from "vite-live-preview"; 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 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("PENPOT_MCP_WEBSOCKET_URL:", JSON.stringify(WS_URI));
console.log("Will define PENPOT_MCP_WEBSOCKET_URL as:", JSON.stringify(WS_URI)); console.log("PENPOT_MCP_VERSION:", MCP_VERSION);
export default defineConfig({ export default defineConfig({
base: "./", base: "./",
@ -31,13 +36,13 @@ export default defineConfig({
}, },
}, },
preview: { preview: {
host: "0.0.0.0", host: SERVER_HOST,
port: 4400, port: 4400,
cors: true, cors: true,
allowedHosts: [], allowedHosts: [],
}, },
define: { define: {
IS_MULTI_USER_MODE: JSON.stringify(process.env.MULTI_USER_MODE === "true"),
PENPOT_MCP_WEBSOCKET_URL: JSON.stringify(WS_URI), PENPOT_MCP_WEBSOCKET_URL: JSON.stringify(WS_URI),
PENPOT_MCP_VERSION: MCP_VERSION,
}, },
}); });

View File

@ -4,7 +4,6 @@ import baseConfig from "./vite.config";
export default mergeConfig( export default mergeConfig(
baseConfig, baseConfig,
defineConfig({ defineConfig({
base: "./",
plugins: [], plugins: [],
}) })
); );

View File

@ -0,0 +1 @@
/pnpm-lock.yaml

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,10 +1,6 @@
You have access to Penpot tools in order to interact with a Penpot design project directly. 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. 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 # Executing Code
One of your key tools is the `execute_code` tool, which allows you to run JavaScript code using the Penpot Plugin API 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. * `parentX` and `parentY` (as well as `boardX` and `boardY`) are READ-ONLY computed properties showing position relative to parent/board.
To position relative to parent, use `penpotUtils.setParentXY(shape, parentX, parentY)` or manually set `shape.x = parent.x + parentX`. To position relative to parent, use `penpotUtils.setParentXY(shape, parentX, parentY)` or manually set `shape.x = parent.x + parentX`.
* `width` and `height` are READ-ONLY. Use `resize(width, height)` method to change dimensions. * `width` and `height` are READ-ONLY. Use `resize(width, height)` method to change dimensions.
* `bounds` is a READ-ONLY property. Use `x`, `y` with `resize()` to modify shape bounds. * `bounds` is READ-ONLY (members: x, y, width, height). To modify the bounding box, change `x`, `y` or apply `resize()`.
**Other Writable Properties**: **Other Writable Properties**:
* `name` - Shape name * `name` - Shape name
* `fills`, `strokes` - Styling properties * `fills: Fill[]`, `strokes: Stroke[]`, `shadows: Shadow[]` - Styling properties
* `rotation`, `opacity`, `blocked`, `hidden`, `visible` - 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**: **Z-Order**:
* The z-order of shapes is determined by the order in the `children` array of the parent shape. * The z-order of shapes is determined by the order in the `children` array of the parent shape.
Therefore, when creating shapes that should be on top of each other, add them to the parent in the correct order Therefore, when creating shapes that should be on top of each other, add them to the parent in the correct order
(i.e. add background shapes first, then foreground shapes later). (i.e. add background shapes first, then foreground shapes later).
CRITICAL: NEVER use the broken function `appendChild` to achieve this, ALWAYS use `parent.insertChild(parent.children.length, shape)`
* To modify z-order after creation, use these methods: `bringToFront()`, `sendToBack()`, `bringForward()`, `sendBackward()`, * To modify z-order after creation, use these methods: `bringToFront()`, `sendToBack()`, `bringForward()`, `sendBackward()`,
and, for precise control, `setParentIndex(index)` (0-based). and, for precise control, `setParentIndex(index)` (0-based).
@ -67,15 +75,15 @@ Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image`
**Hierarchical Structure**: **Hierarchical Structure**:
* `parent` - The parent shape (null for root shapes) * `parent` - The parent shape (null for root shapes)
Note: Hierarchical nesting does not necessarily imply visual containment Note: Hierarchical nesting does not necessarily imply visual containment
* CRITICAL: To add children to a parent shape (e.g. a `Board`): * To add children to a parent shape (e.g. a `Board`): `parent.appendChild(shape)` or `parent.insertChild(index, shape)`
- ALWAYS use `parent.insertChild(index, shape)` to add a child, e.g. `parent.insertChild(parent.children.length, shape)` to append
- NEVER use `parent.appendChild(shape)` as it is BROKEN and will not insert in a predictable place (except in flex layout boards)
* Reparenting: `newParent.appendChild(shape)` or `newParent.insertChild(index, shape)` will move a shape to new parent * Reparenting: `newParent.appendChild(shape)` or `newParent.insertChild(index, shape)` will move a shape to new parent
- Automatically removes the shape from its old parent - Automatically removes the shape from its old parent
- Absolute x/y positions are preserved (use `penpotUtils.setParentXY` to adjust relative position) - Absolute x/y positions are preserved (use `penpotUtils.setParentXY` to adjust relative position)
Cloning: Use `shape.clone(): Shape` to create an exact duplicate (including all properties and children) of a shape; same position as original. 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 # Images
The `Image` type is a legacy type. Images are now typically embedded in a `Fill`, with `fillImage` set to an 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: 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. * 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! - `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`) - 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`) - min/max sizes (`minWidth`, `maxWidth`, `minHeight`, `maxHeight`)
- `zIndex: number` (higher numbers on top) - `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" - `dir`: "row" | "column" | "row-reverse" | "column-reverse"
- Padding: `topPadding`, `rightPadding`, `bottomPadding`, `leftPadding`, or combined `verticalPadding`, `horizontalPadding` - Padding: `topPadding`, `rightPadding`, `bottomPadding`, `leftPadding`, or combined `verticalPadding`, `horizontalPadding`
- To modify spacing: adjust `rowGap` and `columnGap` properties, not individual child positions. - To modify spacing: adjust `rowGap` and `columnGap` properties, not individual child positions.
Optionally, adjust indivudual child margins via `child.layoutChild`. Optionally, adjust individual child margins via `child.layoutChild`.
- When a board has flex layout, - When a board has flex layout, child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true);
- child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true); appending or inserting children automatically positions them according to the layout rules.
appending or inserting children automatically positions them according to the layout rules. - 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.
- CRITICAL: For for dir="column" or dir="row", the order of the `children` array is reversed relative to the visual order! To insert at a specific index, use `board.insertChild(index, shape)`.
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".
- Add to a board with `board.addFlexLayout(): FlexLayout`; instance then accessible via `board.flex`. - Add to a board with `board.addFlexLayout(): FlexLayout`; instance then accessible via `board.flex`.
IMPORTANT: When adding a flex layout to a container that already has children, IMPORTANT: When adding a flex layout to a container that already has children,
use `penpotUtils.addFlexLayout(container, dir)` instead! This preserves the existing visual order of children. use `penpotUtils.addFlexLayout(container, dir)` instead! This preserves the existing visual order of children.
@ -122,9 +123,14 @@ Boards can have layout systems that automatically control the positioning and sp
Check with: `if (board.grid) { ... }` Check with: `if (board.grid) { ... }`
- Properties: `rows`, `columns`, `rowGap`, `columnGap` - Properties: `rows`, `columns`, `rowGap`, `columnGap`
- Children are positioned via 1-based row/column indices - 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` - 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: * When working with boards:
- ALWAYS check if the board has a layout system before attempting to reposition children - 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 - 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 # Text Elements
The rendered content of `Text` element is given by the `characters` property. `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, * To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size,
it only changes the formal bounding box; if the text does not fit it, it will overflow. it only changes the formal bounding box; if the text does not fit it, it will overflow; use `textBounds` for the actual bounding box of the rendered text.
The bounding box is sized automatically as long as the `growType` property is set to "auto-width" or "auto-height". * 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-*" if you want automatic sizing - otherwise the bounding box will be meaningless, with the text overflowing! `resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-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. 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 # 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. 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 # Asset Libraries
Libraries in Penpot are collections of reusable design assets (components, colors, and typographies) that can be shared across files. 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 * `colors: LibraryColor[]` - Array of colors
* `typographies: LibraryTypography[]` - Array of typographies * `typographies: LibraryTypography[]` - Array of typographies
## Colors and Typographies
Adding a color:
```
const newColor: LibraryColor = penpot.library.local.createColor();
newColor.name = 'Brand Primary';
newColor.color = '#0066FF';
```
Adding a typography:
```
const newTypo: LibraryTypography = penpot.library.local.createTypography();
newTypo.name = 'Heading Large';
// Set typography properties...
```
## Components
Using library components: Using library components:
* find a component in the library by name: * find a component in the library by name:
const component: LibraryComponent = library.components.find(comp => comp.name.includes('Button')); `const component: LibraryComponent = library.components.find(comp => comp.name.includes('Button'));`
* create a new instance of the component on the current page: * create a new instance of the component on the current page:
const instance: Shape = component.instance(); `const instance: Shape = component.instance();`
This returns a `Shape` (often a `Board` containing child elements). This returns a `Shape` (often a `Board` containing child elements).
After instantiation, modify the instance's properties as desired. After instantiation, modify the instance's properties as desired.
* get the reference to the main component shape: * get the reference to the main component shape:
const mainShape: Shape = component.mainInstance(); `const mainShape: Shape = component.mainInstance();`
Adding assets to a library: Adding a component to a library:
* const newColor: LibraryColor = penpot.library.local.createColor(); ```
newColor.name = 'Brand Primary'; const shapes: Shape[] = [shape1, shape2]; // shapes to include
newColor.color = '#0066FF'; const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes);
* const newTypo: LibraryTypography = penpot.library.local.createTypography(); newComponent.name = 'My Button';
newTypo.name = 'Heading Large'; ```
// Set typography properties...
* const shapes: Shape[] = [shape1, shape2]; // shapes to include
const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes);
newComponent.name = 'My Button';
Detaching: Detaching:
* When creating new design elements based on a component instance/copy, use `shape.detach()` to break the link to the main component, allowing independent modification. * When creating new design elements based on a component instance/copy, use `shape.detach()` to break the link to the main component, allowing independent modification.
* Without detaching, some manipulations will have no effect; e.g. child/descendant removal will not work. * Without detaching, some manipulations will have no effect; e.g. child/descendant removal will not work.
### Variants
Variants are a system for grouping related component versions along named property axes (e.g. Type, Style), powering a structured swap UI for designers using component instances.
* `VariantContainer` (extends `Board`): The board that physically groups all variant components together.
- check with `isVariantContainer()`
- property `variants: Variants`.
* `Variants`: Defines the combinations of property values for which component variants can exist and manages the concrete component variants.
- `properties: string[]` (ordered list of property names); `addProperty(): 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
Design tokens are reusable design values (colors, dimensions, typography, etc.) for consistent styling. Design tokens are reusable design values (colors, dimensions, typography, etc.) for consistent styling.
@ -274,21 +321,22 @@ Design tokens are reusable design values (colors, dimensions, typography, etc.)
The token library: `penpot.library.local.tokens` (type: `TokenCatalog`) The token library: `penpot.library.local.tokens` (type: `TokenCatalog`)
* `sets: TokenSet[]` - Token collections (order matters for precedence) * `sets: TokenSet[]` - Token collections (order matters for precedence)
* `themes: TokenTheme[]` - Presets that activate specific sets * `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 * `addTheme(group: string, name: string): TokenTheme` - Create new theme
`TokenSet` contains tokens with unique names: `TokenSet` contains tokens with unique names:
* `active: boolean` - Only active sets affect shapes; use `set.toggleActive()` to change: `if (!set.active) set.toggleActive();` * `active: boolean` - Only active sets affect shapes; use `set.toggleActive()` to change: `if (!set.active) set.toggleActive();`
* `tokens: Token[]` - All tokens in set * `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" - `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: - Examples:
const token = set.addToken("color", "color.primary", "#0066FF"); // direct value const token = set.addToken({type: "color", name: "color.primary", value: "#0066FF"}); // direct value
const token2 = set.addToken("color", "color.accent", "{color.primary}"); // reference to another token const token2 = set.addToken({type: "color", name: "color.accent", value: "{color.primary}"}); // reference to another token
`Token`: `Token`: union type encompassing various token types, with common properties:
* `name: string` - Token name (may include group path like "color.base.white") * `name: string` - Token name (typically structured, e.g. "color.base.white")
* `value: string | TokenValueString` - Raw value (may be direct value or reference to another token like "{color.primary}") * `value` - Raw value (direct value or reference to another token like "{color.primary}")
* `resolvedValue` - Computed final value (follows references) * `resolvedValue` - Computed final value (follows references)
* `type: TokenType` * `type: TokenType`
@ -303,21 +351,21 @@ Applying tokens:
(if properties is undefined, use a default property based on the token type - not usually recommended). (if properties is undefined, use a default property based on the token type - not usually recommended).
`TokenProperty` is a union type; possible values are: `TokenProperty` is a union type; possible values are:
- "all": applies the token to all properties it can control - "all": applies the token to all properties it can control
- TokenBorderRadiusProps: "r1", "r2", "r3", "r4" - TokenBorderRadiusProps: "borderRadiusTopLeft", "borderRadiusTopRight", "borderRadiusBottomRight", "borderRadiusBottomLeft"
- TokenShadowProps: "shadow" - TokenShadowProps: "shadow"
- TokenColorProps: "fill", "stroke-color" - TokenColorProps: "fill", "strokeColor"
- TokenDimensionProps: "x", "y", "stroke-width" - TokenDimensionProps: "x", "y", "strokeWidth"
- TokenFontFamiliesProps: "font-families" - TokenFontFamiliesProps: "fontFamilies"
- TokenFontSizesProps: "font-size" - TokenFontSizesProps: "fontSize"
- TokenFontWeightProps: "font-weight" - TokenFontWeightProps: "fontWeight"
- TokenLetterSpacingProps: "letter-spacing" - TokenLetterSpacingProps: "letterSpacing"
- TokenNumberProps: "rotation", "line-height" - TokenNumberProps: "rotation"
- TokenOpacityProps: "opacity" - TokenOpacityProps: "opacity"
- TokenSizingProps: "width", "height", "layout-item-min-w", "layout-item-max-w", "layout-item-min-h", "layout-item-max-h" - TokenSizingProps: "width", "height", "layoutItemMinW", "layoutItemMaxW", "layoutItemMinH", "layoutItemMaxH"
- TokenSpacingProps: "row-gap", "column-gap", "p1", "p2", "p3", "p4", "m1", "m2", "m3", "m4" - TokenSpacingProps: "rowGap", "columnGap", "paddingLeft", "paddingTop", "paddingRight", "paddingBottom", "marginLeft", "marginTop", "marginRight", "marginBottom"
- TokenBorderWidthProps: "stroke-width" - TokenBorderWidthProps: "strokeWidth"
- TokenTextCaseProps: "text-case" - TokenTextCaseProps: "textCase"
- TokenTextDecorationProps: "text-decoration" - TokenTextDecorationProps: "textDecoration"
- TokenTypographyProps: "typography" - TokenTypographyProps: "typography"
* `token.applyToShapes(shapes, properties)` - Apply from token * `token.applyToShapes(shapes, properties)` - Apply from token
* Application is **asynchronous** (wait for ~100ms to see the effects) * Application is **asynchronous** (wait for ~100ms to see the effects)
@ -326,8 +374,27 @@ Applying tokens:
- The actual shape properties that the tokens control will reflect the token's resolved value. - The actual shape properties that the tokens control will reflect the token's resolved value.
Removing tokens: 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 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. You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again.

View File

@ -6,8 +6,7 @@
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:js-yaml --external:sharp", "build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:js-yaml --external:sharp",
"build": "pnpm run build:server && cp -r src/static dist/static && cp -r data dist/data", "build": "pnpm run build:server && node scripts/copy-resources.js",
"build:multi-user": "pnpm run build",
"build:types": "tsc --emitDeclarationOnly --outDir dist", "build:types": "tsc --emitDeclarationOnly --outDir dist",
"start": "node dist/index.js", "start": "node dist/index.js",
"start:multi-user": "node dist/index.js --multi-user", "start:multi-user": "node dist/index.js --multi-user",
@ -23,7 +22,7 @@
], ],
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.24.0", "@modelcontextprotocol/sdk": "^1.24.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
@ -39,6 +38,7 @@
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"cross-env": "^7.0.3",
"@penpot/mcp-common": "workspace:../common", "@penpot/mcp-common": "workspace:../common",
"@types/express": "^4.17.0", "@types/express": "^4.17.0",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",

View File

@ -0,0 +1,5 @@
import { cpSync } from "fs";
// copy static assets and data to dist
cpSync("src/static", "dist/static", { recursive: true });
cpSync("data", "dist/data", { recursive: true });

View File

@ -4,15 +4,12 @@ import { createLogger } from "./logger.js";
/** /**
* Configuration loader for prompts and server settings. * 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 { export class ConfigurationLoader {
private readonly logger = createLogger("ConfigurationLoader"); private readonly logger = createLogger("ConfigurationLoader");
private readonly baseDir: string; private readonly baseDir: string;
private initialInstructions: string; private readonly initialInstructions: string;
private readonly baseInstructions: string;
/** /**
* Creates a new configuration loader instance. * Creates a new configuration loader instance.
@ -22,6 +19,7 @@ export class ConfigurationLoader {
constructor(baseDir: string) { constructor(baseDir: string) {
this.baseDir = baseDir; this.baseDir = baseDir;
this.initialInstructions = this.loadFileContent(join(this.baseDir, "data", "initial_instructions.md")); 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 { 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 { public getInitialInstructions(): string {
return this.initialInstructions; 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;
}
} }

View File

@ -21,34 +21,61 @@ export interface SessionContext {
userToken?: string; userToken?: string;
} }
/**
* Represents an active Streamable HTTP session, grouping the transport, MCP server, and session metadata.
*/
class StreamableSession {
constructor(
public readonly transport: StreamableHTTPServerTransport,
public readonly userToken: string | undefined,
public lastActiveTime: number
) {}
}
/**
* Holds information about a registered tool, including its instance, name, and configuration.
*/
class ToolInfo {
constructor(
public readonly instance: Tool<any>,
public readonly name: string,
public readonly config: { description: string; inputSchema: any }
) {}
}
export class PenpotMcpServer { export class PenpotMcpServer {
/**
* Timeout, in minutes, for idle Streamable HTTP sessions before they are automatically closed and removed.
*/
private static readonly SESSION_TIMEOUT_MINUTES = 60;
private readonly logger = createLogger("PenpotMcpServer"); private readonly logger = createLogger("PenpotMcpServer");
private readonly server: McpServer; private readonly tools: ToolInfo[];
private readonly tools: Map<string, Tool<any>>;
public readonly configLoader: ConfigurationLoader; public readonly configLoader: ConfigurationLoader;
private app: any; private app: any;
public readonly pluginBridge: PluginBridge; public readonly pluginBridge: PluginBridge;
private readonly replServer: ReplServer; private readonly replServer: ReplServer;
private apiDocs: ApiDocs; private apiDocs: ApiDocs;
private readonly penpotHighLevelOverview: string;
private readonly connectionInstructions: string;
/** /**
* Manages session-specific context, particularly user tokens for each request. * Manages session-specific context, particularly user tokens for each request.
*/ */
private readonly sessionContext = new AsyncLocalStorage<SessionContext>(); private readonly sessionContext = new AsyncLocalStorage<SessionContext>();
private readonly transports = { private readonly streamableTransports: Record<string, StreamableSession> = {};
streamable: {} as Record<string, StreamableHTTPServerTransport>, private readonly sseTransports: Record<string, { transport: SSEServerTransport; userToken?: string }> = {};
sse: {} as Record<string, { transport: SSEServerTransport; userToken?: string }>,
};
public readonly host: string; public readonly host: string;
public readonly port: number; public readonly port: number;
public readonly webSocketPort: number; public readonly webSocketPort: number;
public readonly replPort: number; public readonly replPort: number;
private sessionTimeoutInterval: ReturnType<typeof setInterval> | undefined;
constructor(private isMultiUser: boolean = false) { constructor(private isMultiUser: boolean = false) {
// read port configuration from environment variables // read port configuration from environment variables
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.port = parseInt(process.env.PENPOT_MCP_SERVER_PORT ?? "4401", 10);
this.webSocketPort = parseInt(process.env.PENPOT_MCP_WEBSOCKET_PORT ?? "4402", 10); this.webSocketPort = parseInt(process.env.PENPOT_MCP_WEBSOCKET_PORT ?? "4402", 10);
this.replPort = parseInt(process.env.PENPOT_MCP_REPL_PORT ?? "4403", 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.configLoader = new ConfigurationLoader(process.cwd());
this.apiDocs = new ApiDocs(); this.apiDocs = new ApiDocs();
this.server = new McpServer( // prepare instructions
{ let instructions = this.configLoader.getInitialInstructions();
name: "penpot-mcp-server", instructions = instructions.replace("$api_types", this.apiDocs.getTypeNames().join(", "));
version: "1.0.0", this.penpotHighLevelOverview = instructions;
}, this.connectionInstructions = this.configLoader.getBaseInstructions();
{
instructions: this.getInitialInstructions(), this.tools = this.initTools();
}
);
this.tools = new Map<string, Tool<any>>();
this.pluginBridge = new PluginBridge(this, this.webSocketPort); this.pluginBridge = new PluginBridge(this, this.webSocketPort);
this.replServer = new ReplServer(this.pluginBridge, this.replPort); this.replServer = new ReplServer(this.pluginBridge, this.replPort);
this.registerTools();
} }
/** /**
@ -104,10 +126,11 @@ export class PenpotMcpServer {
return !this.isRemoteMode(); return !this.isRemoteMode();
} }
public getInitialInstructions(): string { /**
let instructions = this.configLoader.getInitialInstructions(); * Retrieves the high-level overview instructions explaining core Penpot usage.
instructions = instructions.replace("$api_types", this.apiDocs.getTypeNames().join(", ")); */
return instructions; public getHighLevelOverviewInstructions(): string {
return this.penpotHighLevelOverview;
} }
/** /**
@ -119,88 +142,134 @@ export class PenpotMcpServer {
return this.sessionContext.getStore(); return this.sessionContext.getStore();
} }
private registerTools(): void { private initTools(): ToolInfo[] {
// Create relevant tool instances (depending on file system access)
const toolInstances: Tool<any>[] = [ const toolInstances: Tool<any>[] = [
new ExecuteCodeTool(this), new ExecuteCodeTool(this),
new HighLevelOverviewTool(this), new HighLevelOverviewTool(this),
new PenpotApiInfoTool(this, this.apiDocs), new PenpotApiInfoTool(this, this.apiDocs),
new ExportShapeTool(this), // tool adapts to file system access internally new ExportShapeTool(this),
]; ];
if (this.isFileSystemAccessEnabled()) { if (this.isFileSystemAccessEnabled()) {
toolInstances.push(new ImportImageTool(this)); toolInstances.push(new ImportImageTool(this));
} }
for (const tool of toolInstances) { return toolInstances.map((instance) => {
const toolName = tool.getToolName(); this.logger.info(`Registering tool: ${instance.getToolName()}`);
this.tools.set(toolName, tool); return new ToolInfo(instance, instance.getToolName(), {
description: instance.getToolDescription(),
inputSchema: instance.getInputSchema(),
});
});
}
// Register each tool with McpServer /**
this.logger.info(`Registering tool: ${toolName}`); * Creates a fresh {@link McpServer} instance with all tools registered.
this.server.registerTool( */
toolName, private createMcpServer(): McpServer {
{ const server = new McpServer(
description: tool.getToolDescription(), { name: "penpot", version: "1.0.0" },
inputSchema: tool.getInputSchema(), { instructions: this.connectionInstructions }
}, );
async (args) => {
return tool.execute(args); for (const tool of this.tools) {
} server.registerTool(tool.name, tool.config, async (args: any) => tool.instance.execute(args));
);
} }
return server;
}
/**
* Starts a periodic timer that closes and removes Streamable HTTP sessions that have been
* idle for longer than {@link SESSION_TIMEOUT_MINUTES}.
*/
private startSessionTimeoutChecker(): void {
const timeoutMs = PenpotMcpServer.SESSION_TIMEOUT_MINUTES * 60 * 1000;
const checkIntervalMs = timeoutMs / 2;
this.sessionTimeoutInterval = setInterval(() => {
this.logger.info("Checking for stale sessions...");
const now = Date.now();
let removed = 0;
for (const session of Object.values(this.streamableTransports)) {
if (now - session.lastActiveTime > timeoutMs) {
session.transport.close();
removed++;
}
}
this.logger.info(
`Removed ${removed} stale session(s); total sessions remaining: ${Object.keys(this.streamableTransports).length}`
);
}, checkIntervalMs);
} }
private setupHttpEndpoints(): void { private setupHttpEndpoints(): void {
/** /**
* Modern Streamable HTTP connection endpoint * Modern Streamable HTTP connection endpoint.
*
* New sessions are created on initialize requests (no mcp-session-id header).
* Subsequent requests for an existing session are routed to the stored transport,
* with the session context populated from the stored userToken.
*/ */
this.app.all("/mcp", async (req: any, res: any) => { this.app.all("/mcp", async (req: any, res: any) => {
const userToken = req.query.userToken as string | undefined; 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 { 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; // handle the request
let transport: StreamableHTTPServerTransport; await this.sessionContext.run({ userToken }, async () => {
if (sessionId && this.transports.streamable[sessionId]) {
transport = this.transports.streamable[sessionId];
} else {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id: string) => {
this.transports.streamable[id] = transport;
},
});
transport.onclose = () => {
if (transport.sessionId) {
delete this.transports.streamable[transport.sessionId];
}
};
await this.server.connect(transport);
}
await transport.handleRequest(req, res, req.body); await transport.handleRequest(req, res, req.body);
}); });
}); });
/** /**
* Legacy SSE connection endpoint * Legacy SSE connection endpoint.
*/ */
this.app.get("/sse", async (req: any, res: any) => { this.app.get("/sse", async (req: any, res: any) => {
const userToken = req.query.userToken as string | undefined; const userToken = req.query.userToken as string | undefined;
await this.sessionContext.run({ userToken }, async () => { await this.sessionContext.run({ userToken }, async () => {
const transport = new SSEServerTransport("/messages", res); const transport = new SSEServerTransport("/messages", res);
this.transports.sse[transport.sessionId] = { transport, userToken }; this.sseTransports[transport.sessionId] = { transport, userToken };
const server = this.createMcpServer();
await server.connect(transport);
res.on("close", () => { res.on("close", () => {
delete this.transports.sse[transport.sessionId]; delete this.sseTransports[transport.sessionId];
server.close();
}); });
await this.server.connect(transport);
}); });
}); });
@ -209,7 +278,7 @@ export class PenpotMcpServer {
*/ */
this.app.post("/messages", async (req: any, res: any) => { this.app.post("/messages", async (req: any, res: any) => {
const sessionId = req.query.sessionId as string; const sessionId = req.query.sessionId as string;
const session = this.transports.sse[sessionId]; const session = this.sseTransports[sessionId];
if (session) { if (session) {
await this.sessionContext.run({ userToken: session.userToken }, async () => { await this.sessionContext.run({ userToken: session.userToken }, async () => {
@ -236,8 +305,9 @@ export class PenpotMcpServer {
this.logger.info(`Legacy SSE endpoint: http://${this.host}:${this.port}/sse`); this.logger.info(`Legacy SSE endpoint: http://${this.host}:${this.port}/sse`);
this.logger.info(`WebSocket server URL: ws://${this.host}:${this.webSocketPort}`); this.logger.info(`WebSocket server URL: ws://${this.host}:${this.webSocketPort}`);
// start the REPL server // start the REPL server and session timeout checker
await this.replServer.start(); await this.replServer.start();
this.startSessionTimeoutChecker();
resolve(); resolve();
}); });
@ -251,6 +321,7 @@ export class PenpotMcpServer {
*/ */
public async stop(): Promise<void> { public async stop(): Promise<void> {
this.logger.info("Stopping Penpot MCP Server..."); this.logger.info("Stopping Penpot MCP Server...");
clearInterval(this.sessionTimeoutInterval);
await this.replServer.stop(); await this.replServer.stop();
this.logger.info("Penpot MCP Server stopped"); this.logger.info("Penpot MCP Server stopped");
} }

Some files were not shown because too many files have changed in this diff Show More