Merge remote-tracking branch 'origin/plugins-beta-test' into develop

This commit is contained in:
alonso.torres 2024-07-26 13:47:12 +02:00
commit 19b2f330dd
24 changed files with 1911 additions and 1040 deletions

View file

@ -428,6 +428,8 @@
[shape text]
(let [content (:content shape)
root-styles (select-keys content root-attrs)
paragraph-style (merge
default-text-attrs
(select-keys (->> content (node-seq is-paragraph-node?) first) text-all-attrs))
@ -447,10 +449,12 @@
:children [(merge {:text pt} text-style)]}))))
new-content
{:type "root"
:children
[{:type "paragraph-set"
:children paragraphs}]}]
(d/patch-object
{:type "root"
:children
[{:type "paragraph-set"
:children paragraphs}]}
root-styles)]
(assoc shape :content new-content)))

View file

@ -48,8 +48,7 @@
#{:flex :grid})
(def flex-direction-types
;;TODO remove reverse-column and reverse-row after script
#{:row :reverse-row :row-reverse :column :reverse-column :column-reverse})
#{:row :row-reverse :column :column-reverse})
(def grid-direction-types
#{:row :column})
@ -58,7 +57,7 @@
#{:simple :multiple})
(def wrap-types
#{:wrap :nowrap :no-wrap}) ;;TODO remove no-wrap after script
#{:wrap :nowrap})
(def padding-type
#{:simple :multiple})

File diff suppressed because it is too large Load diff

View file

@ -479,8 +479,8 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn create-page
[{:keys [file-id]}]
(let [id (uuid/next)]
[{:keys [page-id file-id]}]
(let [id (or page-id (uuid/next))]
(ptk/reify ::create-page
ev/Event
(-data [_]

View file

@ -28,29 +28,33 @@
;; --- Flows
(defn add-flow
[starting-frame]
([starting-frame]
(add-flow nil nil nil starting-frame))
(dm/assert!
"expect uuid"
(uuid? starting-frame))
([flow-id page-id name starting-frame]
(dm/assert!
"expect uuid"
(uuid? starting-frame))
(ptk/reify ::add-flow
ptk/WatchEvent
(watch [it state _]
(let [page (wsh/lookup-page state)
(ptk/reify ::add-flow
ptk/WatchEvent
(watch [it state _]
(let [page (if page-id
(wsh/lookup-page state page-id)
(wsh/lookup-page state))
flows (get-in page [:options :flows] [])
unames (cfh/get-used-names flows)
name (cfh/generate-unique-name unames "Flow 1")
flows (get-in page [:options :flows] [])
unames (cfh/get-used-names flows)
name (or name (cfh/generate-unique-name unames "Flow 1"))
new-flow {:id (uuid/next)
:name name
:starting-frame starting-frame}]
new-flow {:id (or flow-id (uuid/next))
:name name
:starting-frame starting-frame}]
(rx/of (dch/commit-changes
(-> (pcb/empty-changes it)
(pcb/with-page page)
(pcb/update-page-option :flows ctp/add-flow new-flow))))))))
(rx/of (dch/commit-changes
(-> (pcb/empty-changes it)
(pcb/with-page page)
(pcb/update-page-option :flows ctp/add-flow new-flow)))))))))
(defn add-flow-selected-frame
[]
@ -61,16 +65,35 @@
(rx/of (add-flow (first selected)))))))
(defn remove-flow
[flow-id]
([flow-id]
(remove-flow nil flow-id))
([page-id flow-id]
(dm/assert! (uuid? flow-id))
(ptk/reify ::remove-flow
ptk/WatchEvent
(watch [it state _]
(let [page (if page-id
(wsh/lookup-page state page-id)
(wsh/lookup-page state))]
(rx/of (dch/commit-changes
(-> (pcb/empty-changes it)
(pcb/with-page page)
(pcb/update-page-option :flows ctp/remove-flow flow-id)))))))))
(defn update-flow
[page-id flow-id update-fn]
(dm/assert! (uuid? flow-id))
(ptk/reify ::remove-flow
(ptk/reify ::update-flow
ptk/WatchEvent
(watch [it state _]
(let [page (wsh/lookup-page state)]
(let [page (if page-id
(wsh/lookup-page state page-id)
(wsh/lookup-page state))]
(rx/of (dch/commit-changes
(-> (pcb/empty-changes it)
(pcb/with-page page)
(pcb/update-page-option :flows ctp/remove-flow flow-id))))))))
(pcb/update-page-option :flows ctp/update-flow flow-id update-fn))))))))
(defn rename-flow
[flow-id name]
@ -111,6 +134,18 @@
(or (some ctsi/flow-origin? (map :interactions children))
(some #(ctsi/flow-to? % frame-id) (map :interactions (vals objects))))))
(defn add-interaction
[page-id shape-id interaction]
(ptk/reify ::add-interaction
ptk/WatchEvent
(watch [_ state _]
(let [page-id (or page-id (:current-page-id state))]
(rx/of (dwsh/update-shapes
[shape-id]
(fn [shape]
(cls/add-new-interaction shape interaction))
{:page-id page-id}))))))
(defn add-new-interaction
([shape] (add-new-interaction shape nil))
([shape destination]
@ -138,23 +173,29 @@
(rx/of (add-flow (:id frame))))))))))
(defn remove-interaction
[shape index]
(ptk/reify ::remove-interaction
ptk/WatchEvent
(watch [_ _ _]
(rx/of (dwsh/update-shapes [(:id shape)]
(fn [shape]
(update shape :interactions
ctsi/remove-interaction index)))))))
([shape index]
(remove-interaction nil shape index))
([page-id shape index]
(ptk/reify ::remove-interaction
ptk/WatchEvent
(watch [_ _ _]
(rx/of (dwsh/update-shapes [(:id shape)]
(fn [shape]
(update shape :interactions
ctsi/remove-interaction index))
{:page-id page-id}))))))
(defn update-interaction
[shape index update-fn]
(ptk/reify ::update-interaction
ptk/WatchEvent
(watch [_ _ _]
(rx/of (dwsh/update-shapes [(:id shape)]
(fn [shape]
(update shape :interactions
ctsi/update-interaction index update-fn)))))))
([shape index update-fn]
(update-interaction shape index update-fn nil))
([shape index update-fn options]
(ptk/reify ::update-interaction
ptk/WatchEvent
(watch [_ _ _]
(rx/of (dwsh/update-shapes [(:id shape)]
(fn [shape]
(update shape :interactions
ctsi/update-interaction index update-fn))
options))))))
(defn remove-all-interactions-nav-to
"Remove all interactions that navigate to the given frame."

View file

@ -30,7 +30,7 @@
[app.main.ui.hooks.resize :as r]
[app.main.ui.icons :as i]
[app.main.ui.workspace.plugins :as uwp]
[app.plugins :as plugins]
[app.plugins.register :as preg]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
@ -609,7 +609,7 @@
::mf/wrap [mf/memo]}
[{:keys [open-plugins on-close]}]
(when (features/active-feature? @st/state "plugins/runtime")
(let [plugins @plugins/pluginsdb]
(let [plugins (preg/plugins-list)]
[:& dropdown-menu {:show true
:list-class (stl/css-case :sub-menu true :plugins true)
:on-close on-close}

View file

@ -16,7 +16,7 @@
[app.main.ui.components.search-bar :refer [search-bar]]
[app.main.ui.components.title-bar :refer [title-bar]]
[app.main.ui.icons :as i]
[app.plugins :as plugins]
[app.plugins.register :as preg]
[app.util.avatars :as avatars]
[app.util.dom :as dom]
[app.util.http :as http]
@ -80,12 +80,14 @@
::mf/register-as :plugin-management}
[]
(let [plugins-state* (mf/use-state @plugins/pluginsdb)
(let [plugins-state* (mf/use-state #(preg/plugins-list))
plugins-state @plugins-state*
plugin-url* (mf/use-state "")
plugin-url @plugin-url*
fetching-manifest? (mf/use-state false)
input-status* (mf/use-state nil) ;; :error-url :error-manifest :success
input-status @input-status*
@ -106,6 +108,7 @@
(mf/use-callback
(mf/deps plugins-state plugin-url)
(fn []
(reset! fetching-manifest? true)
(->> (http/send! {:method :get
:uri plugin-url
:omit-default-headers true
@ -113,18 +116,20 @@
(rx/map :body)
(rx/subs!
(fn [body]
(let [plugin (plugins/parser-manifest plugin-url body)]
(reset! fetching-manifest? false)
(let [plugin (preg/parse-manifest plugin-url body)]
(st/emit! (ptk/event ::ev/event {::ev/name "install-plugin" :name (:name plugin) :url plugin-url}))
(modal/show!
:plugin-permissions
{:plugin plugin
:on-accept
#(do
(plugins/install-plugin! plugin)
(preg/install-plugin! plugin)
(modal/show! :plugin-management {}))})
(reset! input-status* :success)
(reset! plugin-url* "")))
(fn [_]
(reset! fetching-manifest? false)
(reset! input-status* :error-url))))))
handle-open-plugin
@ -141,12 +146,13 @@
(mf/use-callback
(mf/deps plugins-state)
(fn [plugin-index]
(let [plugin (nth @plugins/pluginsdb plugin-index)]
(let [plugins-list (preg/plugins-list)
plugin (nth plugins-list plugin-index)]
(st/emit! (ptk/event ::ev/event {::ev/name "remove-plugin"
:name (:name plugin)
:host (:host plugin)}))
(plugins/remove-plugin! plugin)
(reset! plugins-state* @plugins/pluginsdb))))]
(preg/remove-plugin! plugin)
(reset! plugins-state* (preg/plugins-list)))))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-dialog :plugin-management)}
@ -161,12 +167,18 @@
:class (stl/css-case :input-error error?)}]
[:button {:class (stl/css :primary-button)
:disabled @fetching-manifest?
:on-click handle-install-click} (tr "workspace.plugins.install")]]
(when error?
[:div {:class (stl/css-case :info true :error error?)}
(tr "workspace.plugins.error.url")])
[:> i18n/tr-html*
{:class (stl/css :discover)
:on-click #(st/emit! (ptk/event ::ev/event {::ev/name "open-plugins-list"}))
:content (tr "workspace.plugins.discover" cf/plugins-list-uri)}]
[:hr]
(if (empty? plugins-state)

View file

@ -213,7 +213,7 @@ div.input-error {
gap: $s-4;
svg {
margin-top: calc(-1 * var($s-2));
margin-top: calc(-1 * $s-2);
width: $s-12;
height: $s-12;
stroke: $da-primary;
@ -262,3 +262,13 @@ div.input-error {
display: flex;
gap: $s-12;
}
.discover {
@include bodySmallTypography;
color: $df-secondary;
margin-top: $s-24;
a {
color: $da-primary;
}
}

View file

@ -7,7 +7,6 @@
(ns app.plugins
"RPC for plugins runtime."
(:require
[app.common.uuid :as uuid]
[app.main.features :as features]
[app.main.store :as st]
[app.plugins.api :as api]
@ -18,10 +17,6 @@
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(def pluginsdb register/pluginsdb)
(def install-plugin! register/install-plugin!)
(def remove-plugin! register/remove-plugin!)
(defn init-plugins-runtime!
[]
(when-let [init-runtime (obj/get global "initPluginsRuntime")]
@ -41,28 +36,3 @@
(rx/tap init-plugins-runtime!)
(rx/ignore)))))
(defn parser-manifest
[plugin-url ^js manifest]
(let [name (obj/get manifest "name")
desc (obj/get manifest "description")
code (obj/get manifest "code")
icon (obj/get manifest "icon")
permissions (into #{} (obj/get manifest "permissions" []))
permissions
(cond-> permissions
(contains? permissions "content:write")
(conj "content:read")
(contains? permissions "library:write")
(conj "content:write"))
origin (obj/get (js/URL. plugin-url) "origin")
plugin-id (str (uuid/next))]
{:plugin-id plugin-id
:name name
:description desc
:host origin
:code code
:icon icon
:permissions (->> permissions (mapv str))}))

View file

@ -19,6 +19,7 @@
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[app.main.data.changes :as ch]
[app.main.data.workspace :as dw]
[app.main.data.workspace.bool :as dwb]
[app.main.data.workspace.colors :as dwc]
[app.main.data.workspace.groups :as dwg]
@ -29,6 +30,7 @@
[app.plugins.file :as file]
[app.plugins.fonts :as fonts]
[app.plugins.format :as format]
[app.plugins.history :as history]
[app.plugins.library :as library]
[app.plugins.page :as page]
[app.plugins.parser :as parser]
@ -61,8 +63,8 @@
(deftype PenpotContext [$plugin]
Object
(addListener
[_ type callback]
(events/add-listener type $plugin callback))
[_ type callback props]
(events/add-listener type $plugin callback props))
(removeListener
[_ listener-id]
@ -347,7 +349,26 @@
(mapcat #(cfh/get-children-with-self objects (:id %))))
shapes)]
(cg/generate-style-code
objects type shapes shapes-with-children {:with-prelude? prelude?}))))))
objects type shapes shapes-with-children {:with-prelude? prelude?})))))
(openViewer
[_]
(let [params {:page-id (:current-page-id @st/state)
:file-id (:current-file-id @st/state)
:section "interactions"}]
(st/emit! (dw/go-to-viewer params))))
(createPage
[_]
(let [file-id (:current-file-id @st/state)
id (uuid/next)]
(st/emit! (dw/create-page {:page-id id :file-id file-id}))
(page/page-proxy $plugin file-id id)))
(openPage
[_ page]
(let [id (obj/get page "$id")]
(st/emit! (dw/go-to-page id)))))
(defn create-context
[plugin-id]
@ -374,4 +395,5 @@
{:name "currentUser" :get #(.getCurrentUser ^js %)}
{:name "activeUsers" :get #(.getActiveUsers ^js %)}
{:name "fonts" :get (fn [_] (fonts/fonts-subcontext plugin-id))}
{:name "library" :get (fn [_] (library/library-subcontext plugin-id))}))
{:name "library" :get (fn [_] (library/library-subcontext plugin-id))}
{:name "history" :get (fn [_] (history/history-subcontext plugin-id))}))

View file

@ -6,15 +6,20 @@
(ns app.plugins.events
(:require
[app.common.data.macros :as dm]
[app.main.data.workspace.state-helpers :as wsh]
[app.main.store :as st]
[app.plugins.file :as file]
[app.plugins.page :as page]
[app.plugins.parser :as parser]
[app.plugins.shape :as shape]
[app.util.object :as obj]
[goog.functions :as gf]))
(defmulti handle-state-change (fn [type _] type))
(defmethod handle-state-change "finish"
[_ _ old-val new-val]
[_ _ old-val new-val _]
(let [old-file-id (:current-file-id old-val)
new-file-id (:current-file-id new-val)]
(if (and (some? old-file-id) (nil? new-file-id))
@ -22,7 +27,7 @@
::not-changed)))
(defmethod handle-state-change "filechange"
[_ plugin-id old-val new-val]
[_ plugin-id old-val new-val _]
(let [old-file-id (:current-file-id old-val)
new-file-id (:current-file-id new-val)]
(if (identical? old-file-id new-file-id)
@ -30,7 +35,7 @@
(file/file-proxy plugin-id new-file-id))))
(defmethod handle-state-change "pagechange"
[_ plugin-id old-val new-val]
[_ plugin-id old-val new-val _]
(let [old-page-id (:current-page-id old-val)
new-page-id (:current-page-id new-val)]
(if (identical? old-page-id new-page-id)
@ -38,7 +43,7 @@
(page/page-proxy plugin-id (:current-file-id new-val) new-page-id))))
(defmethod handle-state-change "selectionchange"
[_ _ old-val new-val]
[_ _ old-val new-val _]
(let [old-selection (get-in old-val [:workspace-local :selected])
new-selection (get-in new-val [:workspace-local :selected])]
(if (identical? old-selection new-selection)
@ -46,7 +51,7 @@
(apply array (map str new-selection)))))
(defmethod handle-state-change "themechange"
[_ _ old-val new-val]
[_ _ old-val new-val _]
(let [old-theme (get-in old-val [:profile :theme])
new-theme (get-in new-val [:profile :theme])]
(if (identical? old-theme new-theme)
@ -55,23 +60,55 @@
"dark"
new-theme))))
(defmethod handle-state-change "shapechange"
[_ plugin-id old-val new-val props]
(let [shape-id (-> (obj/get props "shapeId") parser/parse-id)
old-shape (wsh/lookup-shape old-val shape-id)
new-shape (wsh/lookup-shape new-val shape-id)
file-id (:current-file-id new-val)
page-id (:current-page-id new-val)]
(if (and (identical? old-shape new-shape) (some? plugin-id) (some? file-id) (some? page-id) (some? shape-id))
::not-changed
(shape/shape-proxy plugin-id file-id page-id shape-id))))
(defmethod handle-state-change "contentsave"
[_ _ old-val new-val _]
(let [old-status (dm/get-in old-val [:persistence :status])
new-status (dm/get-in new-val [:persistence :status])]
(if (and (= :saved new-status) (not= new-status old-status))
::void ;; Changed but void
::not-changed)))
(defmethod handle-state-change :default
[_ _ _ _]
::not-changed)
(defn add-listener
[type plugin-id callback]
(let [key (js/Symbol)
callback (gf/debounce callback 10)]
[type plugin-id callback props]
(let [plugin-id (parser/parse-id plugin-id)
key (js/Symbol)
;; We wrap the callback in an exception handler so the plugins
;; don't crash the application
safe-callback
(fn [value]
(try
(if (= ::void value)
(callback)
(callback value))
(catch :default cause
(.error js/console cause))))
;; We also debounce the callbacks so we don't get too many at the same time
debounced-callback (gf/debounce safe-callback 10)]
(add-watch
st/state key
(fn [_ _ old-val new-val]
(let [result (handle-state-change type plugin-id old-val new-val)]
(let [result (handle-state-change type plugin-id old-val new-val props)]
(when (not= ::not-changed result)
(try
(callback result)
(catch :default cause
(.error js/console cause)))))))
(debounced-callback result)))))
;; return the generated key
key))

View file

@ -5,16 +5,23 @@
;; Copyright (c) KALEIDOS INC
(ns app.plugins.file
"RPC for plugins runtime."
(:require
[app.common.data.macros :as dm]
[app.common.record :as crc]
[app.common.uuid :as uuid]
[app.main.data.workspace :as dw]
[app.main.features :as features]
[app.main.store :as st]
[app.main.ui.export :as mue]
[app.main.worker :as uw]
[app.plugins.page :as page]
[app.plugins.parser :as parser]
[app.plugins.register :as r]
[app.plugins.utils :as u]
[app.util.object :as obj]))
[app.util.http :as http]
[app.util.object :as obj]
[beicon.v2.core :as rx]
[promesa.core :as p]))
(deftype FileProxy [$plugin $id]
Object
@ -93,7 +100,58 @@
:else
(let [file (u/proxy->file self)]
(apply array (keys (dm/get-in file [:data :plugin-data (keyword "shared" namespace)])))))))
(apply array (keys (dm/get-in file [:data :plugin-data (keyword "shared" namespace)]))))))
(createPage
[_]
(cond
(not (r/check-permission $plugin "content:write"))
(u/display-not-valid :createPage "Plugin doesn't have 'content:write' permission")
:else
(let [page-id (uuid/next)]
(st/emit! (dw/create-page {:page-id page-id :file-id $id}))
(page/page-proxy $plugin $id page-id))))
(export
[self type export-type]
(let [export-type (or (parser/parse-keyword export-type) :all)]
(cond
(not (contains? #{"penpot" "zip"} type))
(u/display-not-valid :export-type type)
(not (contains? (set mue/export-types) export-type))
(u/display-not-valid :export-exportType export-type)
:else
(let [export-cmd (if (= type "penpot") :export-binary-file :export-standard-file)
file (u/proxy->file self)
features (features/get-team-enabled-features @st/state)
team-id (:current-team-id @st/state)]
(p/create
(fn [resolve reject]
(->> (uw/ask-many!
{:cmd export-cmd
:team-id team-id
:features features
:export-type export-type
:files [file]})
(rx/mapcat #(->> (rx/of %) (rx/delay 1000)))
(rx/mapcat
(fn [msg]
(case (:type msg)
:error
(rx/throw (ex-info "cannot export file" {:type :export-file}))
:progress
(rx/empty)
:finish
(http/send! {:method :get :uri (:uri msg) :mode :no-cors :response-type :blob}))))
(rx/first)
(rx/mapcat (fn [{:keys [body]}] (.arrayBuffer ^js body)))
(rx/map (fn [data] (js/Uint8Array. data)))
(rx/subs! resolve reject)))))))))
(crc/define-properties!
FileProxy

View file

@ -66,6 +66,22 @@
(let [id (obj/get self "$id")]
(st/emit! (dwsl/update-layout #{id} {:layout-flex-dir value}))))))}
{:name "wrap"
:get #(-> % u/proxy->shape :layout-wrap-type d/name)
:set
(fn [self value]
(let [value (keyword value)]
(cond
(not (contains? ctl/wrap-types value))
(u/display-not-valid :wrap value)
(not (r/check-permission plugin-id "content:write"))
(u/display-not-valid :wrap "Plugin doesn't have 'content:write' permission")
:else
(let [id (obj/get self "$id")]
(st/emit! (dwsl/update-layout #{id} {:layout-wrap-type value}))))))}
{:name "alignItems"
:get #(-> % u/proxy->shape :layout-align-items d/name)
:set

View file

@ -10,6 +10,8 @@
[app.common.data.macros :as dm]
[app.util.object :as obj]))
(def shape-proxy nil)
(defn format-id
[id]
(when id (dm/str id)))
@ -422,3 +424,163 @@
[tracks]
(when (some? tracks)
(format-array format-track tracks)))
;; export interface PenpotDissolve {
;; type: 'dissolve';
;; duration: number;
;; easing?: 'linear' | 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out';
;; }
;;
;; export interface PenpotSlide {
;; type: 'slide';
;; way: 'in' | 'out';
;; direction?:
;; | 'right'
;; | 'left'
;; | 'up'
;; | 'down';
;; duration: number;
;; offsetEffect?: boolean;
;; easing?: 'linear' | 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out';
;; }
;;
;; export interface PenpotPush {
;; type: 'push';
;; direction?:
;; | 'right'
;; | 'left'
;; | 'up'
;; | 'down';
;;
;; duration: number;
;; easing?: 'linear' | 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out';
;; }
;;
;; export type PenpotAnimation = PenpotDissolve | PenpotSlide | PenpotPush;
(defn format-animation
[animation]
(when animation
(obj/clear-empty
(case (:animation-type animation)
:dissolve
#js {:type "dissolve"
:duration (:duration animation)
:easing (format-key (:easing animation))}
:slide
#js {:type "slide"
:way (format-key (:way animation))
:direction (format-key (:direction animation))
:duration (:duration animation)
:easing (format-key (:easing animation))
:offsetEffect (:offset-effect animation)}
:push
#js {:type "push"
:direction (format-key (:direction animation))
:duration (:duration animation)
:easing (format-key (:easing animation))}
nil))))
;;export type PenpotAction =
;; | PenpotNavigateTo
;; | PenpotOpenOverlay
;; | PenpotToggleOverlay
;; | PenpotCloseOverlay
;; | PenpotPreviousScreen
;; | PenpotOpenUrl;
;;
;;export interface PenpotNavigateTo {
;; type: 'navigate-to';
;; destination: PenpotFrame;
;; preserveScrollPosition?: boolean;
;; animation: PenpotAnimation;
;;}
;;
;;export interface PenpotOverlayAction {
;; destination: PenpotFrame;
;; relativeTo?: PenpotShape;
;; position?:
;; | 'manual'
;; | 'center'
;; | 'top-left'
;; | 'top-right'
;; | 'top-center'
;; | 'bottom-left'
;; | 'bottom-right'
;; | 'bottom-center';
;; manualPositionLocation?: PenpotPoint;
;; closeWhenClickOutside?: boolean;
;; addBackgroundOverlay?: boolean;
;; animation: PenpotAnimation;
;;}
;;
;;export interface PenpotOpenOverlay extends PenpotOverlayAction {
;; type: 'open-overlay';
;;}
;;
;;export interface PenpotToggleOverlay extends PenpotOverlayAction {
;; type: 'toggle-overlay';
;;}
;;
;;export interface PenpotCloseOverlay {
;; type: 'close-overlay';
;; destination?: PenpotFrame;
;; animation: PenpotAnimation;
;;}
;;
;;export interface PenpotPreviousScreen {
;; type: 'previous-screen';
;;}
;;
;;export interface PenpotOpenUrl {
;; type: 'open-url';
;; url: string;
;;}
(defn format-action
[interaction plugin file-id page-id]
(when interaction
(obj/clear-empty
(case (:action-type interaction)
:navigate
#js {:type "navigate-to"
:destination (when (:destination interaction) (shape-proxy plugin file-id page-id (:destination interaction)))
:preserveScrollPosition (:preserve-scroll interaction false)
:animation (format-animation (:animation interaction))}
:open-overlay
#js {:type "open-overlay"
:destination (when (:destination interaction) (shape-proxy plugin file-id page-id (:destination interaction)))
:relativeTo (when (:relative-to interaction) (shape-proxy plugin file-id page-id (:relative-to interaction)))
:position (format-key (:overlay-pos-type interaction))
:manualPositionLocation (format-point (:overlay-position interaction))
:closeWhenClickOutside (:close-click-outside interaction)
:addBackgroundOverlay (:background-overlay interaction)
:animation (format-animation (:animation interaction))}
:toggle-overlay
#js {:type "toggle-overlay"
:destination (when (:destination interaction) (shape-proxy plugin file-id page-id (:destination interaction)))
:relativeTo (when (:relative-to interaction) (shape-proxy plugin file-id page-id (:relative-to interaction)))
:position (format-key (:overlay-pos-type interaction))
:manualPositionLocation (format-point (:overlay-position interaction))
:closeWhenClickOutside (:close-click-outside interaction)
:addBackgroundOverlay (:background-overlay interaction)
:animation (format-animation (:animation interaction))}
:close-overlay
#js {:type "close-overlay"
:destination (when (:destination interaction) (shape-proxy plugin file-id page-id (:destination interaction)))
:animation (format-animation (:animation interaction))}
:prev-screen
#js {:type "previous-screen"}
:open-url
#js {:type "open-url"
:url (:url interaction)}
nil))))

View file

@ -0,0 +1,52 @@
;; 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.plugins.history
(:require
[app.common.record :as crc]
[app.main.data.workspace.undo :as dwu]
[app.main.store :as st]
[app.plugins.register :as r]
[app.plugins.utils :as u]))
(deftype HistorySubcontext [$plugin]
Object
(undoBlockBegin
[_]
(cond
(not (r/check-permission $plugin "content:write"))
(u/display-not-valid :resize "Plugin doesn't have 'content:write' permission")
:else
(let [id (js/Symbol)]
(st/emit! (dwu/start-undo-transaction id))
id)))
(undoBlockFinish
[_ block-id]
(cond
(not (r/check-permission $plugin "content:write"))
(u/display-not-valid :resize "Plugin doesn't have 'content:write' permission")
(not block-id)
(u/display-not-valid :undoBlockFinish block-id)
:else
(st/emit! (dwu/commit-undo-transaction block-id)))))
(crc/define-properties!
HistorySubcontext
{:name js/Symbol.toStringTag
:get (fn [] (str "HistorySubcontext"))})
(defn history-subcontext? [p]
(instance? HistorySubcontext p))
(defn history-subcontext
[plugin-id]
(HistorySubcontext. plugin-id))

View file

@ -8,11 +8,14 @@
"RPC for plugins runtime."
(:require
[app.common.colors :as cc]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.record :as crc]
[app.common.uuid :as uuid]
[app.main.data.workspace :as dw]
[app.main.data.workspace.interactions :as dwi]
[app.main.store :as st]
[app.plugins.format :as format]
[app.plugins.parser :as parser]
[app.plugins.register :as r]
[app.plugins.shape :as shape]
@ -20,6 +23,49 @@
[app.util.object :as obj]
[cuerdas.core :as str]))
(deftype FlowProxy [$plugin $file $page $id]
Object
(remove [_]
(st/emit! (dwi/remove-flow $page $id))))
(defn flow-proxy? [p]
(instance? FlowProxy p))
(defn flow-proxy
[plugin-id file-id page-id id]
(crc/add-properties!
(FlowProxy. plugin-id file-id page-id id)
{:name "$plugin" :enumerable false :get (constantly plugin-id)}
{:name "$file" :enumerable false :get (constantly file-id)}
{:name "$page" :enumerable false :get (constantly page-id)}
{:name "$id" :enumerable false :get (constantly id)}
{:name "page" :enumerable false :get (fn [_] (u/locate-page file-id page-id))}
{:name "name"
:get #(-> % u/proxy->flow :name)
:set
(fn [_ value]
(cond
(or (not (string? value)) (empty? value))
(u/display-not-valid :name value)
:else
(st/emit! (dwi/update-flow page-id id #(assoc % :name value)))))}
{:name "startingFrame"
:get
(fn [self]
(let [frame (-> self u/proxy->flow :starting-frame)]
(u/locate-shape file-id page-id frame)))
:set
(fn [_ value]
(cond
(not (shape/shape-proxy? value))
(u/display-not-valid :startingFrame value)
:else
(st/emit! (dwi/update-flow page-id id #(assoc % :starting-frame (obj/get value "$id"))))))}))
(deftype PageProxy [$plugin $file $id]
Object
(getShapeById
@ -131,7 +177,39 @@
:else
(let [page (u/proxy->page self)]
(apply array (keys (dm/get-in page [:options :plugin-data (keyword "shared" namespace)])))))))
(apply array (keys (dm/get-in page [:options :plugin-data (keyword "shared" namespace)]))))))
(openPage
[_]
(cond
(not (r/check-permission $plugin "content:read"))
(u/display-not-valid :openPage "Plugin doesn't have 'content:read' permission")
:else
(st/emit! (dw/go-to-page $id))))
(createFlow
[_ name frame]
(cond
(or (not (string? name)) (empty? name))
(u/display-not-valid :createFlow-name name)
(not (shape/shape-proxy? frame))
(u/display-not-valid :createFlow-frame frame)
:else
(let [flow-id (uuid/next)]
(st/emit! (dwi/add-flow flow-id $id name (obj/get frame "$id")))
(flow-proxy $plugin $file $id flow-id))))
(removeFlow
[_ flow]
(cond
(not (flow-proxy? flow))
(u/display-not-valid :removeFlow-flow flow)
:else
(st/emit! (dwi/remove-flow $id (obj/get flow "$id"))))))
(crc/define-properties!
PageProxy
@ -183,4 +261,10 @@
(u/display-not-valid :background "Plugin doesn't have 'content:write' permission")
:else
(st/emit! (dw/change-canvas-color id {:color value}))))}))
(st/emit! (dw/change-canvas-color id {:color value}))))}
{:name "flows"
:get
(fn [self]
(let [flows (d/nilv (-> (u/proxy->page self) :options :flows) [])]
(format/format-array #(flow-proxy plugin-id file-id id (:id %)) flows)))}))

View file

@ -23,6 +23,12 @@
[color]
(if (string? color) (-> color str/lower) color))
(defn parse-point
[^js point]
(when point
{:x (obj/get point "x")
:y (obj/get point "y")}))
;; {
;; name?: string;
;; nameLike?: string;
@ -394,3 +400,164 @@
[^js content]
(when (some? content)
(into [] (map parse-command) content)))
;; export interface PenpotDissolve {
;; type: 'dissolve';
;; duration: number;
;; easing?: 'linear' | 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out';
;; }
;;
;; export interface PenpotSlide {
;; type: 'slide';
;; way: 'in' | 'out';
;; direction?:
;; | 'right'
;; | 'left'
;; | 'up'
;; | 'down';
;; duration: number;
;; offsetEffect?: boolean;
;; easing?: 'linear' | 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out';
;; }
;;
;; export interface PenpotPush {
;; type: 'push';
;; direction?:
;; | 'right'
;; | 'left'
;; | 'up'
;; | 'down';
;;
;; duration: number;
;; easing?: 'linear' | 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out';
;; }
;;
;; export type PenpotAnimation = PenpotDissolve | PenpotSlide | PenpotPush;
(defn parse-animation
[^js animation]
(when animation
(let [animation-type (-> (obj/get animation "type") parse-keyword)]
(d/without-nils
(case animation-type
:dissolve
{:type animation-type
:duration (obj/get animation "duration")
:easing (-> (obj/get animation "easing") parse-keyword)}
:slide
{:type animation-type
:way (-> (obj/get animation "way") parse-keyword)
:direction (-> (obj/get animation "direction") parse-keyword)
:duration (obj/get animation "duration")
:easing (-> (obj/get animation "easing") parse-keyword)
:offset-effect (obj/get animation "offsetEffect")}
:push
{:type animation-type
:direction (-> (obj/get animation "direction") parse-keyword)
:duration (obj/get animation "duration")
:easing (-> (obj/get animation "easing") parse-keyword)}
nil)))))
;;export type PenpotAction =
;; | PenpotNavigateTo
;; | PenpotOpenOverlay
;; | PenpotToggleOverlay
;; | PenpotCloseOverlay
;; | PenpotPreviousScreen
;; | PenpotOpenUrl;
;;
;;export interface PenpotNavigateTo {
;; type: 'navigate-to';
;; destination: PenpotFrame;
;; preserveScrollPosition?: boolean;
;; animation: PenpotAnimation;
;;}
;;
;;export interface PenpotOverlayAction {
;; destination: PenpotFrame;
;; relativeTo?: PenpotShape;
;; position?:
;; | 'manual'
;; | 'center'
;; | 'top-left'
;; | 'top-right'
;; | 'top-center'
;; | 'bottom-left'
;; | 'bottom-right'
;; | 'bottom-center';
;; manualPositionLocation?: PenpotPoint;
;; closeWhenClickOutside?: boolean;
;; addBackgroundOverlay?: boolean;
;; animation: PenpotAnimation;
;;}
;;
;;export interface PenpotOpenOverlay extends PenpotOverlayAction {
;; type: 'open-overlay';
;;}
;;
;;export interface PenpotToggleOverlay extends PenpotOverlayAction {
;; type: 'toggle-overlay';
;;}
;;
;;export interface PenpotCloseOverlay {
;; type: 'close-overlay';
;; destination?: PenpotFrame;
;; animation: PenpotAnimation;
;;}
;;
;;export interface PenpotPreviousScreen {
;; type: 'previous-screen';
;;}
;;
;;export interface PenpotOpenUrl {
;; type: 'open-url';
;; url: string;
;;}
(defn parse-action
[action]
(when action
(let [action-type (-> (obj/get action "type") parse-keyword)]
(d/without-nils
(case action-type
:navigate-to
{:action-type :navigate
:destination (-> (obj/get action "destination") (obj/get "$id"))
:preserve-scroll (obj/get action "preserveScrollPosition")
:animation (-> (obj/get action "animation") parse-animation)}
(:open-overlay
:toggle-overlay)
{:action-type action-type
:destination (-> (obj/get action "destination") (obj/get "$id"))
:relative-to (-> (obj/get action "relativeTo") (obj/get "$id"))
:overlay-pos-type (-> (obj/get action "position") parse-keyword)
:overlay-position (-> (obj/get action "manualPositionLocation") parse-point)
:close-click-outside (obj/get action "closeWhenClickOutside")
:background-overlay (obj/get action "addBackgroundOverlay")
:animation (-> (obj/get action "animation") parse-animation)}
:close-overlay
{:action-type action-type
:destination (-> (obj/get action "destination") (obj/get "$id"))
:animation (-> (obj/get action "animation") parse-animation)}
:previous-screen
{:action-type :prev-screen}
:open-url
{:action-type action-type
:url (obj/get action "url")}
nil)))))
(defn parse-interaction
[^js interaction]
(when interaction
(let [trigger (-> (obj/get interaction "trigger") parse-keyword)
delay (obj/get interaction "trigger")
action (-> (obj/get interaction "action") parse-action)]
(d/without-nils
(d/patch-object {:event-type trigger :delay delay} action)))))

View file

@ -7,49 +7,119 @@
(ns app.plugins.register
"RPC for plugins runtime."
(:require
[app.common.data :as d]))
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.uuid :as uuid]
[app.util.object :as obj]
[app.util.storage :refer [storage]]))
;; TODO: Remove clj->js and parse into a better data structure for accessing the permissions
;; Stores the installed plugins information
(defonce ^:private registry (atom {}))
(def pluginsdb (atom nil))
(defn plugins-list
"Retrieves the plugin data as an ordered list of plugin elements"
[]
(->> (:ids @registry)
(mapv #(dm/get-in @registry [:data %]))))
(defn parse-manifest
"Read the manifest.json defined by the plugins definition and transforms it into an
object that will be stored in the register."
[plugin-url ^js manifest]
(let [name (obj/get manifest "name")
desc (obj/get manifest "description")
code (obj/get manifest "code")
icon (obj/get manifest "icon")
permissions (into #{} (obj/get manifest "permissions" []))
permissions
(cond-> permissions
(contains? permissions "content:write")
(conj "content:read")
(contains? permissions "library:write")
(conj "content:write"))
origin (obj/get (js/URL. plugin-url) "origin")
prev-plugin
(->> (:data @registry)
(vals)
(d/seek (fn [plugin]
(and (= name (:name plugin))
(= origin (:host plugin))))))
plugin-id (d/nilv (:plugin-id prev-plugin) (str (uuid/next)))]
{:plugin-id plugin-id
:name name
:description desc
:host origin
:code code
:icon icon
:permissions (into #{} (map str) permissions)}))
;; FIXME: LEGACY version of the load from store
;; can be removed before deploying plugins to production
;; Needs to be preserved for the beta users
(defn legacy-load-from-store
[]
(let [parse-plugin-data
(fn [^js data]
{:plugin-id (obj/get data "plugin-id")
:name (obj/get data "name")
:description (obj/get data "description")
:host (obj/get data "host")
:code (obj/get data "code")
:icon (obj/get data "icon")
:permissions (into #{} (obj/get data "permissions"))})
ls (.-localStorage js/window)
plugins-val (.getItem ls "plugins")]
(when plugins-val
(let [stored (->> (.parse js/JSON plugins-val)
(map parse-plugin-data))]
(reset! registry
{:ids (->> stored (map :plugin-id))
:data (d/index-by :plugin-id stored)})))))
(defn save-to-store
[]
(swap! storage assoc :plugins @registry))
(defn load-from-store
[]
(let [ls (.-localStorage js/window)
plugins-val (.getItem ls "plugins")]
(when plugins-val
(let [plugins-js (.parse js/JSON plugins-val)]
(js->clj plugins-js {:keywordize-keys true})))))
(defn save-to-store
[plugins]
(let [ls (.-localStorage js/window)
plugins-js (clj->js plugins)
plugins-val (.stringify js/JSON plugins-js)]
(.setItem ls "plugins" plugins-val)))
(if (:plugins @storage)
(reset! registry (:plugins @storage))
(do (legacy-load-from-store)
(save-to-store))))
(defn init
[]
(reset! pluginsdb (load-from-store)))
(load-from-store))
(defn install-plugin!
[plugin]
(let [plugins (vec (conj (seq @pluginsdb) plugin))]
(reset! pluginsdb plugins)
(save-to-store plugins)))
(letfn [(update-ids [ids]
(conj
(->> ids (remove #(= % (:plugin-id plugin))))
(:plugin-id plugin)))]
(swap! registry #(-> %
(update :ids update-ids)
(update :data assoc (:plugin-id plugin) plugin)))
(save-to-store)))
(defn remove-plugin!
[{:keys [plugin-id]}]
(let [plugins
(into []
(keep (fn [plugin]
(when (not= plugin-id (:plugin-id plugin)) plugin)))
@pluginsdb)]
(reset! pluginsdb plugins)
(save-to-store plugins)))
(letfn [(update-ids [ids]
(->> ids
(remove #(= % plugin-id))))]
(swap! registry #(-> %
(update :ids update-ids)
(update :data dissoc plugin-id)))
(save-to-store)))
(defn check-permission
[plugin-id permission]
(or (= plugin-id "TEST")
(let [{:keys [permissions]} (->> @pluginsdb (d/seek #(= (:plugin-id %) plugin-id)))]
(->> permissions (d/seek #(= % permission))))))
(let [{:keys [permissions]} (dm/get-in @registry [:data plugin-id])]
(contains? permissions permission))))

View file

@ -25,6 +25,7 @@
[app.common.types.shape :as cts]
[app.common.types.shape.blur :as ctsb]
[app.common.types.shape.export :as ctse]
[app.common.types.shape.interactions :as ctsi]
[app.common.types.shape.layout :as ctl]
[app.common.types.shape.path :as ctsp]
[app.common.types.shape.radius :as ctsr]
@ -32,6 +33,8 @@
[app.common.uuid :as uuid]
[app.main.data.workspace :as dw]
[app.main.data.workspace.groups :as dwg]
[app.main.data.workspace.interactions :as dwi]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.shape-layout :as dwsl]
[app.main.data.workspace.shapes :as dwsh]
@ -51,6 +54,81 @@
[cuerdas.core :as str]
[promesa.core :as p]))
(declare shape-proxy)
(declare shape-proxy?)
(deftype InteractionProxy [$plugin $file $page $shape $index]
Object
(remove [_]
(st/emit! (dwi/remove-interaction {:id $shape} $index))))
(defn interaction-proxy? [p]
(instance? InteractionProxy p))
(defn interaction-proxy
[plugin-id file-id page-id shape-id index]
(crc/add-properties!
(InteractionProxy. plugin-id file-id page-id shape-id index)
{:name "$plugin" :enumerable false :get (constantly plugin-id)}
{:name "$file" :enumerable false :get (constantly file-id)}
{:name "$page" :enumerable false :get (constantly page-id)}
{:name "$shape" :enumerable false :get (constantly shape-id)}
{:name "$index" :enumerable false :get (constantly index)}
;; Not enumerable so we don't have an infinite loop
{:name "shape" :enumerable false
:get (fn [_] (shape-proxy plugin-id file-id page-id shape-id))}
{:name "trigger"
:get #(-> % u/proxy->interaction :event-type format/format-key)
:set
(fn [_ value]
(let [value (parser/parse-keyword value)]
(cond
(not (contains? ctsi/event-types value))
(u/display-not-valid :trigger value)
:else
(st/emit! (dwi/update-interaction
{:id shape-id}
index
#(assoc % :event-type value)
{:page-id page-id})))))}
{:name "delay"
:get #(-> % u/proxy->interaction :delay)
:set
(fn [_ value]
(cond
(or (not (number? value)) (not (pos? value)))
(u/display-not-valid :delay value)
:else
(st/emit! (dwi/update-interaction
{:id shape-id}
index
#(assoc % :delay value)
{:page-id page-id}))))}
{:name "action"
:get #(-> % u/proxy->interaction (format/format-action plugin-id file-id page-id))
:set
(fn [self value]
(let [params (parser/parse-action value)
interaction
(-> (u/proxy->interaction self)
(d/patch-object params))]
(cond
(not (sm/validate ::ctsi/interaction interaction))
(u/display-not-valid :action interaction)
:else
(st/emit! (dwi/update-interaction
{:id shape-id}
index
#(d/patch-object % params)
{:page-id page-id})))))}))
(def lib-typography-proxy? nil)
(def lib-component-proxy nil)
@ -61,8 +139,6 @@
(dwt/current-paragraph-values {:shape shape :attrs txt/paragraph-attrs})
(dwt/current-text-values {:shape shape :attrs txt/text-node-attrs})))
(declare shape-proxy)
(declare shape-proxy?)
(defn- shadow-defaults
[shadow]
@ -441,6 +517,11 @@
(let [[root component] (u/locate-component objects shape)]
(lib-component-proxy $plugin (:component-file root) (:id component))))))
(detach
[_]
(st/emit! (dwl/detach-component $id)))
;; Export
(export
[self value]
(let [value (parser/parse-export value)]
@ -466,7 +547,31 @@
(rx/mapcat #(rp/cmd! :export {:cmd :get-resource :wait true :id (:id %) :blob? true}))
(rx/mapcat #(.arrayBuffer %))
(rx/map #(js/Uint8Array. %))
(rx/subs! resolve reject)))))))))
(rx/subs! resolve reject))))))))
;; Interactions
(addInteraction
[self interaction]
(let [interaction
(-> ctsi/default-interaction
(d/patch-object (parser/parse-interaction interaction)))]
(cond
(not (sm/validate ::ctsi/interaction interaction))
(u/display-not-valid :addInteraction interaction)
:else
(let [index (-> (u/proxy->shape self) (:interactions []) count)]
(st/emit! (dwi/add-interaction $page $id interaction))
(interaction-proxy $plugin $file $page $id index)))))
(removeInteraction
[_ interaction]
(cond
(not (interaction-proxy? interaction))
(u/display-not-valid :removeInteraction interaction)
:else
(st/emit! (dwi/remove-interaction {:id $id} (obj/get interaction "$index"))))))
(defn shape-proxy? [p]
(instance? ShapeProxy p))
@ -475,6 +580,8 @@
(do (set! flex/shape-proxy? shape-proxy?)
(set! grid/shape-proxy? shape-proxy?))
(set! format/shape-proxy shape-proxy)
(crc/define-properties!
ShapeProxy
{:name js/Symbol.toStringTag
@ -819,6 +926,13 @@
:else
(st/emit! (dw/update-position id {:y value})))))}
{:name "parent"
;; not enumerable so there are no infinite loops
:enumerable false
:get (fn [self]
(let [shape (u/proxy->shape self)
parent-id (:parent-id shape)]
(shape-proxy (obj/get self "$file") (obj/get self "$page") parent-id)))}
{:name "parentX"
:get (fn [self]
(let [shape (u/proxy->shape self)
@ -1024,7 +1138,17 @@
id (obj/get self "$id")
objects (u/locate-objects file-id page-id)]
(when (ctl/grid-layout-immediate-child-id? objects id)
(grid/layout-cell-proxy plugin-id file-id page-id id))))})
(grid/layout-cell-proxy plugin-id file-id page-id id))))}
;; Interactions
{:name "interactions"
:get
(fn [self]
(let [interactions (-> self u/proxy->shape :interactions)]
(format/format-array
#(interaction-proxy plugin-id file-id page-id id %)
(range 0 (count interactions)))))})
(cond-> (or (cfh/frame-shape? data) (cfh/group-shape? data) (cfh/svg-raw-shape? data) (cfh/bool-shape? data))
(crc/add-properties!

View file

@ -113,6 +113,25 @@
(when (and (some? file-id) (some? id))
(locate-library-component file-id id))))
(defn proxy->flow
[proxy]
(let [file-id (obj/get proxy "$file")
page-id (obj/get proxy "$page")
flow-id (obj/get proxy "$id")
page (locate-page file-id page-id)]
(when (some? page)
(d/seek #(= (:id %) flow-id) (-> page :options :flows)))))
(defn proxy->interaction
[proxy]
(let [file-id (obj/get proxy "$file")
page-id (obj/get proxy "$page")
shape-id (obj/get proxy "$shape")
index (obj/get proxy "$index")
shape (locate-shape file-id page-id shape-id)]
(when (some? shape)
(get-in shape [:interactions index]))))
(defn get-data
([self attr]
(-> (obj/get self "_data")

View file

@ -156,10 +156,11 @@
(mf/defc tr-html*
{::mf/props :obj}
[{:keys [content class tag-name]}]
[{:keys [content class tag-name on-click]}]
(let [tag-name (d/nilv tag-name "p")]
[:> tag-name {:dangerouslySetInnerHTML #js {:__html content}
:className class}]))
:className class
:on-click on-click}]))
;; DEPRECATED
(defn use-locale

View file

@ -134,3 +134,4 @@
(catch :default err
(.error js/console err)
nil)))

View file

@ -5286,7 +5286,7 @@ msgid "workspace.plugins.menu.title"
msgstr "Plugins"
msgid "workspace.toolbar.plugins"
msgstr "Plugins"
msgstr "Plugins (%s)"
msgid "workspace.plugins.menu.plugins-manager"
msgstr "Plugins manager"
@ -5314,3 +5314,8 @@ msgstr "Read your libraries and assets."
msgid "workspace.plugins.permissions.library-write"
msgstr "Read and modify your libraries and assets."
#, markdown
msgid "workspace.plugins.discover"
msgstr ""
"Discover [more plugins](%s)"

View file

@ -5366,7 +5366,7 @@ msgid "workspace.plugins.title"
msgstr "Extensiones"
msgid "workspace.toolbar.plugins"
msgstr "Extensiones"
msgstr "Extensiones (%s)"
msgid "workspace.plugins.search-placeholder"
msgstr "Intruduzca URL de la extensión"
@ -5418,3 +5418,8 @@ msgstr "Leer la información de sus bibliotecas y recursos."
msgid "workspace.plugins.permissions.library-write"
msgstr "Leer y modificar la información de sus bibliotecas y recursos."
#, markdown
msgid "workspace.plugins.discover"
msgstr ""
"Descubre [más extensiones](%s)"