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)
- Remove tokens path node and bulk remove tokens [Taiga #13007](https://tree.taiga.io/project/penpot/us/13007)
- Replace themes management modal radio buttons for switches [Taiga #9215](https://tree.taiga.io/project/penpot/us/9215)
- [MCP server] Integrations section [Taiga #13112](https://tree.taiga.io/project/penpot/us/13112)
- [Access Tokens] Look & feel refinement [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114)
### :bug: Bugs fixed

View File

@@ -463,8 +463,10 @@
:fn (mg/resource "app/migrations/sql/0144-mod-server-error-report-table.sql")}
{:name "0145-fix-plugins-uri-on-profile"
:fn mg0145/migrate}])
:fn mg0145/migrate}
{:name "0146-mod-access-token-table"
:fn (mg/resource "app/migrations/sql/0146-mod-access-token-table.sql")}])
(defn apply-migrations!
[pool name migrations]

View File

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

View File

@@ -23,7 +23,7 @@
(dissoc row :perms))
(defn create-access-token
[{:keys [::db/conn] :as cfg} profile-id name expiration]
[{:keys [::db/conn] :as cfg} profile-id name expiration type]
(let [token-id (uuid/next)
expires-at (some-> expiration (ct/in-future))
created-at (ct/now)
@@ -36,6 +36,7 @@
{:id token-id
:name name
:token token
:type type
:profile-id profile-id
:created-at created-at
:updated-at created-at
@@ -50,17 +51,18 @@
(def ^:private schema:create-access-token
[:map {:title "create-access-token"}
[:name [:string {:max 250 :min 1}]]
[:expiration {:optional true} ::ct/duration]])
[:expiration {:optional true} ::ct/duration]
[:type {:optional true} :string]])
(sv/defmethod ::create-access-token
{::doc/added "1.18"
::sm/params schema:create-access-token}
[cfg {:keys [::rpc/profile-id name expiration]}]
[cfg {:keys [::rpc/profile-id name expiration type]}]
(quotes/check! cfg {::quotes/id ::quotes/access-tokens-per-profile
::quotes/profile-id profile-id})
(db/tx-run! cfg create-access-token profile-id name expiration))
(db/tx-run! cfg create-access-token profile-id name expiration type))
(def ^:private schema:delete-access-token
[:map {:title "delete-access-token"}
@@ -83,5 +85,22 @@
(->> (db/query pool :access-token
{:profile-id profile-id}
{:order-by [[:expires-at :asc] [:created-at :asc]]
:columns [:id :name :perms :created-at :updated-at :expires-at]})
:columns [:id :name :perms :type :created-at :updated-at :expires-at]})
(mapv decode-row)))
(def ^:private schema:get-current-mcp-token
[:map {:title "get-current-mcp-token"}])
(sv/defmethod ::get-current-mcp-token
{::doc/added "2.15"
::sm/params schema:get-current-mcp-token}
[{:keys [::db/pool]} {:keys [::rpc/profile-id ::rpc/request-at]}]
(->> (db/query pool :access-token
{:profile-id profile-id
:type "mcp"}
{:order-by [[:expires-at :asc] [:created-at :asc]]
:columns [:token :expires-at]})
(remove #(and (some? (:expires-at %))
(ct/is-after? request-at (:expires-at %))))
(map decode-row)
(first)))

View File

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

View File

@@ -102,7 +102,7 @@
(t/deftest access-token-authz
(let [profile (th/create-profile* 1)
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil)
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil nil)
handler (#'app.http.access-token/wrap-authz identity th/*system*)]
(let [response (handler nil)]

View File

@@ -107,4 +107,18 @@
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [results (:result out)]
(t/is (= 2 (count results))))))))
(t/is (= 2 (count results))))))
(t/testing "get mcp token"
(let [_ (th/command! {::th/type :create-access-token
::rpc/profile-id (:id prof)
:type "mcp"
:name "token 1"
:perms ["get-profile"]})
{:keys [error result]}
(th/command! {::th/type :get-current-mcp-token
::rpc/profile-id (:id prof)})]
;; (th/print-result! result)
(t/is (nil? error))
(t/is (string? (:token result)))))))

View File

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

View File

@@ -13,3 +13,10 @@
unit tests or backend code for logs or error messages."
[key & _args]
key)
(defn c
"This function will be monkeypatched at runtime with the real function in frontend i18n.
Here it just returns the key passed as argument. This way the result can be used in
unit tests or backend code for logs or error messages."
[x]
x)

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]
(assert (uuid? token-id) "expected uuid for `token-id`")
(if-let [token (get-token- this token-id)]
(let [token' (-> (make-token (f token))
(assoc :modified-at (ct/now)))]
(TokenSet. id
name
description
(ct/now)
(if (= (:name token) (:name token'))
(assoc tokens (:name token') token')
(-> tokens
(d/oassoc-before (:name token) (:name token') token')
(dissoc (:name token))))))
(let [token' (f token)]
(if (not= token token')
(let [token' (assoc token' :modified-at (ct/now))]
(TokenSet. id
name
description
(ct/now)
(if (= (:name token) (:name token'))
(assoc tokens (:name token') token')
(-> tokens
(d/oassoc-before (:name token) (:name token') token')
(dissoc (:name token))))))
this))
this))
(delete-token- [this token-id]
@@ -303,6 +305,35 @@
(-clj->js [this]
(clj->js (datafy this)))))
(def ^:private set-prefix "S-")
(def ^:private set-group-prefix "G-")
(def ^:private set-separator "/")
(defn get-set-path
[token-set]
(cpn/split-path (get-name token-set) :separator set-separator))
(defn split-set-name
[name]
(cpn/split-path name :separator set-separator))
(defn join-set-path [path]
(cpn/join-path path :separator set-separator :with-spaces? false))
(defn normalize-set-name
"Normalize a set name (ensure that there are no extra spaces, like ' group / set' -> 'group/set').
If `relative-to` is provided, the normalized name will preserve the same group prefix as reference name."
([name]
(-> (split-set-name (str name))
(cpn/join-path :separator set-separator :with-spaces? false)))
([name relative-to]
(-> (concat (butlast (split-set-name relative-to))
(split-set-name (str name)))
(cpn/join-path :separator set-separator :with-spaces? false))))
(defn token-set?
[o]
(instance? TokenSet o))
@@ -357,6 +388,7 @@
(def check-token-set
(sm/check-fn schema:token-set :hint "expected valid token set"))
(defn map->token-set
[& {:as attrs}]
(TokenSet. (:id attrs)
@@ -372,38 +404,10 @@
(update :modified-at #(or % (ct/now)))
(update :tokens #(into (d/ordered-map) %))
(update :description d/nilv "")
(update :name normalize-set-name)
(check-token-set-attrs)
(map->token-set)))
(def ^:private set-prefix "S-")
(def ^:private set-group-prefix "G-")
(def ^:private set-separator "/")
(defn get-set-path
[token-set]
(cpn/split-path (get-name token-set) :separator set-separator))
(defn split-set-name
[name]
(cpn/split-path name :separator set-separator))
(defn join-set-path [path]
(cpn/join-path path :separator set-separator :with-spaces? false))
(defn normalize-set-name
"Normalize a set name (ensure that there are no extra spaces, like ' group / set' -> 'group/set').
If `relative-to` is provided, the normalized name will preserve the same group prefix as reference name."
([name]
(-> (split-set-name name)
(cpn/join-path :separator set-separator :with-spaces? false)))
([name relative-to]
(-> (concat (butlast (split-set-name relative-to))
(split-set-name name))
(cpn/join-path :separator set-separator :with-spaces? false))))
(defn normalized-set-name?
"Check if a set name is normalized (no extra spaces)."
[name]
@@ -609,7 +613,7 @@
is-source
external-id
(ct/now)
set-names))
(into #{} (filter some?) set-names)))
(enable-set [this set-name]
(set-sets this (conj sets set-name)))
@@ -630,14 +634,9 @@
(update-set-name [this prev-set-name set-name]
(if (get sets prev-set-name)
(TokenTheme. id
name
group
description
is-source
external-id
(ct/now)
(conj (disj sets prev-set-name) set-name))
(let [sets (-> (disj sets prev-set-name)
(conj set-name))]
(set-sets this sets))
this))
(theme-matches-group-name [this group name]
@@ -722,7 +721,8 @@
(update :is-source d/nilv false)
(update :external-id #(or % (str new-id)))
(update :modified-at #(or % (ct/now)))
(update :sets set)
(update :sets #(into #{} (comp (filter some?)
(map normalize-set-name)) %))
(check-token-theme-attrs)
(map->TokenTheme))))

View File

@@ -8,7 +8,7 @@ localhost:3449 {
header -Strict-Transport-Security
}
http://localhost:3450 {
:3450 {
# For subpath test
# handle_path /penpot/* {
# 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 CARGO_HOME="/home/penpot/.cargo"
export PENPOT_MCP_PLUGIN_SERVER_HOST=0.0.0.0
export PENPOT_MCP_SERVER_HOST=0.0.0.0
alias l='ls --color -GFlh'
alias ll='ls --color -GFlh'
alias rm='rm -rf'

View File

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

View File

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

View File

@@ -50,7 +50,7 @@
"devDependencies": {
"@penpot/draft-js": "workspace:./packages/draft-js",
"@penpot/mousetrap": "workspace:./packages/mousetrap",
"@penpot/plugins-runtime": "link:../plugins/dist/plugins-runtime",
"@penpot/plugins-runtime": "link:../plugins/libs/plugins-runtime",
"@penpot/svgo": "penpot/svgo#v3.2",
"@penpot/text-editor": "workspace:./text-editor",
"@penpot/tokenscript": "workspace:./packages/tokenscript",

View File

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

View File

@@ -36,7 +36,7 @@ popd
pushd ../mcp;
rm -rf node_modules;
./scripts/setup
WS_URI="/mcp/ws" pnpm run --filter "mcp-plugin" build:multi-user
WS_URI="/mcp/ws" pnpm run --filter "mcp-plugin" build
popd;
pnpm run build:app:main $EXTRA_PARAMS;

View File

@@ -172,6 +172,10 @@
(normalize-uri (or (obj/get global "penpotPublicURI")
(obj/get location "origin"))))
(def mcp-ws-uri
(or (some-> (obj/get global "penpotMcpServerURI") u/uri)
(u/join public-uri "mcp/ws")))
(def rasterizer-uri
(or (some-> (obj/get global "penpotRasterizerURI") normalize-uri)
public-uri))
@@ -205,6 +209,9 @@
(let [f (obj/get global "initializeExternalConfigInfo")]
(when (fn? f) (f))))
(def mcp-server-url (-> public-uri u/ensure-path-slash (u/join "mcp/stream") str))
(def mcp-help-center-uri "https://help.penpot.app/mcp/")
;; --- Helper Functions
(defn ^boolean check-browser? [candidate]

View File

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

View File

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

View File

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

View File

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

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 update-presence))))))
(defn handle-pointer-update
[{:keys [page-id session-id position zoom zoom-inverse vbox vport] :as msg}]
(ptk/reify ::handle-pointer-update

View File

@@ -49,14 +49,14 @@
;; (note that dwsh/update-shapes function returns an event)
(defn update-shape-radius-all
([value shape-ids attributes] (update-shape-radius-all value shape-ids attributes nil))
([value shape-ids _attributes page-id] ; The attributes param is needed to have the same arity that other update functions
(defn update-shape-radius
([value shape-ids attributes] (update-shape-radius value shape-ids attributes nil))
([value shape-ids attributes page-id]
(when (number? value)
(let [value (max 0 value)]
(dwsh/update-shapes shape-ids
(fn [shape]
(ctsr/set-radius-to-all-corners shape value))
(ctsr/set-radius-for-corners shape attributes value))
{:reg-objects? true
:ignore-touched true
:page-id page-id
@@ -531,7 +531,7 @@
(some attributes #{:r1 :r2 :r3 :r4})
(conj #(if (= attributes #{:r1 :r2 :r3 :r4})
(update-shape-radius-all value shape-ids attributes page-id)
(update-shape-radius value shape-ids attributes page-id)
(update-shape-radius-for-corners
value shape-ids
(set (filter attributes #{:r1 :r2 :r3 :r4}))
@@ -607,6 +607,46 @@
:state state})]
(apply rx/of (map #(%) actions)))))))))
(def attributes->shape-update
"Maps each attribute-set to the update function that applies it to a shape.
Used both here (to resolve the correct update fn when explicit attrs are
passed to toggle-token) and in propagation.cljs (re-exported from there)."
{ctt/border-radius-keys update-shape-radius-for-corners
ctt/color-keys update-fill-stroke
ctt/stroke-width-keys update-stroke-width
ctt/sizing-keys apply-dimensions-token
ctt/opacity-keys update-opacity
ctt/rotation-keys update-rotation
;; Typography
ctt/font-family-keys update-font-family
ctt/font-size-keys update-font-size
ctt/font-weight-keys update-font-weight
ctt/letter-spacing-keys update-letter-spacing
ctt/text-case-keys update-text-case
ctt/text-decoration-keys update-text-decoration
ctt/typography-token-keys update-typography
ctt/shadow-keys update-shadow
ctt/line-height-keys update-line-height
;; Layout
#{:x :y} update-shape-position
#{:p1 :p2 :p3 :p4} update-layout-padding
#{:m1 :m2 :m3 :m4} update-layout-item-margin
#{:column-gap :row-gap} update-layout-gap
#{:width :height} apply-dimensions-token
#{:layout-item-min-w :layout-item-min-h
:layout-item-max-w :layout-item-max-h} update-layout-sizing-limits})
;; Flattened per-individual-key version of attributes->shape-update.
;; Allows O(1) lookup of the update function for any single attribute.
(def ^:private attr->shape-update
(reduce
(fn [acc [attr-set update-fn]]
(into acc (map (fn [k] [k update-fn]) attr-set)))
{}
attributes->shape-update))
;; Events to apply / unapply tokens to shapes ------------------------------------------------------------
(defn apply-token
@@ -620,65 +660,73 @@
ptk/WatchEvent
(watch [_ state _]
;; We do not allow to apply tokens while text editor is open.
(if (empty? (get state :workspace-editor-state))
(let [attributes-to-remove
;; Remove atomic typography tokens when applying composite and vice-verca
(cond
(ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys)
(ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys)
:else attributes-to-remove)]
(when-let [tokens (some-> (dsh/lookup-file-data state)
(get :tokens-lib)
(ctob/get-tokens-in-active-sets))]
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [undo-id (js/Symbol)
objects (dsh/lookup-page-objects state)
selected-shapes (select-keys objects shape-ids)
;; The classic text editor sets :workspace-editor-state; the WASM text editor
;; does not, so we also check :workspace-local :edition for text shapes.
(let [edition (get-in state [:workspace-local :edition])
objects (dsh/lookup-page-objects state)
text-editing? (and (some? edition)
(= :text (:type (get objects edition))))]
(if (and (empty? (get state :workspace-editor-state))
(not text-editing?))
(let [attributes-to-remove
;; Remove atomic typography tokens when applying composite and vice-verca
(cond
(ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys)
(ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys)
:else attributes-to-remove)]
(when-let [tokens (some-> (dsh/lookup-file-data state)
(get :tokens-lib)
(ctob/get-tokens-in-active-sets))]
(->> (if (contains? cf/flags :tokenscript)
(rx/of (ts/resolve-tokens tokens))
(sd/resolve-tokens tokens))
(rx/mapcat
(fn [resolved-tokens]
(let [undo-id (js/Symbol)
objects (dsh/lookup-page-objects state)
selected-shapes (select-keys objects shape-ids)
shapes (->> selected-shapes
(filter (fn [[_ shape]]
(or
(and (ctsl/any-layout-immediate-child? objects shape)
(some ctt/spacing-margin-keys attributes))
(and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape))
(all-attrs-appliable-for-token? attributes (:type token)))))))
shape-ids (d/nilv (keys shapes) [])
any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
shapes (->> selected-shapes
(filter (fn [[_ shape]]
(or
(and (ctsl/any-layout-immediate-child? objects shape)
(some ctt/spacing-margin-keys attributes))
(and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape))
(all-attrs-appliable-for-token? attributes (:type token)))))))
shape-ids (d/nilv (keys shapes) [])
any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value])
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)
tokenized-attributes (cfo/attributes-map attributes token)
type (:type token)]
(rx/concat
(rx/of
(st/emit! (ev/event {::ev/name "apply-tokens"
:type type
:applied-to attributes
:applied-to-variant any-variant?}))
(dwu/start-undo-transaction undo-id)
(dwsh/update-shapes shape-ids (fn [shape]
(cond-> shape
attributes-to-remove
(update :applied-tokens #(apply (partial dissoc %) attributes-to-remove))
:always
(update :applied-tokens merge tokenized-attributes)))))
(when on-update-shape
(let [res (on-update-shape resolved-value shape-ids attributes)]
;; Composed updates return observables and need to be executed differently
(if (rx/observable? res)
res
(rx/of res))))
(rx/of (dwu/commit-undo-transaction undo-id)))))))))
(rx/of (ntf/show {:content (tr "workspace.tokens.error-text-edition")
:type :toast
:level :warning
:timeout 3000}))))))
resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value])
resolved-value (if (contains? cf/flags :tokenscript)
(ts/tokenscript-symbols->penpot-unit resolved-value)
resolved-value)
tokenized-attributes (cfo/attributes-map attributes token)
type (:type token)]
(rx/concat
(rx/of
(st/emit! (ev/event {::ev/name "apply-tokens"
:type type
:applied-to attributes
:applied-to-variant any-variant?}))
(dwu/start-undo-transaction undo-id)
(dwsh/update-shapes shape-ids (fn [shape]
(cond-> shape
attributes-to-remove
(update :applied-tokens #(apply (partial dissoc %) attributes-to-remove))
:always
(update :applied-tokens merge tokenized-attributes)))))
(when on-update-shape
(let [res (on-update-shape resolved-value shape-ids attributes)]
;; Composed updates return observables and need to be executed differently
(if (rx/observable? res)
res
(rx/of res))))
(rx/of (dwu/commit-undo-transaction undo-id)))))))))
(rx/of (ntf/show {:content (tr "workspace.tokens.error-text-edition")
:type :toast
:level :warning
:timeout 3000})))))))
(defn apply-spacing-token-separated
"Handles edge-case for spacing token when applying token via toggle button.
@@ -744,10 +792,16 @@
{:keys [attributes all-attributes on-update-shape]}
(get token-properties (:type token))
on-update-shape
(if (seq attrs)
(or (get attr->shape-update (first attrs)) on-update-shape)
on-update-shape)
unapply-tokens?
(cfo/shapes-token-applied? token shapes (or attrs all-attributes attributes))
shape-ids (map :id shapes)]
shape-ids
(map :id shapes)]
(if unapply-tokens?
(rx/of
@@ -808,7 +862,7 @@
:border-radius
{:title "Border Radius"
:attributes ctt/border-radius-keys
:on-update-shape update-shape-radius-all
:on-update-shape update-shape-radius
:modal {:key :tokens/border-radius
:fields [{:label "Border Radius"
:key :border-radius}]}}

View File

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

View File

@@ -33,6 +33,16 @@
;; Will contain last uncaught exception
(def last-exception nil)
(defn is-plugin-error?
"This is a placeholder that always return false. It will be
overwritten when plugin system is initialized. This works this way
because we can't import plugins here because plugins requries full
DOM.
This placeholder is set on app.plugins/initialize event"
[_]
false)
;; Re-entrancy guard: prevents on-error from calling itself recursively.
;; If an error occurs while we are already handling an error (e.g. the
;; notification emit itself throws), we log it and bail out immediately
@@ -206,6 +216,16 @@
(ex/print-throwable cause :prefix "Unexpected Error")
(flash :cause cause :type :unhandled))))
(defmethod ptk/handle-error :wasm-error
[error]
(when-let [cause (::instance error)]
(ex/print-throwable cause)
(let [code (get error :code)]
(if (or (= code :panic)
(= code :webgl-context-lost))
(st/emit! (rt/assign-exception error))
(flash :type :handled :cause cause)))))
;; We receive a explicit authentication error; If the uri is for
;; workspace, dashboard, viewer or settings, then assign the exception
;; for show the error page. Otherwise this explicitly clears all
@@ -420,6 +440,15 @@
(and (string? stack)
(str/includes? stack "posthog"))))
;; Check if the error is marked as originating from plugin code.
;; The plugin runtime tracks plugin errors in a WeakMap, which works
;; even in SES hardened environments where error objects may be frozen.
(from-plugin? [cause]
(try
(is-plugin-error? cause)
(catch :default _
false)))
(is-ignorable-exception? [cause]
(let [message (ex-message cause)]
(or (from-extension? cause)
@@ -447,32 +476,55 @@
(on-unhandled-error [event]
(.preventDefault ^js event)
(when-let [cause (unchecked-get event "error")]
(when-not (is-ignorable-exception? cause)
(if (stale-asset-error? cause)
(cf/throttled-reload :reason (ex-message cause))
(let [data (ex-data cause)
type (get data :type)]
(set! last-exception cause)
(if (= :wasm-error type)
(on-error cause)
(do
(ex/print-throwable cause :prefix "Uncaught Exception")
(ts/asap #(flash :cause cause :type :unhandled)))))))))
(cond
(stale-asset-error? cause)
(cf/throttled-reload :reason (ex-message cause))
;; Plugin errors: log to console and ignore
(from-plugin? cause)
(ex/print-throwable cause :prefix "Plugin Error")
;; Other ignorable exceptions: ignore silently
(is-ignorable-exception? cause)
nil
;; All other errors: show exception page
:else
(let [data (ex-data cause)
type (get data :type)]
(set! last-exception cause)
(if (= :wasm-error type)
(on-error cause)
(do
(ex/print-throwable cause :prefix "Uncaught Exception")
(ts/asap #(flash :cause cause :type :unhandled))))))))
(on-unhandled-rejection [event]
(.preventDefault ^js event)
(when-let [cause (unchecked-get event "reason")]
(when-not (is-ignorable-exception? cause)
(if (stale-asset-error? cause)
(cf/throttled-reload :reason (ex-message cause))
(let [data (ex-data cause)
type (get data :type)]
(set! last-exception cause)
(if (= :wasm-error type)
(on-error cause)
(do
(ex/print-throwable cause :prefix "Uncaught Rejection")
(ts/asap #(flash :cause cause :type :unhandled)))))))))]
(cond
(stale-asset-error? cause)
(cf/throttled-reload :reason (ex-message cause))
;; Plugin errors: log to console and ignore
(from-plugin? cause)
(ex/print-throwable cause :prefix "Plugin Error")
;; Other ignorable exceptions: ignore silently
(is-ignorable-exception? cause)
nil
;; All other errors: show exception page
:else
(let [data (ex-data cause)
type (get data :type)]
(set! last-exception cause)
(if (= :wasm-error type)
(on-error cause)
(do
(ex/print-throwable cause :prefix "Uncaught Rejection")
(ts/asap #(flash :cause cause :type :unhandled))))))))]
(.addEventListener g/window "error" on-unhandled-error)
(.addEventListener g/window "unhandledrejection" on-unhandled-rejection)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@
(:require
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.controls.select :refer [select*]]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.keyboard :as k]
@@ -47,6 +48,23 @@
[:> input* props]))
(mf/defc form-select*
[{:keys [name] :as props}]
(let [select-name name
form (mf/use-ctx context)
value (get-in @form [:data select-name] "")
handle-change
(fn [event]
(let [value (if (string? event) event (dom/get-target-val event))]
(fm/on-input-change form select-name value)))
props
(mf/spread-props props {:on-change handle-change
:value value})]
[:> select* props]))
(mf/defc form-submit*
[{:keys [disabled on-submit] :rest props}]
(let [form (mf/use-ctx context)
@@ -79,4 +97,4 @@
(when (fn? on-submit)
(on-submit form event))))]
[:> (mf/provider context) {:value form}
[:form {:class class :on-submit on-submit'} children]]))
[:form {:class class :on-submit on-submit'} children]]))

View File

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

View File

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

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

View File

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

View File

File diff suppressed because it is too large Load Diff

View File

@@ -4,125 +4,178 @@
//
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/typography.scss" as t;
@use "ds/z-index.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
.base-menu {
position: absolute;
display: flex;
flex-direction: column;
gap: var(--sp-xs);
padding: var(--sp-xs);
border-radius: $br-8;
z-index: var(--z-index-dropdown);
background-color: var(--menu-background-color);
border: $b-2 solid var(--panel-border-color);
box-shadow: 0 0 $sz-12 0 var(--menu-shadow-color);
}
.menu {
@extend .menu-dropdown;
top: deprecated.$s-48;
left: calc(var(--right-sidebar-width, deprecated.$s-256) - deprecated.$s-16);
width: deprecated.$s-192;
margin: 0;
}
.menu-item {
@extend .menu-item-base;
cursor: pointer;
.open-arrow {
@include deprecated.flexCenter;
svg {
@extend .button-icon;
stroke: var(--icon-foreground);
}
}
&:hover {
color: var(--menu-foreground-color-hover);
.open-arrow {
svg {
stroke: var(--menu-foreground-color-hover);
}
}
.shortcut-key {
color: var(--menu-shortcut-foreground-color-hover);
}
}
}
.separator {
border-top: deprecated.$s-1 solid var(--color-background-quaternary);
height: deprecated.$s-4;
left: calc(-1 * deprecated.$s-4);
margin-top: deprecated.$s-8;
position: relative;
width: calc(100% + deprecated.$s-8);
}
.shortcut {
@extend .shortcut-base;
}
.shortcut-key {
@extend .shortcut-key-base;
top: $sz-48;
left: calc(var(--right-sidebar-width) - $sz-40);
inline-size: $sz-192;
}
.sub-menu {
@extend .menu-dropdown;
left: calc(var(--right-sidebar-width, deprecated.$s-256) + deprecated.$s-180);
width: deprecated.$s-192;
min-width: calc(deprecated.$s-272 - deprecated.$s-2);
width: 110%;
left: calc(var(--right-sidebar-width) + $sz-154);
min-width: $sz-284;
width: 115%;
.submenu-item {
@extend .menu-item-base;
&:hover {
color: var(--menu-foreground-color-hover);
.shortcut-key {
color: var(--menu-shortcut-foreground-color-hover);
}
}
&.pos-1 {
top: calc($sz-16 + $sz-32);
}
.menu-disabled {
color: var(--color-foreground-secondary);
&:hover {
cursor: default;
color: var(--color-foreground-secondary);
background-color: var(--menu-background-color);
}
&.pos-2 {
top: calc($sz-16 + (2 * $sz-32));
}
&.file {
top: deprecated.$s-48;
&.pos-3 {
top: calc($sz-16 + (3 * $sz-32));
}
&.edit {
top: deprecated.$s-76;
&.pos-4 {
top: calc($sz-16 + (4 * $sz-32));
}
&.view {
top: deprecated.$s-116;
&.pos-5 {
top: calc($sz-16 + (5 * $sz-32));
}
&.preferences {
top: deprecated.$s-148;
&.pos-6 {
top: calc($sz-16 + (6 * $sz-32));
}
&.pos-final-5 {
top: calc($sz-32 + (5 * $sz-32));
}
&.pos-final-6 {
top: calc($sz-32 + (6 * $sz-32));
}
&.pos-final-7 {
top: calc($sz-32 + (7 * $sz-32));
}
&.plugins {
top: deprecated.$s-180;
max-height: calc(100vh - deprecated.$s-180);
max-height: calc(100vh - $sz-200);
overflow-x: hidden;
overflow-y: auto;
}
}
&.help-info {
top: deprecated.$s-232;
.base-menu-item {
@include t.use-typography("body-small");
display: grid;
align-items: center;
grid-template-columns: auto $sz-16 $sz-16;
grid-template-areas: "name indicator arrow";
block-size: $sz-28;
inline-size: 100%;
padding: $sz-6;
border-radius: $br-8;
color: var(--menu-foreground-color);
background-color: var(--menu-background-color);
&:hover {
--menu-foreground-color: var(--menu-foreground-color-hover);
--menu-background-color: var(--menu-background-color-hover);
--menu-shortcut-foreground-color: var(--menu-shortcut-foreground-color-hover);
--menu-icon-foreground-color: var(--menu-foreground-color-hover);
}
&.help-info-old {
top: deprecated.$s-192;
&.disabled {
--menu-foreground-color: var(--color-foreground-secondary);
pointer-events: none;
}
}
.menu-item {
display: grid;
align-items: center;
grid-template-columns: auto $sz-16 $sz-16;
grid-template-areas: "name indicator arrow";
}
.submenu-item {
display: flex;
align-items: center;
justify-content: space-between;
}
.item-name {
grid-area: name;
}
.item-indicator {
--menu-indicator-color: var(--color-foreground-secondary);
grid-area: indicator;
display: flex;
align-items: center;
justify-content: center;
inline-size: px2rem(8);
block-size: px2rem(8);
border-radius: $br-circle;
background-color: var(--menu-indicator-color);
&.active {
--menu-indicator-color: var(--color-accent-primary);
}
&.failed {
--menu-indicator-color: var(--color-foreground-error);
}
}
.item-arrow {
grid-area: arrow;
color: var(--menu-icon-foreground-color);
}
.item-icon {
svg {
@extend .button-icon;
stroke: var(--icon-foreground);
}
color: var(--menu-icon-foreground-color);
display: flex;
align-items: center;
justify-content: center;
}
.separator {
position: relative;
block-size: var(--sp-xs);
inline-size: calc(100% + var(--sp-s));
border-top: $b-1 solid var(--color-background-quaternary);
left: calc(-1 * var(--sp-xs));
margin-top: var(--sp-s);
}
.shortcut {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-xxs);
color: var(--menu-shortcut-foreground-color);
}
.shortcut-key {
@include t.use-typography("body-small");
display: flex;
align-items: center;
justify-content: center;
height: px2rem(20);
padding: var(--sp-xxs) px2rem(6);
border-radius: $br-6;
background-color: var(--menu-shortcut-background-color);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,10 +40,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/grid-direction-types value))
(u/display-not-valid :dir value)
(u/not-valid plugin-id :dir value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :dir "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :dir "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-grid-dir value})))))}
@@ -64,10 +64,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/align-items-types value))
(u/display-not-valid :alignItems value)
(u/not-valid plugin-id :alignItems value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :alignItems "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :alignItems "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-align-items value})))))}
@@ -80,10 +80,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/align-content-types value))
(u/display-not-valid :alignContent value)
(u/not-valid plugin-id :alignContent value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :alignContent "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :alignContent "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-align-content value})))))}
@@ -96,10 +96,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/justify-items-types value))
(u/display-not-valid :justifyItems value)
(u/not-valid plugin-id :justifyItems value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :justifyItems "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :justifyItems "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-justify-items value})))))}
@@ -112,10 +112,10 @@
(let [value (keyword value)]
(cond
(not (contains? ctl/justify-content-types value))
(u/display-not-valid :justifyContent value)
(u/not-valid plugin-id :justifyContent value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :justifyContent "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :justifyContent "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-justify-content value})))))}
@@ -127,10 +127,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :rowGap value)
(u/not-valid plugin-id :rowGap value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :rowGap "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :rowGap "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-gap {:row-gap value}}))))}
@@ -142,10 +142,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :columnGap value)
(u/not-valid plugin-id :columnGap value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :columnGap "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :columnGap "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-gap {:column-gap value}}))))}
@@ -157,10 +157,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :verticalPadding value)
(u/not-valid plugin-id :verticalPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :verticalPadding "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :verticalPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value :p3 value}}))))}
@@ -172,10 +172,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :horizontalPadding value)
(u/not-valid plugin-id :horizontalPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :horizontalPadding "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :horizontalPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value :p4 value}}))))}
@@ -187,10 +187,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :topPadding value)
(u/not-valid plugin-id :topPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :topPadding "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :topPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p1 value}}))))}
@@ -202,10 +202,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :rightPadding value)
(u/not-valid plugin-id :rightPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :righPadding "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :righPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p2 value}}))))}
@@ -217,10 +217,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :bottomPadding value)
(u/not-valid plugin-id :bottomPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :bottomPadding "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :bottomPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p3 value}}))))}
@@ -232,10 +232,10 @@
(fn [_ value]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :leftPadding value)
(u/not-valid plugin-id :leftPadding value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :leftPadding "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :leftPadding "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-layout #{id} {:layout-padding {:p4 value}}))))}
@@ -245,14 +245,14 @@
(let [type (keyword type)]
(cond
(not (contains? ctl/grid-track-types type))
(u/display-not-valid :addRow-type type)
(u/not-valid plugin-id :addRow-type type)
(and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value)))
(u/display-not-valid :addRow-value value)
(u/not-valid plugin-id :addRow-value value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :addRow "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :addRow "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/add-layout-track #{id} :row {:type type :value value})))))
@@ -262,17 +262,17 @@
(let [type (keyword type)]
(cond
(not (sm/valid-safe-int? index))
(u/display-not-valid :addRowAtIndex-index index)
(u/not-valid plugin-id :addRowAtIndex-index index)
(not (contains? ctl/grid-track-types type))
(u/display-not-valid :addRowAtIndex-type type)
(u/not-valid plugin-id :addRowAtIndex-type type)
(and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value)))
(u/display-not-valid :addRowAtIndex-value value)
(u/not-valid plugin-id :addRowAtIndex-value value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :addRowAtIndex "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :addRowAtIndex "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/add-layout-track #{id} :row {:type type :value value} index)))))
@@ -282,14 +282,14 @@
(let [type (keyword type)]
(cond
(not (contains? ctl/grid-track-types type))
(u/display-not-valid :addColumn-type type)
(u/not-valid plugin-id :addColumn-type type)
(and (or (= :percent type) (= :flex type) (= :lex type))
(not (sm/valid-safe-number? value)))
(u/display-not-valid :addColumn-value value)
(u/not-valid plugin-id :addColumn-value value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :addColumn "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :addColumn "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/add-layout-track #{id} :column {:type type :value value})))))
@@ -298,17 +298,17 @@
(fn [index type value]
(cond
(not (sm/valid-safe-int? index))
(u/display-not-valid :addColumnAtIndex-index index)
(u/not-valid plugin-id :addColumnAtIndex-index index)
(not (contains? ctl/grid-track-types type))
(u/display-not-valid :addColumnAtIndex-type type)
(u/not-valid plugin-id :addColumnAtIndex-type type)
(and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value)))
(u/display-not-valid :addColumnAtIndex-value value)
(u/not-valid plugin-id :addColumnAtIndex-value value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :addColumnAtIndex "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :addColumnAtIndex "Plugin doesn't have 'content:write' permission")
:else
(let [type (keyword type)]
@@ -318,10 +318,10 @@
(fn [index]
(cond
(not (sm/valid-safe-int? index))
(u/display-not-valid :removeRow index)
(u/not-valid plugin-id :removeRow index)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :removeRow "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :removeRow "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/remove-layout-track #{id} :row index))))
@@ -330,10 +330,10 @@
(fn [index]
(cond
(not (sm/valid-safe-int? index))
(u/display-not-valid :removeColumn index)
(u/not-valid plugin-id :removeColumn index)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :removeColumn "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :removeColumn "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/remove-layout-track #{id} :column index))))
@@ -343,17 +343,17 @@
(let [type (keyword type)]
(cond
(not (sm/valid-safe-int? index))
(u/display-not-valid :setColumn-index index)
(u/not-valid plugin-id :setColumn-index index)
(not (contains? ctl/grid-track-types type))
(u/display-not-valid :setColumn-type type)
(u/not-valid plugin-id :setColumn-type type)
(and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value)))
(u/display-not-valid :setColumn-value value)
(u/not-valid plugin-id :setColumn-value value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :setColumn "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :setColumn "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/change-layout-track #{id} :column index (d/without-nils {:type type :value value}))))))
@@ -363,17 +363,17 @@
(let [type (keyword type)]
(cond
(not (sm/valid-safe-int? index))
(u/display-not-valid :setRow-index index)
(u/not-valid plugin-id :setRow-index index)
(not (contains? ctl/grid-track-types type))
(u/display-not-valid :setRow-type type)
(u/not-valid plugin-id :setRow-type type)
(and (or (= :percent type) (= :flex type) (= :fixed type))
(not (sm/valid-safe-number? value)))
(u/display-not-valid :setRow-value value)
(u/not-valid plugin-id :setRow-value value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :setRow "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :setRow "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/change-layout-track #{id} :row index (d/without-nils {:type type :value value}))))))
@@ -382,7 +382,7 @@
(fn []
(cond
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :remove "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/remove-layout #{id}))))
@@ -391,16 +391,16 @@
(fn [child row column]
(cond
(not (shape-proxy? child))
(u/display-not-valid :appendChild-child child)
(u/not-valid plugin-id :appendChild-child child)
(or (< row 0) (not (sm/valid-safe-int? row)))
(u/display-not-valid :appendChild-row row)
(u/not-valid plugin-id :appendChild-row row)
(or (< column 0) (not (sm/valid-safe-int? column)))
(u/display-not-valid :appendChild-column column)
(u/not-valid plugin-id :appendChild-column column)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :appendChild "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :appendChild "Plugin doesn't have 'content:write' permission")
:else
(let [child-id (obj/get child "$id")]
@@ -432,13 +432,13 @@
shape (u/proxy->shape self)]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :row-value value)
(u/not-valid plugin-id :row-value value)
(nil? cell)
(u/display-not-valid :row-cell "cell not found")
(u/not-valid plugin-id :row-cell "cell not found")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :row "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :row "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:row value})))))}
@@ -452,13 +452,13 @@
cell (locate-cell self)]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :rowSpan-value value)
(u/not-valid plugin-id :rowSpan-value value)
(nil? cell)
(u/display-not-valid :rowSpan-cell "cell not found")
(u/not-valid plugin-id :rowSpan-cell "cell not found")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :rowSpan "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :rowSpan "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:row-span value})))))}
@@ -472,13 +472,13 @@
cell (locate-cell self)]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :column-value value)
(u/not-valid plugin-id :column-value value)
(nil? cell)
(u/display-not-valid :column-cell "cell not found")
(u/not-valid plugin-id :column-cell "cell not found")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :column "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :column "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:column value})))))}
@@ -492,13 +492,13 @@
cell (locate-cell self)]
(cond
(not (sm/valid-safe-int? value))
(u/display-not-valid :columnSpan-value value)
(u/not-valid plugin-id :columnSpan-value value)
(nil? cell)
(u/display-not-valid :columnSpan-cell "cell not found")
(u/not-valid plugin-id :columnSpan-cell "cell not found")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :columnSpan "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :columnSpan "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-grid-cell-position (:parent-id shape) (:id cell) {:column-span value})))))}
@@ -512,13 +512,13 @@
cell (locate-cell self)]
(cond
(not (string? value))
(u/display-not-valid :areaName-value value)
(u/not-valid plugin-id :areaName-value value)
(nil? cell)
(u/display-not-valid :areaName-cell "cell not found")
(u/not-valid plugin-id :areaName-cell "cell not found")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :areaName "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :areaName "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:area-name value})))))}
@@ -533,13 +533,13 @@
value (keyword value)]
(cond
(not (contains? ctl/grid-position-types value))
(u/display-not-valid :position-value value)
(u/not-valid plugin-id :position-value value)
(nil? cell)
(u/display-not-valid :position-cell "cell not found")
(u/not-valid plugin-id :position-cell "cell not found")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :position "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :position "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/change-cells-mode (:parent-id shape) #{(:id cell)} value)))))}
@@ -554,13 +554,13 @@
cell (locate-cell self)]
(cond
(not (contains? ctl/grid-cell-align-self-types value))
(u/display-not-valid :alignSelf-value value)
(u/not-valid plugin-id :alignSelf-value value)
(nil? cell)
(u/display-not-valid :alignSelf-cell "cell not found")
(u/not-valid plugin-id :alignSelf-cell "cell not found")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :alignSelf "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :alignSelf "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:align-self value})))))}
@@ -575,13 +575,13 @@
cell (locate-cell self)]
(cond
(not (contains? ctl/grid-cell-justify-self-types value))
(u/display-not-valid :justifySelf-value value)
(u/not-valid plugin-id :justifySelf-value value)
(nil? cell)
(u/display-not-valid :justifySelf-cell "cell not found")
(u/not-valid plugin-id :justifySelf-cell "cell not found")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :justifySelf "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :justifySelf "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dwsl/update-grid-cells (:parent-id shape) #{(:id cell)} {:justify-self value})))))})))

View File

@@ -24,7 +24,7 @@
(fn []
(cond
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :resize "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :resize "Plugin doesn't have 'content:write' permission")
:else
(let [id (js/Symbol)]
@@ -35,10 +35,10 @@
(fn [block-id]
(cond
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :resize "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :resize "Plugin doesn't have 'content:write' permission")
(not block-id)
(u/display-not-valid :undoBlockFinish block-id)
(u/not-valid plugin-id :undoBlockFinish block-id)
:else
(st/emit! (dwu/commit-undo-transaction block-id))))))

View File

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

View File

@@ -30,10 +30,10 @@
(fn [key]
(cond
(not (r/check-permission plugin-id "allow:localstorage"))
(u/display-not-valid :getItem "Plugin doesn't have 'allow:localstorage' permission")
(u/not-valid plugin-id :getItem "Plugin doesn't have 'allow:localstorage' permission")
(not (string? key))
(u/display-not-valid :getItem "The key must be a string")
(u/not-valid plugin-id :getItem "The key must be a string")
:else
(.getItem ^js local-storage (prefix-key plugin-id key))))
@@ -42,10 +42,10 @@
(fn [key value]
(cond
(not (r/check-permission plugin-id "allow:localstorage"))
(u/display-not-valid :setItem "Plugin doesn't have 'allow:localstorage' permission")
(u/not-valid plugin-id :setItem "Plugin doesn't have 'allow:localstorage' permission")
(not (string? key))
(u/display-not-valid :setItem "The key must be a string")
(u/not-valid plugin-id :setItem "The key must be a string")
:else
(.setItem ^js local-storage (prefix-key plugin-id key) value)))
@@ -54,10 +54,10 @@
(fn [key]
(cond
(not (r/check-permission plugin-id "allow:localstorage"))
(u/display-not-valid :removeItem "Plugin doesn't have 'allow:localstorage' permission")
(u/not-valid plugin-id :removeItem "Plugin doesn't have 'allow:localstorage' permission")
(not (string? key))
(u/display-not-valid :removeItem "The key must be a string")
(u/not-valid plugin-id :removeItem "The key must be a string")
:else
(.getItem ^js local-storage (prefix-key plugin-id key))))

View File

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

View File

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

View File

@@ -17,6 +17,10 @@
[app.util.object :as obj]
[beicon.v2.core :as rx]))
;; Needs to be here because moving it to `app.main.data.workspace.mcp` will
;; cause a circular dependency
(def mcp-plugin-id "96dfa740-005d-8020-8007-55ede24a2bae")
;; Stores the installed plugins information
(defonce ^:private registry (atom {}))
@@ -78,6 +82,7 @@
(d/without-nils
{:plugin-id plugin-id
:url (str plugin-url)
:version vers
:name name
:description desc
:host origin
@@ -127,5 +132,6 @@
(defn check-permission
[plugin-id permission]
(or (= plugin-id "00000000-0000-0000-0000-000000000000")
(= plugin-id mcp-plugin-id)
(let [{:keys [permissions]} (dm/get-in @registry [:data plugin-id])]
(contains? permissions permission))))

View File

@@ -44,13 +44,13 @@
(let [shape (u/locate-shape file-id page-id (obj/get value "$id"))]
(cond
(not (shape-proxy? value))
(u/display-not-valid :board "The board is not a shape proxy")
(u/not-valid plugin-id :board "The board is not a shape proxy")
(not (cfh/frame-shape? shape))
(u/display-not-valid :board "The shape is not a board")
(u/not-valid plugin-id :board "The shape is not a board")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :board "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :board "Plugin doesn't have 'content:write' permission")
:else
(let [board-id (when value (obj/get value "$id"))
@@ -78,10 +78,10 @@
(fn [self value]
(cond
(not (sm/valid-safe-number? value))
(u/display-not-valid :position "Not valid position")
(u/not-valid plugin-id :position "Not valid position")
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :position "Plugin doesn't have 'content:write' permission")
(u/not-valid plugin-id :position "Plugin doesn't have 'content:write' permission")
:else
(let [guide (u/proxy->ruler-guide self)

View File

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

View File

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

View File

@@ -19,18 +19,58 @@
[app.main.store :as st]
[app.plugins.utils :as u]
[app.util.object :as obj]
[clojure.datafy :refer [datafy]]))
[clojure.datafy :refer [datafy]]
[clojure.set :refer [map-invert]]))
;; === Token
;; Give more semantic names to the shape attributes that tokens can be applied to
(def ^:private map:token-attr->token-attr-plugin
{:r1 :border-radius-top-left
:r2 :border-radius-top-right
:r3 :border-radius-bottom-right
:r4 :border-radius-bottom-left
:p1 :padding-top-left
:p2 :padding-top-right
:p3 :padding-bottom-right
:p4 :padding-bottom-left
:m1 :margin-top-left
:m2 :margin-top-right
:m3 :margin-bottom-right
:m4 :margin-bottom-left})
(def ^:private map:token-attr-plugin->token-attr
(map-invert map:token-attr->token-attr-plugin))
(defn token-attr->token-attr-plugin
[k]
(get map:token-attr->token-attr-plugin k k))
(defn token-attr-plugin->token-attr
[k]
(get map:token-attr-plugin->token-attr k k))
(defn applied-tokens-plugin->applied-tokens
[value]
(into {}
(map (fn [[k v]] [(token-attr->token-attr-plugin k) v]))
value))
(defn token-attr?
[attr]
(cto/token-attr? (token-attr-plugin->token-attr attr)))
(defn- apply-token-to-shapes
[file-id set-id id shape-ids attrs]
[plugin-id file-id set-id id shape-ids attrs]
(let [token (u/locate-token file-id set-id id)]
(if (some #(not (cto/token-attr? %)) attrs)
(u/display-not-valid :applyToSelected attrs)
(if (some #(not (token-attr? %)) attrs)
(u/not-valid plugin-id :applyToSelected attrs)
(st/emit!
(dwta/toggle-token {:token token
:attrs attrs
:attrs (into #{} (map token-attr-plugin->token-attr) attrs)
:shape-ids shape-ids
:expand-with-children false})))))
@@ -52,7 +92,7 @@
(defn token-proxy
[plugin-id file-id set-id id]
(obj/reify {:name "TokenProxy"
:on-error u/handle-error}
:on-error (u/handle-error plugin-id)}
:$plugin {:enumerable false :get (constantly plugin-id)}
:$file-id {:enumerable false :get (constantly file-id)}
:$set-id {:enumerable false :get (constantly set-id)}
@@ -146,16 +186,16 @@
{:enumerable false
:schema [:tuple
[:vector [:fn shape-proxy?]]
[:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]]
[:maybe [:set [:and ::sm/keyword [:fn token-attr?]]]]]
:fn (fn [shapes attrs]
(apply-token-to-shapes file-id set-id id (map #(obj/get % "$id") shapes) attrs))}
(apply-token-to-shapes plugin-id file-id set-id id (map #(obj/get % "$id") shapes) attrs))}
:applyToSelected
{:enumerable false
:schema [:tuple [:maybe [:set [:and ::sm/keyword [:fn cto/token-attr?]]]]]
:schema [:tuple [:maybe [:set [:and ::sm/keyword [:fn token-attr?]]]]]
:fn (fn [attrs]
(let [selected (get-in @st/state [:workspace-local :selected])]
(apply-token-to-shapes file-id set-id id selected attrs)))}))
(apply-token-to-shapes plugin-id file-id set-id id selected attrs)))}))
;; === Token Set
@@ -165,7 +205,7 @@
(defn token-set-proxy
[plugin-id file-id id]
(obj/reify {:name "TokenSetProxy"
:on-error u/handle-error}
:on-error (u/handle-error plugin-id)}
:$plugin {:enumerable false :get (constantly plugin-id)}
:$file-id {:enumerable false :get (constantly file-id)}
:$id {:enumerable false :get (constantly id)}
@@ -247,15 +287,19 @@
:addToken
{:enumerable false
:schema (fn [args]
[:tuple (-> (cfo/make-token-schema
(-> (u/locate-tokens-lib file-id) (ctob/get-tokens id))
(cto/dtcg-token-type->token-type (-> args (first) (get "type"))))
;; Don't allow plugins to set the id
(sm/dissoc-key :id)
;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below)
;; and set a converter that changes DTCG types to internal types (:decode/json).
;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width
(sm/update-properties assoc :decode/json cfo/convert-dtcg-token))])
(let [tokens-tree (-> (u/locate-tokens-lib file-id)
(ctob/get-tokens id)
;; Convert to the adecuate format for schema
(ctob/tokens-tree))]
[:tuple (-> (cfo/make-token-schema
tokens-tree
(cto/dtcg-token-type->token-type (-> args (first) (get "type"))))
;; Don't allow plugins to set the id
(sm/dissoc-key :id)
;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below)
;; and set a converter that changes DTCG types to internal types (:decode/json).
;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width
(sm/update-properties assoc :decode/json cfo/convert-dtcg-token))]))
:decode/options {:key-fn identity}
:fn (fn [attrs]
(let [tokens-lib (u/locate-tokens-lib file-id)
@@ -270,7 +314,7 @@
(if resolved-value
(do (st/emit! (dwtl/create-token id token))
(token-proxy plugin-id file-id id (:id token)))
(do (u/display-not-valid :addToken (str errors))
(do (u/not-valid plugin-id :addToken (str errors))
nil))))}
:duplicate
@@ -287,7 +331,7 @@
(defn token-theme-proxy
[plugin-id file-id id]
(obj/reify {:name "TokenThemeProxy"
:on-error u/handle-error}
:on-error (u/handle-error plugin-id)}
:$plugin {:enumerable false :get (constantly plugin-id)}
:$file-id {:enumerable false :get (constantly file-id)}
:$id {:enumerable false :get (constantly id)}
@@ -394,7 +438,7 @@
(defn tokens-catalog
[plugin-id file-id]
(obj/reify {:name "TokensCatalog"
:on-error u/handle-error}
:on-error (u/handle-error plugin-id)}
:$plugin {:enumerable false :get (constantly plugin-id)}
:$id {:enumerable false :get (constantly file-id)}

View File

@@ -9,14 +9,17 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.json :as json]
[app.common.i18n :as i18n :refer [tr]]
[app.common.schema :as sm]
[app.common.schema.messages :as csm]
[app.common.types.component :as ctk]
[app.common.types.container :as ctn]
[app.common.types.file :as ctf]
[app.common.types.tokens-lib :as ctob]
[app.main.data.helpers :as dsh]
[app.main.store :as st]
[app.util.object :as obj]))
[app.util.object :as obj]
[cuerdas.core :as str]))
(defn locate-file
[id]
@@ -221,6 +224,16 @@
(resolve value)))))]
[ret-v ret-p]))
(defn natural-child-ordering?
[plugin-id]
(boolean
(dm/get-in @st/state [:plugins :flags plugin-id :natural-child-ordering])))
(defn throw-validation-errors?
[plugin-id]
(boolean
(dm/get-in @st/state [:plugins :flags plugin-id :throw-validation-errors])))
(defn display-not-valid
[code value]
(if (some? value)
@@ -228,34 +241,54 @@
(.error js/console (dm/str "[PENPOT PLUGIN] Value not valid. Code: " code)))
nil)
(defn throw-not-valid
[code value]
(if (some? value)
(throw (js/Error. (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)))
(throw (js/Error. (dm/str "[PENPOT PLUGIN] Value not valid. Code: " code))))
nil)
(defn not-valid
[plugin-id code value]
(if (throw-validation-errors? plugin-id)
(throw-not-valid code value)
(display-not-valid code value)))
(defn reject-not-valid
[reject code value]
(let [msg (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)]
(.error js/console msg)
(reject msg)))
(defn coerce
"Decodes a javascript object into clj and check against schema. If schema validation fails,
displays a not-valid message with the code and hint provided and returns nil."
[attrs schema code hint]
(let [decoder (sm/decoder schema sm/json-transformer)
explainer (sm/explainer schema)
attrs (-> attrs json/->clj decoder)]
(if-let [explain (explainer attrs)]
(display-not-valid code (str hint " " (sm/humanize-explain explain)))
attrs)))
(defn mixed-value
[values]
(let [s (set values)]
(if (= (count s) 1) (first s) "mixed")))
(defn error-messages
[explain]
(->> (:errors explain)
(reduce csm/interpret-schema-problem {})
(mapcat (comp seq val))
(map (fn [[field {:keys [message]}]]
(tr "plugins.validation.message" (name field) message)))
(str/join ". ")))
(defn handle-error
"Function to be used in plugin proxies methods to handle errors and print a readable
message to the console."
[cause]
(display-not-valid (ex-message cause) nil)
(if-let [explain (-> cause ex-data ::sm/explain)]
(println (sm/humanize-explain explain))
(js/console.log (ex-data cause)))
(js/console.log (.-stack cause)))
[plugin-id]
(fn [cause]
(let [message
(if-let [explain (-> cause ex-data ::sm/explain)]
(do
(js/console.error (sm/humanize-explain explain))
(error-messages explain))
(ex-data cause))]
(js/console.log (.-stack cause))
(not-valid plugin-id :error message))))
(defn is-main-component-proxy?
[p]
(when-let [shape (proxy->shape p)]
(ctk/main-instance? shape)))

View File

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

View File

@@ -10,84 +10,10 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.schema :as sm]
[app.util.i18n :as i18n :refer [tr]]
[app.common.schema.messages :as csm]
[cuerdas.core :as str]
[malli.core :as m]
[rumext.v2 :as mf]))
;; --- Handlers Helpers
(defn- translate-code
[code]
(if (vector? code)
(tr (nth code 0) (i18n/c (nth code 1)))
(tr code)))
(defn- handle-error-fn
[props problem]
(let [v-fn (:error/fn props)
result (v-fn problem)]
(if (string? result)
{:message result}
{:message (or (some-> (get result :code)
(translate-code))
(get result :message)
(tr "errors.invalid-data"))})))
(defn- handle-error-message
[props]
{:message (get props :error/message)})
(defn- handle-error-code
[props]
(let [code (get props :error/code)]
{:message (translate-code code)}))
(defn- interpret-schema-problem
[acc {:keys [schema in value type] :as problem}]
(let [props (m/properties schema)
tprops (m/type-properties schema)
field (or (:error/field props)
in)
field (if (vector? field)
field
[field])]
(if (and (= 1 (count field))
(contains? acc (first field)))
acc
(cond
(or (nil? field)
(empty? field))
acc
(or (= type :malli.core/missing-key)
(nil? value))
(assoc-in acc field {:message (tr "errors.field-missing")})
;; --- CHECK on schema props
(contains? props :error/fn)
(assoc-in acc field (handle-error-fn props problem))
(contains? props :error/message)
(assoc-in acc field (handle-error-message props))
(contains? props :error/code)
(assoc-in acc field (handle-error-code props))
;; --- CHECK on type props
(contains? tprops :error/fn)
(assoc-in acc field (handle-error-fn tprops problem))
(contains? tprops :error/message)
(assoc-in acc field (handle-error-message tprops))
(contains? tprops :error/code)
(assoc-in acc field (handle-error-code tprops))
:else
(assoc-in acc field {:message (tr "errors.invalid-data")})))))
(defn- use-rerender-fn
[]
(let [state (mf/useState 0)
@@ -97,24 +23,6 @@
(fn []
(render-fn inc)))))
(defn- apply-validators
[validators state errors]
(reduce (fn [errors validator-fn]
(merge errors (validator-fn errors (:data state))))
errors
validators))
(defn- collect-schema-errors
[schema validators state]
(let [explain (sm/explain schema (:data state))
errors (->> (reduce interpret-schema-problem {} (:errors explain))
(apply-validators validators state))]
(-> (:errors state)
(merge errors)
(d/without-nils)
(not-empty))))
(defn- wrap-update-schema-fn
[f {:keys [schema validators]}]
(fn [& args]
@@ -124,7 +32,7 @@
errors
(when-not valid?
(collect-schema-errors schema validators state))
(csm/collect-schema-errors schema validators state))
extra-errors
(not-empty (:extra-errors state))]

View File

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

View File

@@ -338,77 +338,6 @@ msgstr "You're going to restore %s."
msgid "dashboard-restore-file-confirmation.title"
msgstr "Restore file"
#: src/app/main/ui/settings/access_tokens.cljs:103
msgid "dashboard.access-tokens.copied-success"
msgstr "Copied token"
#: src/app/main/ui/settings/access_tokens.cljs:189
msgid "dashboard.access-tokens.create"
msgstr "Generate new token"
#: src/app/main/ui/settings/access_tokens.cljs:64
msgid "dashboard.access-tokens.create.success"
msgstr "Access token created successfully."
#: src/app/main/ui/settings/access_tokens.cljs:286
msgid "dashboard.access-tokens.empty.add-one"
msgstr "Press the button \"Generate new token\" to generate one."
#: src/app/main/ui/settings/access_tokens.cljs:285
msgid "dashboard.access-tokens.empty.no-access-tokens"
msgstr "You have no tokens so far."
#: src/app/main/ui/settings/access_tokens.cljs:135
msgid "dashboard.access-tokens.expiration-180-days"
msgstr "180 days"
#: src/app/main/ui/settings/access_tokens.cljs:132
msgid "dashboard.access-tokens.expiration-30-days"
msgstr "30 days"
#: src/app/main/ui/settings/access_tokens.cljs:133
msgid "dashboard.access-tokens.expiration-60-days"
msgstr "60 days"
#: src/app/main/ui/settings/access_tokens.cljs:134
msgid "dashboard.access-tokens.expiration-90-days"
msgstr "90 days"
#: src/app/main/ui/settings/access_tokens.cljs:131
msgid "dashboard.access-tokens.expiration-never"
msgstr "Never"
#: src/app/main/ui/settings/access_tokens.cljs:268
msgid "dashboard.access-tokens.expired-on"
msgstr "Expired on %s"
#: src/app/main/ui/settings/access_tokens.cljs:269
msgid "dashboard.access-tokens.expires-on"
msgstr "Expires on %s"
#: src/app/main/ui/settings/access_tokens.cljs:267
msgid "dashboard.access-tokens.no-expiration"
msgstr "No expiration date"
#: src/app/main/ui/settings/access_tokens.cljs:184
msgid "dashboard.access-tokens.personal"
msgstr "Personal access tokens"
#: src/app/main/ui/settings/access_tokens.cljs:185
msgid "dashboard.access-tokens.personal.description"
msgstr ""
"Personal access tokens function like an alternative to our login/password "
"authentication system and can be used to allow an application to access the "
"internal Penpot API"
#: src/app/main/ui/settings/access_tokens.cljs:142
msgid "dashboard.access-tokens.token-will-expire"
msgstr "The token will expire on %s"
#: src/app/main/ui/settings/access_tokens.cljs:143
msgid "dashboard.access-tokens.token-will-not-expire"
msgstr "The token has no expiration date"
#: src/app/main/ui/dashboard/placeholder.cljs:41
msgid "dashboard.add-file"
msgstr "Add file"
@@ -2138,6 +2067,237 @@ msgstr "Resolved value:"
msgid "inspect.tabs.styles.variants-panel"
msgstr "Variant Properties"
#: src/app/main/ui/settings/integrations.cljs:189
msgid "integrations.access-tokens.create"
msgstr "Create new access token"
#: src/app/main/ui/settings/integrations.cljs:286
msgid "integrations.access-tokens.empty.add-one"
msgstr "Press the button \"Create new access token\" to generate one."
#: src/app/main/ui/settings/integrations.cljs:285
msgid "integrations.access-tokens.empty.no-access-tokens"
msgstr "You have no tokens so far."
#: src/app/main/ui/settings/integrations.cljs:184
msgid "integrations.access-tokens.personal"
msgstr "Personal access tokens"
#: src/app/main/ui/settings/integrations.cljs:185
msgid "integrations.access-tokens.personal.description"
msgstr ""
"Personal access tokens function like an alternative to our login/password "
"authentication system and can be used to allow an application to access the "
"internal Penpot API"
#: src/app/main/ui/settings/integrations.cljs:152, src/app/main/ui/settings/integrations.cljs:158
msgid "integrations.copy-to-clipboard"
msgstr "Copy to clipboard"
#: src/app/main/ui/settings/integrations.cljs:432
msgid "integrations.create-access-token.title"
msgstr "Create access token"
#: src/app/main/ui/settings/integrations.cljs:433
msgid "integrations.create-access-token.title.created"
msgstr "Access token created"
#: src/app/main/ui/settings/integrations.cljs:257
msgid "integrations.delete-token.accept"
msgstr "Delete token"
#: src/app/main/ui/settings/integrations.cljs:256
msgid "integrations.delete-token.message"
msgstr "Are you sure you want to delete this token?"
#: src/app/main/ui/settings/integrations.cljs:255
msgid "integrations.delete-token.title"
msgstr "Delete token"
#: src/app/main/ui/settings/integrations.cljs:135
msgid "integrations.expiration-180-days"
msgstr "180 days"
#: src/app/main/ui/settings/integrations.cljs:132
msgid "integrations.expiration-30-days"
msgstr "30 days"
#: src/app/main/ui/settings/integrations.cljs:133
msgid "integrations.expiration-60-days"
msgstr "60 days"
#: src/app/main/ui/settings/integrations.cljs:134
msgid "integrations.expiration-90-days"
msgstr "90 days"
#: src/app/main/ui/settings/integrations.cljs:131
msgid "integrations.expiration-never"
msgstr "Never"
#: src/app/main/ui/settings/integrations.cljs:268
msgid "integrations.expired-on"
msgstr "Expired on %s"
#: src/app/main/ui/settings/integrations.cljs:269
msgid "integrations.expires-on"
msgstr "Expires on %s"
#: src/app/main/ui/settings/integrations.cljs:267
msgid "integrations.no-expiration"
msgstr "No expiration date"
#: src/app/main/ui/settings/integrations.cljs:130
msgid "integrations.expiration-date.label"
msgstr "Expiration date"
#: src/app/main/ui/settings/integrations.cljs:290
msgid "integrations.generate-mcp-key.title"
msgstr "Generate MCP key"
#: src/app/main/ui/settings/integrations.cljs:291
msgid "integrations.generate-mcp-key.title.created"
msgstr "MCP key generated"
#: src/app/main/ui/settings/integrations.cljs:113
msgid "integrations.info.mcp-client-config"
msgstr "Add this configuration to your MCP client (e.g. ~/.mcp.json)."
#: src/app/main/ui/settings/integrations.cljs:183
msgid "integrations.info.mcp-server"
msgstr "The Penpot MCP Server enables MCP clients to interact directly with Penpot design files."
#: src/app/main/ui/settings/integrations.cljs:131
msgid "integrations.token.info.non-recuperable"
msgstr "This unique token is non-recuperable. If you lose it, you will need to create a new one."
#: src/app/main/ui/settings/integrations.cljs:131
#: src/app/main/ui/settings/integrations.cljs:131
msgid "integrations.mcp-key.info.non-recuperable"
msgstr "This unique MCP key is non-recoverable. If you lose it, you will need to create a new one."
#: src/app/main/ui/settings/integrations.cljs:336
msgid "integrations.mcp-server.title"
msgstr "MCP Server"
#: src/app/main/ui/settings/integrations.cljs:336
msgid "integrations.mcp-server.title.beta"
msgstr "Beta"
#: src/app/main/ui/settings/integrations.cljs:347
msgid "integrations.mcp-server.description"
msgstr "The Penpot MCP Server enables MCP clients to interact directly with Penpot design files."
#: src/app/main/ui/settings/integrations.cljs:353
msgid "integrations.mcp-server.status"
msgstr "Status"
#: src/app/main/ui/settings/integrations.cljs:370
msgid "integrations.mcp-server.status.disabled"
msgstr "Disabled"
#: src/app/main/ui/settings/integrations.cljs:370
msgid "integrations.mcp-server.status.enabled"
msgstr "Enabled"
#: src/app/main/ui/settings/integrations.cljs:363
msgid "integrations.mcp-server.status.expired.0"
msgstr "The MCP key used to connect to the MCP server has expired. As a result, the connection cannot be established."
#: src/app/main/ui/settings/integrations.cljs:368
msgid "integrations.mcp-server.status.expired.1"
msgstr "Please regenerate the MCP key and update your client configuration with the new key."
#: src/app/main/ui/settings/integrations.cljs:415
msgid "integrations.mcp-server.mcp-keys.copy"
msgstr "Copy link"
#: src/app/main/ui/settings/integrations.cljs:422
msgid "integrations.mcp-server.mcp-keys.help"
msgstr "How to configure MCP clients"
#: src/app/main/ui/settings/integrations.cljs:405
msgid "integrations.mcp-server.mcp-keys.info"
msgstr "This is the server url you'll need to configure your MCP client in order to connect it to the Penpot MCP server."
#: src/app/main/ui/settings/integrations.cljs:387
msgid "integrations.mcp-server.mcp-keys.regenerate"
msgstr "Regenerate MCP key"
#: src/app/main/ui/settings/integrations.cljs:381
msgid "integrations.mcp-server.mcp-keys.title"
msgstr "MCP key"
#: src/app/main/ui/settings/integrations.cljs:388
msgid "integrations.mcp-server.mcp-keys.tootip"
msgstr "The MCP key is needed for the MCP client set up"
#: src/app/main/ui/settings/integrations.cljs:124
msgid "integrations.name.label"
msgstr "Name"
#: src/app/main/ui/settings/integrations.cljs:126
msgid "integrations.name.placeholder"
msgstr "The name can help to know what's the token for"
#: src/app/main/ui/settings/integrations.cljs:103
msgid "integrations.notification.success.token-copied"
msgstr "Copied token"
#: src/app/main/ui/settings/integrations.cljs:103
#: src/app/main/ui/settings/integrations.cljs:103
msgid "integrations.notification.success.mcp-key-copied"
msgstr "MCP key copied"
#: src/app/main/ui/settings/integrations.cljs:64
msgid "integrations.notification.success.created"
msgstr "Token created successfully"
#: src/app/main/ui/settings/integrations.cljs:327
msgid "integrations.notification.success.copied-link"
msgstr "Link copied to clipboard"
#: src/app/main/ui/settings/integrations.cljs:293
msgid "integrations.notification.success.mcp-server-disabled"
msgstr "MCP server disabled"
#: src/app/main/ui/settings/integrations.cljs:299
msgid "integrations.notification.success.mcp-server-enabled"
msgstr "MCP server enabled"
#: src/app/main/ui/settings/integrations.cljs:317
msgid "integrations.regenerate-mcp-key.info"
msgstr "Regenerating the MCP key will immediately revoke the current one. Any application using it will stop working."
#: src/app/main/ui/settings/integrations.cljs:317
msgid "integrations.regenerate-mcp-key.title"
msgstr "Regenerate MCP key"
#: src/app/main/ui/settings/integrations.cljs:318
msgid "integrations.regenerate-mcp-key.title.created"
msgstr "MCP key regenerated"
#: src/app/main/ui/settings/integrations.cljs:480
msgid "integrations.title"
msgstr "Integrations"
#: src/app/main/ui/settings/integrations.cljs:142
msgid "integrations.token.will-expire"
msgstr "The token will expire on %s"
#: src/app/main/ui/settings/integrations.cljs:142
#: src/app/main/ui/settings/integrations.cljs:142
msgid "integrations.mcp-key.will-expire"
msgstr "The MCP key will expire on %s"
#: src/app/main/ui/settings/integrations.cljs:143
msgid "integrations.token.will-not-expire"
msgstr "The token has no expiration date"
#: src/app/main/ui/settings/integrations.cljs:143
#: src/app/main/ui/settings/integrations.cljs:143
msgid "integrations.mcp-key.will-not-expire"
msgstr "The MCP key has no expiration date"
#: src/app/main/ui/dashboard/comments.cljs:96
msgid "label.mark-all-as-read"
msgstr "Mark all as read"
@@ -2354,6 +2514,9 @@ msgstr "Director"
msgid "labels.discard"
msgstr "Discard"
msgid "labels.dismiss"
msgstr "Dismiss"
#: src/app/main/ui/settings/feedback.cljs:134, src/app/main/ui/static.cljs:409
msgid "labels.download"
msgstr "Download %s"
@@ -2489,6 +2652,10 @@ msgstr "Info"
msgid "labels.installed-fonts"
msgstr "Installed fonts"
#: src/app/main/ui/settings/sidebar.cljs:123
msgid "labels.integrations"
msgstr "Integrations"
#: src/app/main/ui/static.cljs:405
msgid "labels.internal-error.desc-message-first"
msgstr "Something bad happened."
@@ -3148,30 +3315,6 @@ msgstr "Change email"
msgid "modals.change-email.title"
msgstr "Change your email"
#: src/app/main/ui/settings/access_tokens.cljs:152, src/app/main/ui/settings/access_tokens.cljs:158
msgid "modals.create-access-token.copy-token"
msgstr "Copy token"
#: src/app/main/ui/settings/access_tokens.cljs:130
msgid "modals.create-access-token.expiration-date.label"
msgstr "Expiration date"
#: src/app/main/ui/settings/access_tokens.cljs:124
msgid "modals.create-access-token.name.label"
msgstr "Name"
#: src/app/main/ui/settings/access_tokens.cljs:126
msgid "modals.create-access-token.name.placeholder"
msgstr "The name can help to know what's the token for"
#: src/app/main/ui/settings/access_tokens.cljs:178
msgid "modals.create-access-token.submit-label"
msgstr "Create token"
#: src/app/main/ui/settings/access_tokens.cljs:111
msgid "modals.create-access-token.title"
msgstr "Generate access token"
#: src/app/main/ui/dashboard/team.cljs:1127
msgid "modals.create-webhook.submit-label"
msgstr "Create webhook"
@@ -3188,18 +3331,6 @@ msgstr "Payload URL"
msgid "modals.create-webhook.url.placeholder"
msgstr "https://example.com/postreceive"
#: src/app/main/ui/settings/access_tokens.cljs:257
msgid "modals.delete-acces-token.accept"
msgstr "Delete token"
#: src/app/main/ui/settings/access_tokens.cljs:256
msgid "modals.delete-acces-token.message"
msgstr "Are you sure you want to delete this token?"
#: src/app/main/ui/settings/access_tokens.cljs:255
msgid "modals.delete-acces-token.title"
msgstr "Delete token"
#: src/app/main/ui/settings/delete_account.cljs:56
msgid "modals.delete-account.cancel"
msgstr "Cancel and keep my account"
@@ -3699,6 +3830,12 @@ msgstr "Invitation sent successfully"
msgid "notifications.invitation-link-copied"
msgstr "Invitation link copied"
msgid "notifications.mcp.active-in-another-tab"
msgstr "MCP is active in another tab. Switch here?"
msgid "notifications.mcp.active-in-this-tab"
msgstr "MCP is now active in this tab."
#: src/app/main/ui/settings/delete_account.cljs:24
msgid "notifications.profile-deletion-not-allowed"
msgstr "You can't delete your profile. Reassign your teams before proceed."
@@ -5093,14 +5230,14 @@ msgstr "Shared Libraries - %s - Penpot"
msgid "title.default"
msgstr "Penpot - Design Freedom for Teams"
#: src/app/main/ui/settings/access_tokens.cljs:278
msgid "title.settings.access-tokens"
msgstr "Profile - Access tokens"
#: src/app/main/ui/settings/feedback.cljs:161
msgid "title.settings.feedback"
msgstr "Give feedback - Penpot"
#: src/app/main/ui/settings/integrations.cljs:278
msgid "title.settings.integrations"
msgstr "Integrations - Penpot"
#: src/app/main/ui/settings/notifications.cljs:45
msgid "title.settings.notifications"
msgstr "Notifications - Penpot"
@@ -5594,6 +5731,18 @@ msgstr "Hide rulers"
msgid "workspace.header.menu.hide-textpalette"
msgstr "Hide fonts palette"
msgid "workspace.header.menu.mcp.plugin.status.connect"
msgstr "Connect"
msgid "workspace.header.menu.mcp.plugin.status.disconnect"
msgstr "Disconnect"
msgid "workspace.header.menu.mcp.server.status.enabled"
msgstr "Manage (Status: enabled)"
msgid "workspace.header.menu.mcp.server.status.disabled"
msgstr "Manage (Status: disabled)"
#: src/app/main/ui/workspace/main_menu.cljs:884
msgid "workspace.header.menu.option.edit"
msgstr "Edit"
@@ -5606,6 +5755,9 @@ msgstr "File"
msgid "workspace.header.menu.option.help-info"
msgstr "Help & info"
msgid "workspace.header.menu.option.mcp"
msgstr "MCP server"
#: src/app/main/ui/workspace/main_menu.cljs:916
#, unused
msgid "workspace.header.menu.option.power-up"
@@ -7234,11 +7386,14 @@ msgid "workspace.plugins.empty-plugins"
msgstr "No plugins installed yet"
#: src/app/main/ui/workspace/plugins.cljs:193
msgid "workspace.plugins.error.manifest"
msgstr "The plugin manifest is incorrect."
msgid "workspace.plugins.error.manifest"
msgstr "The plugin manifest is incorrect."
msgid "plugins.validation.message"
msgstr "Field %s is invalid: %s"
#: src/app/main/data/plugins.cljs:105, src/app/main/ui/workspace/main_menu.cljs:766, src/app/main/ui/workspace/plugins.cljs:84
msgid "workspace.plugins.error.need-editor"
msgid "workspace.plugins.error.need-editor"
msgstr "You need to be an editor to use this plugin"
#: src/app/main/ui/workspace/plugins.cljs:189

View File

@@ -347,77 +347,6 @@ msgstr "Vas a restaurar %s."
msgid "dashboard-restore-file-confirmation.title"
msgstr "Restaurar archivo"
#: src/app/main/ui/settings/access_tokens.cljs:103
msgid "dashboard.access-tokens.copied-success"
msgstr "Token copiado"
#: src/app/main/ui/settings/access_tokens.cljs:189
msgid "dashboard.access-tokens.create"
msgstr "Generar nuevo token"
#: src/app/main/ui/settings/access_tokens.cljs:64
msgid "dashboard.access-tokens.create.success"
msgstr "Access token creado con éxito."
#: src/app/main/ui/settings/access_tokens.cljs:286
msgid "dashboard.access-tokens.empty.add-one"
msgstr "Pulsa el botón \"Generar nuevo token\" para generar uno."
#: src/app/main/ui/settings/access_tokens.cljs:285
msgid "dashboard.access-tokens.empty.no-access-tokens"
msgstr "Todavía no tienes ningún token."
#: src/app/main/ui/settings/access_tokens.cljs:135
msgid "dashboard.access-tokens.expiration-180-days"
msgstr "180 días"
#: src/app/main/ui/settings/access_tokens.cljs:132
msgid "dashboard.access-tokens.expiration-30-days"
msgstr "30 días"
#: src/app/main/ui/settings/access_tokens.cljs:133
msgid "dashboard.access-tokens.expiration-60-days"
msgstr "60 días"
#: src/app/main/ui/settings/access_tokens.cljs:134
msgid "dashboard.access-tokens.expiration-90-days"
msgstr "90 días"
#: src/app/main/ui/settings/access_tokens.cljs:131
msgid "dashboard.access-tokens.expiration-never"
msgstr "Nunca"
#: src/app/main/ui/settings/access_tokens.cljs:268
msgid "dashboard.access-tokens.expired-on"
msgstr "Expiró el %s"
#: src/app/main/ui/settings/access_tokens.cljs:269
msgid "dashboard.access-tokens.expires-on"
msgstr "Expira el %s"
#: src/app/main/ui/settings/access_tokens.cljs:267
msgid "dashboard.access-tokens.no-expiration"
msgstr "Sin fecha de expiración"
#: src/app/main/ui/settings/access_tokens.cljs:184
msgid "dashboard.access-tokens.personal"
msgstr "Access tokens personales"
#: src/app/main/ui/settings/access_tokens.cljs:185
msgid "dashboard.access-tokens.personal.description"
msgstr ""
"Los access tokens personales funcionan como una alternativa a nuestro "
"sistema de autenticación usuario/password y se pueden usar para permitir a "
"otras aplicaciones acceso a la API interna de Penpot"
#: src/app/main/ui/settings/access_tokens.cljs:142
msgid "dashboard.access-tokens.token-will-expire"
msgstr "El token expirará el %s"
#: src/app/main/ui/settings/access_tokens.cljs:143
msgid "dashboard.access-tokens.token-will-not-expire"
msgstr "El token no tiene fecha de expiración"
#: src/app/main/ui/dashboard/placeholder.cljs:41
msgid "dashboard.add-file"
msgstr "Añadir archivo"
@@ -2105,6 +2034,237 @@ msgstr "Valor resuelto:"
msgid "inspect.tabs.styles.variants-panel"
msgstr "Propiedades de las variantes"
#: src/app/main/ui/settings/integrations.cljs:189
msgid "integrations.access-tokens.create"
msgstr "Crear nuevo token de acceso"
#: src/app/main/ui/settings/integrations.cljs:286
msgid "integrations.access-tokens.empty.add-one"
msgstr "Pulsa el botón \"Crear nuevo token de accesso\" para generar uno."
#: src/app/main/ui/settings/integrations.cljs:285
msgid "integrations.access-tokens.empty.no-access-tokens"
msgstr "Todavía no tienes ningún token."
#: src/app/main/ui/settings/integrations.cljs:184
msgid "integrations.access-tokens.personal"
msgstr "Tokens de acceso personales"
#: src/app/main/ui/settings/integrations.cljs:185
msgid "integrations.access-tokens.personal.description"
msgstr ""
"Los tokens de accesso personales funcionan como una alternativa a nuestro "
"sistema de autenticación usuario/password y se pueden usar para permitir a "
"otras aplicaciones acceso a la API interna de Penpot"
#: src/app/main/ui/settings/integrations.cljs:152, src/app/main/ui/settings/integrations.cljs:158
msgid "integrations.copy-to-clipboard"
msgstr "Copiar al portapapeles"
#: src/app/main/ui/settings/integrations.cljs:432
msgid "integrations.create-access-token.title"
msgstr "Crear token de accesso"
#: src/app/main/ui/settings/integrations.cljs:433
msgid "integrations.create-access-token.title.created"
msgstr "Token de acceso creado"
#: src/app/main/ui/settings/integrations.cljs:257
msgid "integrations.delete-token.accept"
msgstr "Borrar token"
#: src/app/main/ui/settings/integrations.cljs:256
msgid "integrations.delete-token.message"
msgstr "¿Seguro que deseas borrar este token?"
#: src/app/main/ui/settings/integrations.cljs:255
msgid "integrations.delete-token.title"
msgstr "Borrar token"
#: src/app/main/ui/settings/integrations.cljs:135
msgid "integrations.expiration-180-days"
msgstr "180 días"
#: src/app/main/ui/settings/integrations.cljs:132
msgid "integrations.expiration-30-days"
msgstr "30 días"
#: src/app/main/ui/settings/integrations.cljs:133
msgid "integrations.expiration-60-days"
msgstr "60 días"
#: src/app/main/ui/settings/integrations.cljs:134
msgid "integrations.expiration-90-days"
msgstr "90 días"
#: src/app/main/ui/settings/integrations.cljs:131
msgid "integrations.expiration-never"
msgstr "Nunca"
#: src/app/main/ui/settings/integrations.cljs:268
msgid "integrations.expired-on"
msgstr "Expiró el %s"
#: src/app/main/ui/settings/integrations.cljs:269
msgid "integrations.expires-on"
msgstr "Expira el %s"
#: src/app/main/ui/settings/integrations.cljs:267
msgid "integrations.no-expiration"
msgstr "Sin fecha de expiración"
#: src/app/main/ui/settings/integrations.cljs:130
msgid "integrations.expiration-date.label"
msgstr "Fecha de expiración"
#: src/app/main/ui/settings/integrations.cljs:290
msgid "integrations.generate-mcp-key.title"
msgstr "Generar clave MCP"
#: src/app/main/ui/settings/integrations.cljs:291
msgid "integrations.generate-mcp-key.title.created"
msgstr "Clave MCP generada"
#: src/app/main/ui/settings/integrations.cljs:113
msgid "integrations.info.mcp-client-config"
msgstr "Agrega esta configuración a tu cliente MCP (por ejemplo, ~/.mcp.json)."
#: src/app/main/ui/settings/integrations.cljs:183
msgid "integrations.info.mcp-server"
msgstr "El servidor MCP de Penpot permite a los clientes MCP interactuar directamente con los archivos de diseño de Penpot."
#: src/app/main/ui/settings/integrations.cljs:131
msgid "integrations.token.info.non-recuperable"
msgstr "Esta clave única no es recuperable. Si la pierdes, tendrás que crear una nueva."
#: src/app/main/ui/settings/integrations.cljs:131
#: src/app/main/ui/settings/integrations.cljs:131
msgid "integrations.mcp-key.info.non-recuperable"
msgstr "Esta clave MCP única no es recuperable. Si la pierdes, tendrás que crear una nueva."
#: src/app/main/ui/settings/integrations.cljs:336
msgid "integrations.mcp-server.title"
msgstr "Servidor MCP"
#: src/app/main/ui/settings/integrations.cljs:336
msgid "integrations.mcp-server.title.beta"
msgstr "Beta"
#: src/app/main/ui/settings/integrations.cljs:347
msgid "integrations.mcp-server.description"
msgstr "El servidor MCP de Penpot permite que los clientes MCP interactúen directamente con los archivos de diseño de Penpot."
#: src/app/main/ui/settings/integrations.cljs:353
msgid "integrations.mcp-server.status"
msgstr "Estado"
#: src/app/main/ui/settings/integrations.cljs:370
msgid "integrations.mcp-server.status.enabled"
msgstr "Habilitado"
#: src/app/main/ui/settings/integrations.cljs:370
msgid "integrations.mcp-server.status.disabled"
msgstr "Deshabilitado"
#: src/app/main/ui/settings/integrations.cljs:363
msgid "integrations.mcp-server.status.expired.0"
msgstr "La clave MCP utilizada para conectarse al servidor MCP ha expirado. Como resultado, no se puede establecer la conexión."
#: src/app/main/ui/settings/integrations.cljs:368
msgid "integrations.mcp-server.status.expired.1"
msgstr "Por favor, regenera la clave MCP y actualiza la configuración de tu cliente con la nueva clave."
#: src/app/main/ui/settings/integrations.cljs:415
msgid "integrations.mcp-server.mcp-keys.copy"
msgstr "Copiar enlace"
#: src/app/main/ui/settings/integrations.cljs:422
msgid "integrations.mcp-server.mcp-keys.help"
msgstr "Cómo configurar clientes MCP"
#: src/app/main/ui/settings/integrations.cljs:405
msgid "integrations.mcp-server.mcp-keys.info"
msgstr "Esta es la URL del servidor que necesitarás configurar en tu cliente MCP para conectarlo al servidor MCP de Penpot."
#: src/app/main/ui/settings/integrations.cljs:387
msgid "integrations.mcp-server.mcp-keys.regenerate"
msgstr "Regenerar clave MCP"
#: src/app/main/ui/settings/integrations.cljs:381
msgid "integrations.mcp-server.mcp-keys.title"
msgstr "Clave MCP"
#: src/app/main/ui/settings/integrations.cljs:388
msgid "integrations.mcp-server.mcp-keys.tootip"
msgstr "La clave MCP es necesaria para la configuración del cliente MCP"
#: src/app/main/ui/settings/integrations.cljs:124
msgid "integrations.name.label"
msgstr "Nombre"
#: src/app/main/ui/settings/integrations.cljs:126
msgid "integrations.name.placeholder"
msgstr "El nombre te pude ayudar a saber para qué se utiliza el token"
#: src/app/main/ui/settings/integrations.cljs:103
msgid "integrations.notification.success.token-copied"
msgstr "Token copiado"
#: src/app/main/ui/settings/integrations.cljs:103
#: src/app/main/ui/settings/integrations.cljs:103
msgid "integrations.notification.success.mcp-key-copied"
msgstr "Clave MCP copiada"
#: src/app/main/ui/settings/integrations.cljs:64
msgid "integrations.notification.success.created"
msgstr "Token creado con éxito"
#: src/app/main/ui/settings/integrations.cljs:327
msgid "integrations.notification.success.copied-link"
msgstr "Enlace copiado al portapapeles"
#: src/app/main/ui/settings/integrations.cljs:293
msgid "integrations.notification.success.mcp-server-disabled"
msgstr "Servidor MCP deshabilitado"
#: src/app/main/ui/settings/integrations.cljs:299
msgid "integrations.notification.success.mcp-server-enabled"
msgstr "Servidor MCP habilitado"
#: src/app/main/ui/settings/integrations.cljs:317
msgid "integrations.regenerate-mcp-key.info"
msgstr "Regenerar la clave MCP revocará inmediatamente la actual. Cualquier aplicación que la esté utilizando dejará de funcionar."
#: src/app/main/ui/settings/integrations.cljs:317
msgid "integrations.regenerate-mcp-key.title"
msgstr "Regenerar clave MCP"
#: src/app/main/ui/settings/integrations.cljs:318
msgid "integrations.regenerate-mcp-key.title.created"
msgstr "Clave MCP regenerada"
#: src/app/main/ui/settings/integrations.cljs:480
msgid "integrations.title"
msgstr "Integraciones"
#: src/app/main/ui/settings/integrations.cljs:142
msgid "integrations.token.will-expire"
msgstr "El token expirará el %s"
#: src/app/main/ui/settings/integrations.cljs:142
#: src/app/main/ui/settings/integrations.cljs:142
msgid "integrations.mcp-key.will-expire"
msgstr "La clave MCP expirará el %s"
#: src/app/main/ui/settings/integrations.cljs:143
msgid "integrations.token.will-not-expire"
msgstr "El token no tiene fecha de expiración"
#: src/app/main/ui/settings/integrations.cljs:143
#: src/app/main/ui/settings/integrations.cljs:143
msgid "integrations.mcp-key.will-not-expire"
msgstr "La clave MCP no tiene fecha de expiración"
#: src/app/main/ui/dashboard/comments.cljs:96
msgid "label.mark-all-as-read"
msgstr "Marcar todo como leído"
@@ -2321,6 +2481,9 @@ msgstr "Director"
msgid "labels.discard"
msgstr "Descartar"
msgid "labels.dismiss"
msgstr "Cancelar"
#: src/app/main/ui/settings/feedback.cljs:134, src/app/main/ui/static.cljs:409
msgid "labels.download"
msgstr "Descargar %s"
@@ -2456,6 +2619,10 @@ msgstr "Información"
msgid "labels.installed-fonts"
msgstr "Fuentes instaladas"
#: src/app/main/ui/settings/sidebar.cljs:123
msgid "labels.integrations"
msgstr "Integraciones"
#: src/app/main/ui/static.cljs:405
msgid "labels.internal-error.desc-message-first"
msgstr "Ha ocurrido algo extraño."
@@ -3111,30 +3278,6 @@ msgstr "Cambiar correo"
msgid "modals.change-email.title"
msgstr "Cambiar tu correo"
#: src/app/main/ui/settings/access_tokens.cljs:152, src/app/main/ui/settings/access_tokens.cljs:158
msgid "modals.create-access-token.copy-token"
msgstr "Copiar token"
#: src/app/main/ui/settings/access_tokens.cljs:130
msgid "modals.create-access-token.expiration-date.label"
msgstr "Fecha de expiración"
#: src/app/main/ui/settings/access_tokens.cljs:124
msgid "modals.create-access-token.name.label"
msgstr "Nombre"
#: src/app/main/ui/settings/access_tokens.cljs:126
msgid "modals.create-access-token.name.placeholder"
msgstr "El nombre te pude ayudar a saber para qué se utiliza el token"
#: src/app/main/ui/settings/access_tokens.cljs:178
msgid "modals.create-access-token.submit-label"
msgstr "Crear token"
#: src/app/main/ui/settings/access_tokens.cljs:111
msgid "modals.create-access-token.title"
msgstr "Generar access token"
#: src/app/main/ui/dashboard/team.cljs:1127
msgid "modals.create-webhook.submit-label"
msgstr "Crear webhook"
@@ -3151,18 +3294,6 @@ msgstr "Payload URL"
msgid "modals.create-webhook.url.placeholder"
msgstr "https://example.com/postreceive"
#: src/app/main/ui/settings/access_tokens.cljs:257
msgid "modals.delete-acces-token.accept"
msgstr "Borrar token"
#: src/app/main/ui/settings/access_tokens.cljs:256
msgid "modals.delete-acces-token.message"
msgstr "¿Seguro que deseas borrar este token?"
#: src/app/main/ui/settings/access_tokens.cljs:255
msgid "modals.delete-acces-token.title"
msgstr "Borrar token"
#: src/app/main/ui/settings/delete_account.cljs:56
msgid "modals.delete-account.cancel"
msgstr "Cancelar y mantener mi cuenta"
@@ -3666,6 +3797,12 @@ msgstr "Invitación enviada con éxito"
msgid "notifications.invitation-link-copied"
msgstr "Enlace de invitacion copiado"
msgid "notifications.mcp.active-in-another-tab"
msgstr "MCP está activo en otra pestaña. ¿Cambiar a esta?"
msgid "notifications.mcp.active-in-this-tab"
msgstr "MCP está ahora activo en esta pestaña."
#: src/app/main/ui/settings/delete_account.cljs:24
msgid "notifications.profile-deletion-not-allowed"
msgstr "No puedes borrar tu perfil. Reasigna tus equipos antes de seguir."
@@ -5068,14 +5205,14 @@ msgstr "Bibliotecas Compartidas - %s - Penpot"
msgid "title.default"
msgstr "Penpot - Diseño Libre para Equipos"
#: src/app/main/ui/settings/access_tokens.cljs:278
msgid "title.settings.access-tokens"
msgstr "Perfil - Access tokens"
#: src/app/main/ui/settings/feedback.cljs:161
msgid "title.settings.feedback"
msgstr "Danos tu opinión - Penpot"
#: src/app/main/ui/settings/integrations.cljs:278
msgid "title.settings.integrations"
msgstr "Integraciones - Penpot"
#: src/app/main/ui/settings/notifications.cljs:45
msgid "title.settings.notifications"
msgstr "Notificaciones - Penpot"
@@ -5571,6 +5708,18 @@ msgstr "Ocultar reglas"
msgid "workspace.header.menu.hide-textpalette"
msgstr "Ocultar paleta de textos"
msgid "workspace.header.menu.mcp.plugin.status.connect"
msgstr "Conectar"
msgid "workspace.header.menu.mcp.plugin.status.disconnect"
msgstr "Desconectar"
msgid "workspace.header.menu.mcp.server.status.enabled"
msgstr "Gestionar (estado: habilitado)"
msgid "workspace.header.menu.mcp.server.status.disabled"
msgstr "Gestionar (estado: deshabilitado)"
#: src/app/main/ui/workspace/main_menu.cljs:884
msgid "workspace.header.menu.option.edit"
msgstr "Editar"
@@ -5583,6 +5732,9 @@ msgstr "Archivo"
msgid "workspace.header.menu.option.help-info"
msgstr "Ayuda e información"
msgid "workspace.header.menu.option.mcp"
msgstr "Servidor MCP"
#: src/app/main/ui/workspace/main_menu.cljs:906
msgid "workspace.header.menu.option.preferences"
msgstr "Preferencias"

2
mcp/.gitignore vendored
View File

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

View File

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

View File

@@ -1,5 +1,3 @@
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
@@ -19,7 +17,7 @@ read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
@@ -62,15 +60,17 @@ excluded_tools: []
# (contrary to the memories, which are loaded on demand).
initial_prompt: |
IMPORTANT: You use an idiomatic, object-oriented style.
In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions
In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions
rather than mere functions (i.e. use the strategy pattern, for example).
Comments:
Always read the "project_overview" memory.
Comments:
When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase
clearly defines *what* it is. Any details then follow in subsequent sentences.
When describing what blocks of code do, you also use an elliptical style and start with a lower-case letter unless
the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is
the comment is a lengthy explanation with at least two sentences (in which case you start with a capital letter, as is
required for sentences).
# the name by which the project can be referenced within Serena
project_name: "penpot-mcp"
@@ -128,3 +128,39 @@ encoding: utf-8
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# list of regex patterns which, when matched, mark a memory entry as 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
The project requires [Node.js](https://nodejs.org/) (tested with v22.x
with corepack).
The project requires [Node.js](https://nodejs.org/) (tested with v22.x).
Following the installation of Node.js, the tools `pnpm` and `npx`
should be available in your terminal. For ensure corepack installed
and enabled correctly, just execute the `./scripts/setup`.
### 1. Starting the MCP Server and the Plugin Server
It is also required to have `caddy` executeable in the path, it is
used for start a local server for generate types documentation from
the current branch. If you want to run it outside devenv where all
dependencies are already provided, please download caddy from
[here](https://caddyserver.com/download).
#### Running a Released Version via npx
You should probably be using penpot devenv, where all this
dependencies are already present and correctly setup. But nothing
prevents you execute this outside of devenv if you satisfy the
specified dependencies.
The easiest way to launch the servers is to use `npx` to run the appropriate
version that matches your Penpot version.
* If you are using the latest Penpot release, e.g. as served on [design.penpot.app](https://design.penpot.app), run:
```shell
npx -y @penpot/mcp@latest
```
* If you are participating in the MCP beta-test, which uses [test-mcp.penpot.dev](https://test-mcp.penpot.dev), run:
```shell
npx -y @penpot/mcp@beta
```
### 1. Build & Launch the MCP Server and the Plugin Server
Once the servers are running, continue with step 2.
If it's your first execution, install the required dependencies:
#### Running the Source Version from the Repository
The tools `corepack` and `npx` should be available in your terminal.
On Windows, use the Git Bash terminal to ensure compatibility with the provided scripts.
##### Clone the Appropriate Branch of the Repository
> [!IMPORTANT]
> The branches are subject to change in the future.
> Be sure to check the instructions for the latest information on which branch to use.
Clone the Penpot repository, using the proper branch depending on the
version of Penpot you want to use the MCP server with.
* For the current Penpot release 2.14, use the `mcp-prod-2.14.1` branch:
```shell
git clone https://github.com/penpot/penpot.git --branch mcp-prod-2.14.1 --depth 1
```
* For the MCP beta-test, use the `staging` branch:
```shell
git clone https://github.com/penpot/penpot.git --branch staging --depth 1
```
Then change into the `mcp` directory:
```shell
cd penpot/mcp
```
##### Build & Launch the MCP Server and the Plugin Server
If it's your first execution, install the required dependencies.
(If you are using the Penpot devenv, this step is not necessary, as dependencies are already installed.)
```shell
cd mcp/
./scripts/setup
```
@@ -86,9 +120,9 @@ pnpm run bootstrap
This bootstrap command will:
* install dependencies for all components (`pnpm -r run install`)
* build all components (`pnpm -r run build`)
* start all components (`pnpm -r --parallel run start`)
* install dependencies for all components
* build all components
* start all components
### 2. Load the Plugin in Penpot and Establish the Connection
@@ -123,6 +157,19 @@ This bootstrap command will:
### 3. Connect an MCP Client
> [!IMPORTANT]
> **Use an appropriate model.**
>
> We recommend that you ...
> * use the most capable model at your disposal.
> You will achieve the best results with frontier models,
> especially when dealing with more complex tasks.
> Weaker models, including most locally hosted ones,
> are unlikely to produce usable results for anything beyond simple tasks.
> * use a vision language model (VLM), as many design tasks necessitate visual
> inspection.
> (If you are using a standard commercial model, it almost certainly supports vision already.)
By default, the server runs on port 4401 and provides:
- **Modern Streamable HTTP endpoint**: `http://localhost:4401/mcp`
@@ -140,14 +187,9 @@ NOTE: only relevant if you are executing this outside of devenv
The `mcp-remote` package can proxy stdio transport to HTTP/SSE,
allowing clients that support only stdio to connect to the MCP server indirectly.
Use it to provide the launch command for your MCP client as follows:
1. Install `mcp-remote` globally if you haven't already:
npm install -g mcp-remote
2. Use `mcp-remote` to provide the launch command for your MCP client:
npx -y mcp-remote http://localhost:4401/sse --allow-http
npx -y mcp-remote http://localhost:4401/mcp --allow-http
#### Example: Claude Desktop
@@ -170,7 +212,7 @@ Add a `penpot` entry under `mcpServers` with the following content:
"mcpServers": {
"penpot": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:4401/sse", "--allow-http"]
"args": ["-y", "mcp-remote", "http://localhost:4401/mcp", "--allow-http"]
}
}
}
@@ -195,37 +237,36 @@ To add the Penpot MCP server to a Claude Code project, issue the command
This repository is a monorepo containing four main components:
1. **Common Types** (`common/`):
1. **Common Types** (`packages/common/`):
- Shared TypeScript definitions for request/response protocol
- Ensures type safety across server and plugin components
2. **Penpot MCP Server** (`mcp-server/`):
2. **Penpot MCP Server** (`packages/server/`):
- Provides MCP tools to LLMs for Penpot interaction
- Runs a WebSocket server accepting connections from the Penpot MCP plugin
- Implements request/response correlation with unique task IDs
- Handles task timeouts and proper error reporting
3. **Penpot MCP Plugin** (`penpot-plugin/`):
3. **Penpot MCP Plugin** (`packages/plugin/`):
- Connects to the MCP server via WebSocket
- Executes tasks in Penpot using the Plugin API
- Sends structured responses back to the server#
4. **Helper Scripts** (`python-scripts/`):
- Python scripts that prepare data for the MCP server (development use)
4. **Types Generator** (`types-generator/`):
- Generates data on API types for the MCP server (development use)
The core components are written in TypeScript, rendering interactions with the
Penpot Plugin API both natural and type-safe.
## Configuration
The Penpot MCP server can be configured using environment variables. All configuration
options use the `PENPOT_MCP_` prefix for consistency.
The Penpot MCP server can be configured using environment variables.
### Server Configuration
| Environment Variable | Description | Default |
|------------------------------------|----------------------------------------------------------------------------|--------------|
| `PENPOT_MCP_SERVER_LISTEN_ADDRESS` | Address on which the MCP server listens (binds to) | `localhost` |
| `PENPOT_MCP_SERVER_HOST` | Address on which the MCP server listens (binds to) | `localhost` |
| `PENPOT_MCP_SERVER_PORT` | Port for the HTTP/SSE server | `4401` |
| `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` |
| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` |
@@ -243,7 +284,7 @@ options use the `PENPOT_MCP_` prefix for consistency.
| Environment Variable | Description | Default |
|-------------------------------------------|-----------------------------------------------------------------------------------------|--------------|
| `PENPOT_MCP_PLUGIN_SERVER_LISTEN_ADDRESS` | Address on which the plugin web server listens (single address or comma-separated list) | (local only) |
| `PENPOT_MCP_PLUGIN_SERVER_HOST` | Address on which the plugin web server listens (single address or comma-separated list) | (local only) |
## Beyond Local Execution
@@ -263,3 +304,17 @@ you may set the following environment variables to configure the two servers
* `PENPOT_MCP_SERVER_ADDRESS=<your-address>`: This sets the hostname or IP address
where the MCP server can be reached. The Penpot MCP Plugin uses this to construct
the WebSocket URL as `ws://<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",
"version": "1.0.0",
"description": "",
"name": "@penpot/mcp",
"version": "2.15.0-rc.1.153",
"description": "MCP server for Penpot integration",
"bin": {
"penpot-mcp": "./bin/mcp-local.js"
},
"scripts": {
"build": "pnpm -r run build",
"build:multi-user": "pnpm -r run build:multi-user",
"build:types": "bash ./scripts/build-types",
"start": "pnpm -r --parallel run start",
"start:multi-user": "pnpm -r --parallel --filter \"./packages/*\" run start:multi-user",
"start:multi-user": "pnpm -r --parallel run start:multi-user",
"bootstrap": "pnpm -r install && pnpm run build && pnpm run start",
"bootstrap:multi-user": "pnpm -r install && pnpm run build:multi-user && pnpm run start:multi-user",
"bootstrap:multi-user": "pnpm -r install && pnpm run build && pnpm run start:multi-user",
"fmt": "prettier --write packages/",
"fmt:check": "prettier --check packages/"
},
@@ -17,8 +20,7 @@
"type": "git",
"url": "https://github.com/penpot/penpot.git"
},
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
"private": true,
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268",
"devDependencies": {
"concurrently": "^9.2.1",
"prettier": "^3.0.0"

View File

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

View File

@@ -3,12 +3,87 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Penpot plugin example</title>
<title>Penpot MCP Plugin</title>
</head>
<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>
</body>

View File

@@ -5,9 +5,8 @@
"type": "module",
"scripts": {
"start": "vite build --watch --config vite.config.ts",
"start:multi-user": "cross-env MULTI_USER_MODE=true vite build --watch --config vite.config.ts",
"start:multi-user": "pnpm run start",
"build": "tsc && vite build --config vite.release.config.ts",
"build:multi-user": "tsc && cross-env MULTI_USER_MODE=true vite build --config vite.release.config.ts",
"types:check": "tsc --noEmit",
"clean": "rm -rf dist/"
},

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

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

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 {
/**
@@ -189,6 +189,24 @@ export class PenpotUtils {
return penpot.generateStyle([shape], { type: "css", includeChildren: true });
}
/**
* Gets the actual rendering bounds of a shape. For most shapes, this is simply the `bounds` property.
* However, for Text shapes, the `bounds` may not reflect the true size of the rendered text content,
* so we use the `textBounds` property instead.
*
* @param shape - The shape to get the bounds for
*/
public static getBounds(shape: Shape): Bounds {
if (shape.type === "text") {
const text = shape as Text;
// TODO: Remove ts-ignore once type definitions are updated
// @ts-ignore
return text.textBounds;
} else {
return shape.bounds;
}
}
/**
* Checks if a child shape is fully contained within its parent's bounds.
* Visual containment means all edges of the child are within the parent's bounding box.
@@ -198,11 +216,13 @@ export class PenpotUtils {
* @returns true if child is fully contained within parent bounds, false otherwise
*/
public static isContainedIn(child: Shape, parent: Shape): boolean {
const childBounds = this.getBounds(child);
const parentBounds = this.getBounds(parent);
return (
child.x >= parent.x &&
child.y >= parent.y &&
child.x + child.width <= parent.x + parent.width &&
child.y + child.height <= parent.y + parent.height
childBounds.x >= parentBounds.x &&
childBounds.y >= parentBounds.y &&
childBounds.x + childBounds.width <= parentBounds.x + parentBounds.width &&
childBounds.y + childBounds.height <= parentBounds.y + parentBounds.height
);
}
@@ -298,39 +318,16 @@ export class PenpotUtils {
/**
* Decodes a base64 string to a Uint8Array.
* This is required because the Penpot plugin environment does not provide the atob function.
*
* @param base64 - The base64-encoded string to decode
* @returns The decoded data as a Uint8Array
*/
public static atob(base64: string): Uint8Array {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const lookup = new Uint8Array(256);
for (let i = 0; i < chars.length; i++) {
lookup[chars.charCodeAt(i)] = i;
public static base64ToByteArray(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
let bufferLength = base64.length * 0.75;
if (base64[base64.length - 1] === "=") {
bufferLength--;
if (base64[base64.length - 2] === "=") {
bufferLength--;
}
}
const bytes = new Uint8Array(bufferLength);
let p = 0;
for (let i = 0; i < base64.length; i += 4) {
const encoded1 = lookup[base64.charCodeAt(i)];
const encoded2 = lookup[base64.charCodeAt(i + 1)];
const encoded3 = lookup[base64.charCodeAt(i + 2)];
const encoded4 = lookup[base64.charCodeAt(i + 3)];
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
}
return bytes;
}
@@ -360,7 +357,7 @@ export class PenpotUtils {
height: number | undefined
): Promise<Rectangle> {
// convert base64 to Uint8Array
const bytes = PenpotUtils.atob(base64);
const bytes = PenpotUtils.base64ToByteArray(base64);
// upload the image data to Penpot
const imageData = await penpot.uploadMediaData(name, bytes, mimeType);
@@ -423,6 +420,11 @@ export class PenpotUtils {
* - For mode="fill", it will be whatever format the fill image is stored in.
*/
public static async exportImage(shape: Shape, mode: "shape" | "fill", asSVG: boolean): Promise<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) {
case "shape":
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";
// get the current theme from the URL
const searchParams = new URLSearchParams(window.location.search);
const searchParams = new URLSearchParams(window.location.hash.split("?")[1]);
document.body.dataset.theme = searchParams.get("theme") ?? "light";
// Determine whether multi-user mode is enabled based on URL parameters
const isMultiUserMode = searchParams.get("multiUser") === "true";
console.log("Penpot MCP multi-user mode:", isMultiUserMode);
// WebSocket connection management
let ws: WebSocket | null = null;
const statusElement = document.getElementById("connection-status");
const statusPill = document.getElementById("connection-status") as HTMLElement;
const statusText = document.getElementById("status-text") as HTMLElement;
const currentTaskEl = document.getElementById("current-task") as HTMLElement;
const executedCodeEl = document.getElementById("executed-code") as HTMLTextAreaElement;
const copyCodeBtn = document.getElementById("copy-code-btn") as HTMLButtonElement;
const connectBtn = document.getElementById("connect-btn") as HTMLButtonElement;
const disconnectBtn = document.getElementById("disconnect-btn") as HTMLButtonElement;
const versionWarningEl = document.getElementById("version-warning") as HTMLElement;
const versionWarningTextEl = document.getElementById("version-warning-text") as HTMLElement;
/**
* Updates the connection status display element.
* Updates the status pill and button visibility based on connection state.
*
* @param status - the base status text to display
* @param isConnectedState - whether the connection is in a connected state (affects color)
* @param message - optional additional message to append to the status
* @param code - the connection state code ("idle" | "connecting" | "connected" | "disconnected" | "error")
* @param label - human-readable label to display inside the pill
*/
function updateConnectionStatus(status: string, isConnectedState: boolean, message?: string): void {
if (statusElement) {
const displayText = message ? `${status}: ${message}` : status;
statusElement.textContent = displayText;
statusElement.style.color = isConnectedState ? "var(--accent-primary)" : "var(--error-700)";
function updateConnectionStatus(code: string, label: string): void {
if (statusPill) {
statusPill.dataset.status = code;
}
if (statusText) {
statusText.textContent = label;
}
const isConnected = code === "connected";
if (connectBtn) connectBtn.hidden = isConnected;
if (disconnectBtn) disconnectBtn.hidden = !isConnected;
parent.postMessage(
{
type: "update-connection-status",
status: code,
},
"*"
);
}
/**
* Updates the "Current task" display with the currently executing task name.
*
* @param taskName - the task name to display, or null to reset to "---"
*/
function updateCurrentTask(taskName: string | null): void {
if (currentTaskEl) {
currentTaskEl.textContent = taskName ?? "---";
}
if (taskName === null) {
updateExecutedCode(null);
}
}
/**
* Updates the executed code textarea with the last code run during task execution.
*
* @param code - the code string to display, or null to clear
*/
function updateExecutedCode(code: string | null): void {
if (executedCodeEl) {
executedCodeEl.value = code ?? "";
}
if (copyCodeBtn) {
copyCodeBtn.disabled = !code;
}
}
@@ -44,31 +89,41 @@ function sendTaskResponse(response: any): void {
/**
* Establishes a WebSocket connection to the MCP server.
*/
function connectToMcpServer(): void {
function connectToMcpServer(baseUrl?: string, token?: string): void {
if (ws?.readyState === WebSocket.OPEN) {
updateConnectionStatus("Already connected", true);
updateConnectionStatus("connected", "Connected");
return;
}
try {
let wsUrl = PENPOT_MCP_WEBSOCKET_URL;
if (isMultiUserMode) {
// TODO obtain proper userToken from penpot
const userToken = "dummyToken";
wsUrl += `?userToken=${encodeURIComponent(userToken)}`;
let wsUrl = baseUrl || PENPOT_MCP_WEBSOCKET_URL;
let wsError: unknown | undefined;
if (token) {
wsUrl += `?userToken=${encodeURIComponent(token)}`;
}
ws = new WebSocket(wsUrl);
updateConnectionStatus("Connecting...", false);
updateConnectionStatus("connecting", "Connecting...");
ws.onopen = () => {
console.log("Connected to MCP server");
updateConnectionStatus("Connected to MCP server", true);
setTimeout(() => {
if (ws) {
console.log("Connected to MCP server");
updateConnectionStatus("connected", "Connected");
}
}, 100);
};
ws.onmessage = (event) => {
console.log("Received from MCP server:", event.data);
try {
console.log("Received from MCP server:", event.data);
const request = JSON.parse(event.data);
// Track the current task received from the MCP server
if (request.task) {
updateCurrentTask(request.task);
updateExecutedCode(request.params?.code ?? null);
}
// Forward the task request to the plugin for execution
parent.postMessage(request, "*");
} catch (error) {
@@ -77,34 +132,70 @@ function connectToMcpServer(): void {
};
ws.onclose = (event: CloseEvent) => {
console.log("Disconnected from MCP server");
const message = event.reason || undefined;
updateConnectionStatus("Disconnected", false, message);
// If we've send the error update we don't send the disconnect as well
if (!wsError) {
console.log("Disconnected from MCP server");
const label = event.reason ? `Disconnected: ${event.reason}` : "Disconnected";
updateConnectionStatus("disconnected", label);
updateCurrentTask(null);
}
ws = null;
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
wsError = error;
// note: WebSocket error events typically don't contain detailed error messages
updateConnectionStatus("Connection error", false);
updateConnectionStatus("error", "Connection error");
};
} catch (error) {
console.error("Failed to connect to MCP server:", error);
const message = error instanceof Error ? error.message : undefined;
updateConnectionStatus("Connection failed", false, message);
const reason = error instanceof Error ? error.message : undefined;
const label = reason ? `Connection failed: ${reason}` : "Connection failed";
updateConnectionStatus("error", label);
}
}
document.querySelector("[data-handler='connect-mcp']")?.addEventListener("click", () => {
copyCodeBtn?.addEventListener("click", () => {
const code = executedCodeEl?.value;
if (!code) return;
navigator.clipboard.writeText(code).then(() => {
copyCodeBtn.classList.add("copied");
setTimeout(() => copyCodeBtn.classList.remove("copied"), 1500);
});
});
connectBtn?.addEventListener("click", () => {
connectToMcpServer();
});
disconnectBtn?.addEventListener("click", () => {
ws?.close();
});
// Listen plugin.ts messages
window.addEventListener("message", (event) => {
if (event.data.source === "penpot") {
if (event.data.type === "start-server") {
connectToMcpServer(event.data.url, event.data.token);
}
if (event.data.type === "version-mismatch") {
if (versionWarningEl && versionWarningTextEl) {
versionWarningTextEl.innerHTML =
`<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;
} else if (event.data.type === "task-response") {
// Forward task response back to MCP server
sendTaskResponse(event.data.response);
}
});
parent.postMessage({ type: "ui-initialized" }, "*");

View File

@@ -1,22 +1,58 @@
import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler";
import { Task, TaskHandler } from "./TaskHandler";
/**
* Extracts the major.minor.patch prefix from a version string.
*
* @param version - a version string starting with major.minor.patch
* @returns the major.minor.patch prefix, or the original string if it does not match
*/
function extractVersionPrefix(version: string): string {
const match = version.match(/^(\d+\.\d+\.\d+)/);
return match ? match[1] : version;
}
mcp?.setMcpStatus("connecting");
/**
* Registry of all available task handlers.
*/
const taskHandlers: TaskHandler[] = [new ExecuteCodeTaskHandler()];
// Determine whether multi-user mode is enabled based on build-time configuration
declare const IS_MULTI_USER_MODE: boolean;
const isMultiUserMode = typeof IS_MULTI_USER_MODE !== "undefined" ? IS_MULTI_USER_MODE : false;
// Open the plugin UI (main.ts)
penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}&multiUser=${isMultiUserMode}`, { width: 158, height: 200 });
penpot.ui.open("Penpot MCP Plugin", `?theme=${penpot.theme}`, {
width: 236,
height: 210,
hidden: !!mcp,
} as any);
// Handle messages
penpot.ui.onMessage<string | { id: string; task: string; params: any }>((message) => {
// Handle plugin task requests
if (typeof message === "object" && message.task && message.id) {
// Register message handlers
penpot.ui.onMessage<string | { id: string; type?: string; status?: string; task: string; params: any }>((message) => {
if (typeof message === "object" && message.type === "ui-initialized") {
// Check Penpot version compatibility
const penpotVersionPrefix = penpot.version ? extractVersionPrefix(penpot.version) : "<2.15"; // pre-2.15 versions don't have version info
const mcpVersionPrefix = extractVersionPrefix(PENPOT_MCP_VERSION);
console.log(`Penpot version: ${penpotVersionPrefix}, MCP version: ${mcpVersionPrefix}`);
const isLocalPenpotVersion = penpotVersionPrefix == "0.0.0";
if (penpotVersionPrefix !== mcpVersionPrefix && !isLocalPenpotVersion) {
penpot.ui.sendMessage({
type: "version-mismatch",
mcpVersion: mcpVersionPrefix,
penpotVersion: penpotVersionPrefix,
});
}
// Initiate connection to remote MCP server (if enabled)
if (mcp) {
penpot.ui.sendMessage({
type: "start-server",
url: mcp?.getServerUrl(),
token: mcp?.getToken(),
});
}
} else if (typeof message === "object" && message.type === "update-connection-status") {
mcp?.setMcpStatus(message.status || "unknown");
} else if (typeof message === "object" && message.task && message.id) {
// Handle plugin tasks submitted by the MCP server
handlePluginTaskRequest(message).catch((error) => {
console.error("Error in handlePluginTaskRequest:", error);
});
@@ -59,6 +95,21 @@ async function handlePluginTaskRequest(request: { id: string; task: string; para
}
}
if (mcp) {
mcp.on("disconnect", async () => {
penpot.ui.sendMessage({
type: "stop-server",
});
});
mcp.on("connect", async () => {
penpot.ui.sendMessage({
type: "start-server",
url: mcp?.getServerUrl(),
token: mcp?.getToken(),
});
});
}
// Handle theme change in the iframe
penpot.on("themechange", (theme) => {
penpot.ui.sendMessage({

View File

@@ -1,10 +1,190 @@
@import "@penpot/plugin-styles/styles.css";
body {
line-height: 1.5;
padding: 10px;
margin: 0;
padding: 0;
}
p {
margin-block-end: 0.75rem;
.plugin-container {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-16) var(--spacing-8);
box-sizing: border-box;
}
/* ── Status pill ─────────────────────────────────────────────────── */
.status-pill {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-16);
border-radius: var(--spacing-8);
border: 1px solid var(--background-quaternary);
color: var(--foreground-secondary);
width: 100%;
box-sizing: border-box;
}
.status-pill[data-status="connected"] {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.status-pill[data-status="disconnected"],
.status-pill[data-status="error"] {
border-color: var(--error-500);
color: var(--error-500);
}
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: currentColor;
flex-shrink: 0;
}
/* ── Collapsible section ─────────────────────────────────────────── */
.collapsible-section {
border: 1px solid var(--background-quaternary);
border-radius: var(--spacing-8);
overflow: hidden;
}
.collapsible-header {
display: flex;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-12);
cursor: pointer;
color: var(--foreground-secondary);
list-style: none;
user-select: none;
}
.collapsible-header::-webkit-details-marker {
display: none;
}
.collapsible-arrow {
flex-shrink: 0;
transition: transform 0.2s ease;
}
details[open] > .collapsible-header .collapsible-arrow {
transform: rotate(90deg);
}
.collapsible-body {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-12) var(--spacing-12);
border-top: 1px solid var(--background-quaternary);
}
/* ── Tool section ────────────────────────────────────────────────── */
.tool-label {
color: var(--foreground-secondary);
}
.tool-display {
display: flex;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-12);
border-radius: var(--spacing-8);
background-color: var(--background-tertiary);
color: var(--foreground-secondary);
min-height: 32px;
box-sizing: border-box;
}
.tool-icon {
flex-shrink: 0;
opacity: 0.7;
}
/* ── Code section ────────────────────────────────────────────────── */
.code-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: var(--spacing-8);
}
.code-textarea {
width: 100%;
height: 100px;
resize: vertical;
padding: var(--spacing-8) var(--spacing-12);
border-radius: var(--spacing-8);
border: 1px solid var(--background-quaternary);
background-color: var(--background-tertiary);
color: var(--foreground-secondary);
font-family: monospace;
font-size: 11px;
line-height: 1.5;
box-sizing: border-box;
outline: none;
}
.copy-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 1px solid var(--background-quaternary);
border-radius: var(--spacing-4);
background-color: transparent;
color: var(--foreground-secondary);
cursor: pointer;
flex-shrink: 0;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.copy-btn:hover:not(:disabled) {
background-color: var(--background-tertiary);
color: var(--foreground-primary);
}
.copy-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.copy-btn.copied {
color: var(--accent-primary);
border-color: var(--accent-primary);
}
/* ── Version warning ─────────────────────────────────────────────── */
.version-warning {
align-items: flex-start;
padding: var(--spacing-8) var(--spacing-12);
border-radius: var(--spacing-8);
border: 1px solid var(--warning-500, #f59e0b);
color: var(--warning-500, #f59e0b);
width: 100%;
box-sizing: border-box;
}
/* ── Action buttons ──────────────────────────────────────────────── */
#connect-btn,
#disconnect-btn {
width: 100%;
margin-top: var(--spacing-4);
}

View File

@@ -195,10 +195,44 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
const context = this.context;
const code = task.params.code;
let result: any = await (async (ctx) => {
const fn = new Function(...Object.keys(ctx), `return (async () => { ${code} })();`);
return fn(...Object.values(ctx));
})(context);
// set the flags naturalChildOrdering and throwValidationErrors to true during code execution.
// TODO: Remove all ts-ignore once Penpot types have been updated
let originalNaturalChildOrdering: any, originalThrowValidationErrors: any;
// @ts-ignore
if (penpot.flags) {
// @ts-ignore
originalNaturalChildOrdering = penpot.flags.naturalChildOrdering;
// @ts-ignore
penpot.flags.naturalChildOrdering = true;
// @ts-ignore
originalThrowValidationErrors = penpot.flags.throwValidationErrors;
// @ts-ignore
penpot.flags.throwValidationErrors = true;
} else {
// TODO: This can be removed once `flags` has been merged to PROD
throw new Error(
"You are using a version of the Penpot MCP server which is incompatible " +
"with the connected Penpot version. " +
"Read the documentation for instructions on which version to use: " +
"https://github.com/penpot/penpot/tree/develop/mcp\n" +
"If you are an LLM, tell the user about this!"
);
}
let result: any;
try {
// execute the code in an async function with the context variables as parameters
result = await (async (ctx) => {
const fn = new Function(...Object.keys(ctx), `return (async () => { ${code} })();`);
return fn(...Object.values(ctx));
})(context);
} finally {
// restore the original value of the flags
// @ts-ignore
penpot.flags.naturalChildOrdering = originalNaturalChildOrdering;
// @ts-ignore
penpot.flags.throwValidationErrors = originalThrowValidationErrors;
}
console.log("Code execution result:", result);

View File

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

View File

@@ -1,11 +1,16 @@
import { defineConfig } from "vite";
import livePreview from "vite-live-preview";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const rootPkg = require("../../package.json");
let WS_URI = process.env.WS_URI || "http://localhost:4402";
let MULTI_USER_MODE = process.env.MULTI_USER_MODE === "true";
let SERVER_HOST = process.env.PENPOT_MCP_PLUGIN_SERVER_HOST ?? "localhost";
let MCP_VERSION = JSON.stringify(rootPkg.version);
console.log("Will define IS_MULTI_USER_MODE as:", JSON.stringify(MULTI_USER_MODE));
console.log("Will define PENPOT_MCP_WEBSOCKET_URL as:", JSON.stringify(WS_URI));
console.log("PENPOT_MCP_WEBSOCKET_URL:", JSON.stringify(WS_URI));
console.log("PENPOT_MCP_VERSION:", MCP_VERSION);
export default defineConfig({
base: "./",
@@ -31,13 +36,13 @@ export default defineConfig({
},
},
preview: {
host: "0.0.0.0",
host: SERVER_HOST,
port: 4400,
cors: true,
allowedHosts: [],
},
define: {
IS_MULTI_USER_MODE: JSON.stringify(process.env.MULTI_USER_MODE === "true"),
PENPOT_MCP_WEBSOCKET_URL: JSON.stringify(WS_URI),
PENPOT_MCP_VERSION: MCP_VERSION,
},
});

View File

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

View File

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

View File

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.
As a precondition, the user must connect the Penpot design project to the MCP server using the Penpot MCP Plugin.
IMPORTANT: When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design.
NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to
non-creative defaults such as white/black if you are lacking information).
# Executing Code
One of your key tools is the `execute_code` tool, which allows you to run JavaScript code using the Penpot Plugin API
@@ -43,18 +39,30 @@ Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image`
* `parentX` and `parentY` (as well as `boardX` and `boardY`) are READ-ONLY computed properties showing position relative to parent/board.
To position relative to parent, use `penpotUtils.setParentXY(shape, parentX, parentY)` or manually set `shape.x = parent.x + parentX`.
* `width` and `height` are READ-ONLY. Use `resize(width, height)` method to change dimensions.
* `bounds` is a READ-ONLY property. Use `x`, `y` with `resize()` to modify shape bounds.
* `bounds` is READ-ONLY (members: x, y, width, height). To modify the bounding box, change `x`, `y` or apply `resize()`.
**Other Writable Properties**:
* `name` - Shape name
* `fills`, `strokes` - Styling properties
* `rotation`, `opacity`, `blocked`, `hidden`, `visible`
* `fills: Fill[]`, `strokes: Stroke[]`, `shadows: Shadow[]` - Styling properties
- Setting fills: `shape.fills = [{ fillColor: "#FF0000", fillOpacity: 1 }]`; no fill (transparent): `shape.fills = []`;
- Reusing objects in another shape: `targetShape.fills = sourceShape.fills` or more granular `targetShape.fills = [{ fillOpacity: 1, fillImage: sourceShape.fills[0].fillImage }]`
The objects are not shared references; you can modify properties of the fills in the target shape without affecting the source shape.
- Colors: Use hex strings with caps only (e.g. '#FF5533')
- IMPORTANT: The contents of the arrays are read-only. You cannot modify individual fills/strokes; you need to replace the entire array to change them!
* `borderRadius` - Uniform border radius for all corners
* `borderRadiusTopLeft`, `borderRadiusTopRight`, `borderRadiusBottomRight`, `borderRadiusBottomLeft` - Individual corner radii.
* `blur: Blur` - Blur properties
* `blendMode` - Blend mode (e.g. `"normal"`, `"multiply"`, `"overlay"`, etc.)
* `rotation` (deg), `opacity`, `blocked`, `hidden`, `visible`
* `proportionLock` - Whether width and height are locked to the same ratio
* `constraintsHorizontal` - Horizontal resize constraint (`"left"`, `"right"`, `"center"`, `"leftright"`, `"scale"`)
* `constraintsVertical` - Vertical resize constraint (`"top"`, `"bottom"`, `"center"`, `"topbottom"`, `"scale"`)
* `flipX`, `flipY` - Horizontal/vertical flip
**Z-Order**:
* The z-order of shapes is determined by the order in the `children` array of the parent shape.
Therefore, when creating shapes that should be on top of each other, add them to the parent in the correct order
(i.e. add background shapes first, then foreground shapes later).
CRITICAL: NEVER use the broken function `appendChild` to achieve this, ALWAYS use `parent.insertChild(parent.children.length, shape)`
* To modify z-order after creation, use these methods: `bringToFront()`, `sendToBack()`, `bringForward()`, `sendBackward()`,
and, for precise control, `setParentIndex(index)` (0-based).
@@ -67,15 +75,15 @@ Actual low-level shape types are `Rectangle`, `Path`, `Text`, `Ellipse`, `Image`
**Hierarchical Structure**:
* `parent` - The parent shape (null for root shapes)
Note: Hierarchical nesting does not necessarily imply visual containment
* CRITICAL: To add children to a parent shape (e.g. a `Board`):
- ALWAYS use `parent.insertChild(index, shape)` to add a child, e.g. `parent.insertChild(parent.children.length, shape)` to append
- NEVER use `parent.appendChild(shape)` as it is BROKEN and will not insert in a predictable place (except in flex layout boards)
* To add children to a parent shape (e.g. a `Board`): `parent.appendChild(shape)` or `parent.insertChild(index, shape)`
* Reparenting: `newParent.appendChild(shape)` or `newParent.insertChild(index, shape)` will move a shape to new parent
- Automatically removes the shape from its old parent
- Absolute x/y positions are preserved (use `penpotUtils.setParentXY` to adjust relative position)
Cloning: Use `shape.clone(): Shape` to create an exact duplicate (including all properties and children) of a shape; same position as original.
Annotations: Don't add text elements to the design that just repeat a shape's name. In the Penpot UI, the name is displayed anyway.
# Images
The `Image` type is a legacy type. Images are now typically embedded in a `Fill`, with `fillImage` set to an
@@ -87,10 +95,10 @@ Use the `export_shape` and `import_image` tools to export and import images.
Boards can have layout systems that automatically control the positioning and spacing of their children:
* If a board has a layout system, then child positions are controlled by the layout system.
For every child, key properties of the child within the layout are stored in `child.layoutChild: LayoutChildProperties`:
After adding a shape to the layout as a child, key properties of the child within the layout are controlled in `child.layoutChild: LayoutChildProperties`:
- `absolute: boolean` - if true, child position is not controlled by layout system. x/y will set *relative* position within parent!
- margins (`topMargin`, `rightMargin`, `bottomMargin`, `leftMargin` or combined `verticalMargin`, `horizontalMargin`)
- sizing (`verticalSizing`, `horizontalSizing`: "fill" | "auto" | "fix")
- sizing (`verticalSizing`, `horizontalSizing`: "fix" | "auto" | "fill") - controls child resizing depending on the layout's sizing mode (see below)
- min/max sizes (`minWidth`, `maxWidth`, `minHeight`, `maxHeight`)
- `zIndex: number` (higher numbers on top)
@@ -99,18 +107,11 @@ Boards can have layout systems that automatically control the positioning and sp
- `dir`: "row" | "column" | "row-reverse" | "column-reverse"
- Padding: `topPadding`, `rightPadding`, `bottomPadding`, `leftPadding`, or combined `verticalPadding`, `horizontalPadding`
- To modify spacing: adjust `rowGap` and `columnGap` properties, not individual child positions.
Optionally, adjust indivudual child margins via `child.layoutChild`.
- When a board has flex layout,
- child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true);
appending or inserting children automatically positions them according to the layout rules.
- CRITICAL: For for dir="column" or dir="row", the order of the `children` array is reversed relative to the visual order!
Therefore, the element that appears first in the array, appears visually at the end (bottom/right) and vice versa.
ALWAYS BEAR IN MIND THAT THE CHILDREN ARRAY ORDER IS REVERSED FOR dir="column" OR dir="row"!
- CRITICAL: The FlexLayout method `board.flex.appendChild` is BROKEN. To append children to a flex layout board such that
they appear visually at the end, ALWAYS use the Board's method `board.appendChild(shape)`; it will insert at the front
of the `children` array for dir="column" or dir="row", which is what you want. So call it in the order of visual appearance.
To insert at a specific index, use `board.insertChild(index, shape)`, bearing in mind the reversed order for dir="column"
or dir="row".
Optionally, adjust individual child margins via `child.layoutChild`.
- When a board has flex layout, child positions are controlled by the layout system, not by individual x/y coordinates (unless `child.layoutChild.absolute` is true);
appending or inserting children automatically positions them according to the layout rules.
- To append children to a flex layout board such that they appear visually at the end, use the Board's method `board.appendChild(shape)`, i.e. call it in the order of visual appearance.
To insert at a specific index, use `board.insertChild(index, shape)`.
- Add to a board with `board.addFlexLayout(): FlexLayout`; instance then accessible via `board.flex`.
IMPORTANT: When adding a flex layout to a container that already has children,
use `penpotUtils.addFlexLayout(container, dir)` instead! This preserves the existing visual order of children.
@@ -122,9 +123,14 @@ Boards can have layout systems that automatically control the positioning and sp
Check with: `if (board.grid) { ... }`
- Properties: `rows`, `columns`, `rowGap`, `columnGap`
- Children are positioned via 1-based row/column indices
- Add to grid via `board.flex.appendChild(shape, row, column)`
- Add to grid via `board.grid.appendChild(shape, row, column)`
- Modify grid positioning after the fact via `shape.layoutCell: LayoutCellProperties`
* Auto-sizing: both types of layouts have properties `verticalSizing`, `horizontalSizing`: "fix" | "auto" | "fill"
- `fix` (default): no resizing (size determined by shape's own width/height)
- `auto`: size determined by content (container will resize depending on children's dimensions); ALWAYS set this if you want the container size to adapt to contents/margins/spacings!
- `fill`: resize children to fill the container's size (child resizing is controlled by each child's `layoutChild` properties)
* When working with boards:
- ALWAYS check if the board has a layout system before attempting to reposition children
- Modify layout properties (gaps, padding) instead of trying to set child x/y positions directly
@@ -132,13 +138,23 @@ Boards can have layout systems that automatically control the positioning and sp
# Text Elements
The rendered content of `Text` element is given by the `characters` property.
To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size,
it only changes the formal bounding box; if the text does not fit it, it will overflow.
The bounding box is sized automatically as long as the `growType` property is set to "auto-width" or "auto-height".
`resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-*" if you want automatic sizing - otherwise the bounding box will be meaningless, with the text overflowing!
The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box.
`Text` elements:
* The text to be rendered is given by the `characters` property.
* To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size,
it only changes the formal bounding box; if the text does not fit it, it will overflow; use `textBounds` for the actual bounding box of the rendered text.
* Property `bounds` is sized automatically (in one dimension) if the `growType` property is set to "auto-width" or "auto-height".
`resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-width" or "auto-height" if you want automatic sizing!
The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box.
* Method `getRange(start, end): TextRange` to reference a range of characters as a `TextRange` object, which can be styled separately from the rest of the text; `start` index inclusive, `end` exclusive
* Other Writable font properties: `fontId`, `fontFamily`, `fontWeight`, `fontVariant`, `fontStyle`
- To discover valid values, check available fonts in `penpot.fonts: FontContext`
- `FontContext` provides `Font` instances; each font has property `variants: FontVariant[]`
- Example: Determine available weights for a font using `penpot.fonts.findByName("Laila").variants.map(v => v.fontWeight)`
- To apply a `Font` to a `Text` instance and set all font properties at once:
- `font.applyToText(text: Text, variant?: FontVariant)`
- `applyToRange(range: TextRange, variant?: FontVariant)`
* Further writable properties: `align`, `verticalAlign`, `lineHeight`, `letterSpacing`, `textTransform`, `textDecoration` (see API info)
* Method `applyTypography(typography: LibraryTypography)`
# The `penpot` and `penpotUtils` Objects, Exploring Designs
@@ -210,19 +226,6 @@ Common tasks - Quick Reference (ALWAYS use penpotUtils for these):
});
Always validate against the root container that is supposed to contain the shapes.
# Visual Inspection of Designs
For many tasks, it can be critical to visually inspect the design. Remember to use the `export_shape` tool for this purpose!
# Revising Designs
* Before applying design changes, ask: "Would a designer consider this appropriate?"
* When dealing with containment issues, ask: Is the parent too small OR is the child too large?
Container sizes are usually intentional, check content first.
* Check for reasonable font sizes and typefaces
* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning.
Consider converting boards to flex layout when appropriate.
# Asset Libraries
Libraries in Penpot are collections of reusable design assets (components, colors, and typographies) that can be shared across files.
@@ -242,31 +245,75 @@ Each `Library` object has:
* `colors: LibraryColor[]` - Array of colors
* `typographies: LibraryTypography[]` - Array of typographies
## Colors and Typographies
Adding a color:
```
const newColor: LibraryColor = penpot.library.local.createColor();
newColor.name = 'Brand Primary';
newColor.color = '#0066FF';
```
Adding a typography:
```
const newTypo: LibraryTypography = penpot.library.local.createTypography();
newTypo.name = 'Heading Large';
// Set typography properties...
```
## Components
Using library components:
* find a component in the library by name:
const component: LibraryComponent = library.components.find(comp => comp.name.includes('Button'));
`const component: LibraryComponent = library.components.find(comp => comp.name.includes('Button'));`
* create a new instance of the component on the current page:
const instance: Shape = component.instance();
`const instance: Shape = component.instance();`
This returns a `Shape` (often a `Board` containing child elements).
After instantiation, modify the instance's properties as desired.
* get the reference to the main component shape:
const mainShape: Shape = component.mainInstance();
`const mainShape: Shape = component.mainInstance();`
Adding assets to a library:
* const newColor: LibraryColor = penpot.library.local.createColor();
newColor.name = 'Brand Primary';
newColor.color = '#0066FF';
* const newTypo: LibraryTypography = penpot.library.local.createTypography();
newTypo.name = 'Heading Large';
// Set typography properties...
* const shapes: Shape[] = [shape1, shape2]; // shapes to include
const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes);
newComponent.name = 'My Button';
Adding a component to a library:
```
const shapes: Shape[] = [shape1, shape2]; // shapes to include
const newComponent: LibraryComponent = penpot.library.local.createComponent(shapes);
newComponent.name = 'My Button';
```
Detaching:
* When creating new design elements based on a component instance/copy, use `shape.detach()` to break the link to the main component, allowing independent modification.
* Without detaching, some manipulations will have no effect; e.g. child/descendant removal will not work.
### Variants
Variants are a system for grouping related component versions along named property axes (e.g. Type, Style), powering a structured swap UI for designers using component instances.
* `VariantContainer` (extends `Board`): The board that physically groups all variant components together.
- check with `isVariantContainer()`
- property `variants: Variants`.
* `Variants`: Defines the combinations of property values for which component variants can exist and manages the concrete component variants.
- `properties: string[]` (ordered list of property names); `addProperty(): void`, `renameProperty(pos, name)`, `currentValues(property)`
- `variantComponents(): LibraryVariantComponent[]`
* `LibraryVariantComponent` (extends `LibraryComponent`): full library component with metadata, for which `isVariant()` returns true.
- `variantProps: { [property: string]: string }` (this component's value for each property)
- `variantError` (non-null if e.g. two variants share the same combination of property values)
- `setVariantProperty(pos, value)`
Properties are often addressed positionally: `pos` parameter in various methods = index in `Variants.properties`.
**Creating a variant group**:
- `penpot.createVariantFromComponents(mainInstances: Board[]): VariantContainer`: Combines several main component instances into a new variant group.
All components end up inside a single new container on the canvas.
The container's `Variants` instance is initialised with one property `Property 1`, with the property values set to the respective component's name.
- After creation, edit properties using `variants.renameProperty(pos, name)`, `variants.addProperty()`, and `comp.setVariantProperty(pos, value)`.
**Adding a variant to an existing group**:
Use `variantContainer.appendChild(mainInstance)` to move a component's main instance into the container, then set its position manually and assign property values via `setVariantProperty`.
**Using Variants**:
- `compInstance.switchVariant(pos, value)`: On a component instance, switches to the nearest variant that has the given value at property position `pos`, keeping all other property values the same.
- To instantiate a specific variant, find the right `LibraryVariantComponent` by checking `variantProps`, then call `.instance()`.
# Design Tokens
Design tokens are reusable design values (colors, dimensions, typography, etc.) for consistent styling.
@@ -274,21 +321,22 @@ Design tokens are reusable design values (colors, dimensions, typography, etc.)
The token library: `penpot.library.local.tokens` (type: `TokenCatalog`)
* `sets: TokenSet[]` - Token collections (order matters for precedence)
* `themes: TokenTheme[]` - Presets that activate specific sets
* `addSet(name: string): TokenSet` - Create new set
* `addSet({name: string}): TokenSet` - Create new set
* `addTheme(group: string, name: string): TokenTheme` - Create new theme
`TokenSet` contains tokens with unique names:
* `active: boolean` - Only active sets affect shapes; use `set.toggleActive()` to change: `if (!set.active) set.toggleActive();`
* `tokens: Token[]` - All tokens in set
* `addToken(type: TokenType, name: string, value: TokenValueString): Token` - Creates a token, adding it to the set.
* `addToken({type: TokenType, name: string, value: TokenValueString}): Token` - Creates a token, adding it to the set.
- `TokenType`: "color" | "dimension" | "spacing" | "typography" | "shadow" | "opacity" | "borderRadius" | "borderWidth" | "fontWeights" | "fontSizes" | "fontFamilies" | "letterSpacing" | "textDecoration" | "textCase"
- `value`: depends on the type of token (inspect `Token` and related types)
- Examples:
const token = set.addToken("color", "color.primary", "#0066FF"); // direct value
const token2 = set.addToken("color", "color.accent", "{color.primary}"); // reference to another token
const token = set.addToken({type: "color", name: "color.primary", value: "#0066FF"}); // direct value
const token2 = set.addToken({type: "color", name: "color.accent", value: "{color.primary}"}); // reference to another token
`Token`:
* `name: string` - Token name (may include group path like "color.base.white")
* `value: string | TokenValueString` - Raw value (may be direct value or reference to another token like "{color.primary}")
`Token`: union type encompassing various token types, with common properties:
* `name: string` - Token name (typically structured, e.g. "color.base.white")
* `value` - Raw value (direct value or reference to another token like "{color.primary}")
* `resolvedValue` - Computed final value (follows references)
* `type: TokenType`
@@ -303,21 +351,21 @@ Applying tokens:
(if properties is undefined, use a default property based on the token type - not usually recommended).
`TokenProperty` is a union type; possible values are:
- "all": applies the token to all properties it can control
- TokenBorderRadiusProps: "r1", "r2", "r3", "r4"
- TokenBorderRadiusProps: "borderRadiusTopLeft", "borderRadiusTopRight", "borderRadiusBottomRight", "borderRadiusBottomLeft"
- TokenShadowProps: "shadow"
- TokenColorProps: "fill", "stroke-color"
- TokenDimensionProps: "x", "y", "stroke-width"
- TokenFontFamiliesProps: "font-families"
- TokenFontSizesProps: "font-size"
- TokenFontWeightProps: "font-weight"
- TokenLetterSpacingProps: "letter-spacing"
- TokenNumberProps: "rotation", "line-height"
- TokenColorProps: "fill", "strokeColor"
- TokenDimensionProps: "x", "y", "strokeWidth"
- TokenFontFamiliesProps: "fontFamilies"
- TokenFontSizesProps: "fontSize"
- TokenFontWeightProps: "fontWeight"
- TokenLetterSpacingProps: "letterSpacing"
- TokenNumberProps: "rotation"
- TokenOpacityProps: "opacity"
- TokenSizingProps: "width", "height", "layout-item-min-w", "layout-item-max-w", "layout-item-min-h", "layout-item-max-h"
- TokenSpacingProps: "row-gap", "column-gap", "p1", "p2", "p3", "p4", "m1", "m2", "m3", "m4"
- TokenBorderWidthProps: "stroke-width"
- TokenTextCaseProps: "text-case"
- TokenTextDecorationProps: "text-decoration"
- TokenSizingProps: "width", "height", "layoutItemMinW", "layoutItemMaxW", "layoutItemMinH", "layoutItemMaxH"
- TokenSpacingProps: "rowGap", "columnGap", "paddingLeft", "paddingTop", "paddingRight", "paddingBottom", "marginLeft", "marginTop", "marginRight", "marginBottom"
- TokenBorderWidthProps: "strokeWidth"
- TokenTextCaseProps: "textCase"
- TokenTextDecorationProps: "textDecoration"
- TokenTypographyProps: "typography"
* `token.applyToShapes(shapes, properties)` - Apply from token
* Application is **asynchronous** (wait for ~100ms to see the effects)
@@ -326,8 +374,27 @@ Applying tokens:
- The actual shape properties that the tokens control will reflect the token's resolved value.
Removing tokens:
Simply set the respective property directly - token binding is automatically removed, e.g.
Simply set the respective property directly - token binding is automatically removed, e.g.
shape.fills = [{ fillColor: "#000000", fillOpacity: 1 }]; // Removes fill token
# Visual Inspection of Designs
For many tasks, it can be critical to visually inspect the design. Remember to use the `export_shape` tool for this purpose!
# Creating and Translating Designs
* When transferring styles from a Penpot design to code, make sure that you strictly adhere to the design.
NEVER make assumptions about missing values and don't get overly creative (e.g. don't pick your own colours and stick to
non-creative defaults such as white/black if you are lacking information).
# Revising Designs
* Before applying design changes, ask: "Would a designer consider this appropriate?"
* When dealing with containment issues, ask: Is the parent too small OR is the child too large?
Container sizes are usually intentional, check content first.
* Check for reasonable font sizes and typefaces
* The use of flex layouts is encouraged for cases where elements are arranged in rows or columns with consistent spacing/positioning.
Consider converting boards to flex layout when appropriate.
--
You have hereby read the 'Penpot High-Level Overview' and need not use a tool to read it again.

View File

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

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.
*
* Handles loading and parsing of YAML configuration files,
* providing type-safe access to configuration values with
* appropriate fallbacks for missing files or values.
*/
export class ConfigurationLoader {
private readonly logger = createLogger("ConfigurationLoader");
private readonly baseDir: string;
private initialInstructions: string;
private readonly initialInstructions: string;
private readonly baseInstructions: string;
/**
* Creates a new configuration loader instance.
@@ -22,6 +19,7 @@ export class ConfigurationLoader {
constructor(baseDir: string) {
this.baseDir = baseDir;
this.initialInstructions = this.loadFileContent(join(this.baseDir, "data", "initial_instructions.md"));
this.baseInstructions = this.loadFileContent(join(this.baseDir, "data", "base_instructions.md"));
}
private loadFileContent(filePath: string): string {
@@ -32,11 +30,22 @@ export class ConfigurationLoader {
}
/**
* Gets the initial instructions for the MCP server.
* Gets the initial instructions for the MCP server corresponding to the
* 'Penpot High-Level Overview'
*
* @returns The initial instructions string, or undefined if not configured
* @returns The initial instructions string
*/
public getInitialInstructions(): string {
return this.initialInstructions;
}
/**
* Gets the base instructions which shall be provided to clients when connecting to
* the MCP server
*
* @returns The initial instructions string
*/
public getBaseInstructions(): string {
return this.baseInstructions;
}
}

View File

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

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