mirror of
https://github.com/penpot/penpot.git
synced 2026-10-28 21:40:34 +00:00
✨ Add component management to plugins API (#12303)
This commit is contained in:
parent
24d3e62c62
commit
9f598fca5b
@ -531,7 +531,8 @@
|
||||
(-> (pcb/empty-changes it nil)
|
||||
(cll/generate-duplicate-component library component-id new-component-id))]
|
||||
(rx/of
|
||||
(ptk/data-event :layout/update {:ids [(:id main-instance)]})
|
||||
(ptk/data-event :layout/update {:page-id (:main-instance-page (get-in library [:data :components component-id]))
|
||||
:ids [(:id main-instance)]})
|
||||
(dch/commit-changes changes)))))))
|
||||
|
||||
(defn delete-component
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
[app.common.types.variant :as ctv]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.plugins :as dp]
|
||||
[app.main.data.workspace :as dw]
|
||||
[app.main.data.workspace.libraries :as dwl]
|
||||
[app.main.data.workspace.texts :as dwt]
|
||||
[app.main.data.workspace.variants :as dwv]
|
||||
@ -845,12 +846,81 @@
|
||||
|
||||
:remove
|
||||
(fn []
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
(u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission")
|
||||
(let [component (u/locate-library-component file-id id)]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
(u/not-valid plugin-id :remove "Plugin doesn't have 'library:write' permission")
|
||||
|
||||
:else
|
||||
(st/emit! (dwl/delete-component {:id id}))))
|
||||
(not= file-id (:current-file-id @st/state))
|
||||
(u/not-valid plugin-id :remove "Can only remove components from the current file's library")
|
||||
|
||||
(nil? component)
|
||||
(u/not-valid plugin-id :remove "Component does not exist")
|
||||
|
||||
(not (:deleted component))
|
||||
(st/emit! (-> (dwl/delete-component {:id id})
|
||||
(se/add-event plugin-id))))))
|
||||
|
||||
:annotation
|
||||
{:get (fn [] (:annotation (u/locate-library-component file-id id)))
|
||||
:set
|
||||
(fn [value]
|
||||
(let [component (u/locate-library-component file-id id)]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
(u/not-valid plugin-id :annotation "Plugin doesn't have 'library:write' permission")
|
||||
|
||||
(not= file-id (:current-file-id @st/state))
|
||||
(u/not-valid plugin-id :annotation "Can only edit annotations in the current file's library")
|
||||
|
||||
(nil? component)
|
||||
(u/not-valid plugin-id :annotation "Component does not exist")
|
||||
|
||||
(:deleted component)
|
||||
(u/not-valid plugin-id :annotation "Cannot edit a deleted component")
|
||||
|
||||
(not (or (nil? value) (and (string? value) (<= (count value) 300))))
|
||||
(u/not-valid plugin-id :annotation "Expected null or a string of at most 300 characters")
|
||||
|
||||
:else
|
||||
(st/emit! (-> (dw/update-component-annotation id value)
|
||||
(se/add-event plugin-id))))))}
|
||||
|
||||
:duplicate
|
||||
(fn []
|
||||
(let [component (u/locate-library-component file-id id)]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
(u/not-valid plugin-id :duplicate "Plugin doesn't have 'library:write' permission")
|
||||
|
||||
(not= file-id (:current-file-id @st/state))
|
||||
(u/not-valid plugin-id :duplicate "Can only duplicate components in the current file's library")
|
||||
|
||||
(nil? component)
|
||||
(u/not-valid plugin-id :duplicate "Component does not exist")
|
||||
|
||||
(:deleted component)
|
||||
(u/not-valid plugin-id :duplicate "Cannot duplicate a deleted component")
|
||||
|
||||
:else
|
||||
(let [new-id (uuid/next)]
|
||||
(st/emit! (-> (dwl/duplicate-component file-id id new-id)
|
||||
(se/add-event plugin-id)))
|
||||
(lib-component-proxy plugin-id file-id new-id)))))
|
||||
|
||||
:restore
|
||||
(fn []
|
||||
(let [component (u/locate-library-component file-id id)]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
(u/not-valid plugin-id :restore "Plugin doesn't have 'library:write' permission")
|
||||
|
||||
(nil? component)
|
||||
(u/not-valid plugin-id :restore "Component does not exist")
|
||||
|
||||
(:deleted component)
|
||||
(st/emit! (-> (dwl/restore-component file-id id)
|
||||
(se/add-event plugin-id))))))
|
||||
|
||||
:instance
|
||||
(fn []
|
||||
@ -1080,6 +1150,16 @@
|
||||
(map #(lib-component-proxy plugin-id file-id %)))]
|
||||
(apply array components)))}
|
||||
|
||||
:deletedComponents
|
||||
{:get
|
||||
(fn []
|
||||
(let [components (->> (u/locate-file file-id)
|
||||
:data
|
||||
:components
|
||||
(filter (comp :deleted second))
|
||||
(map #(lib-component-proxy plugin-id file-id (first %))))]
|
||||
(apply array components)))}
|
||||
|
||||
:tokens
|
||||
{:this true
|
||||
:get
|
||||
|
||||
@ -1587,6 +1587,38 @@
|
||||
target-id
|
||||
true)))))
|
||||
|
||||
:updateMain
|
||||
(fn []
|
||||
(let [shape (u/locate-shape file-id page-id id)
|
||||
objects (u/locate-objects file-id page-id)
|
||||
component (when (:component-id shape)
|
||||
(u/locate-library-component (:component-file shape) (:component-id shape)))]
|
||||
(cond
|
||||
(not (r/check-permission plugin-id "content:write"))
|
||||
(u/not-valid plugin-id :updateMain "Plugin doesn't have 'content:write' permission")
|
||||
|
||||
(not (r/check-permission plugin-id "library:write"))
|
||||
(u/not-valid plugin-id :updateMain "Plugin doesn't have 'library:write' permission")
|
||||
|
||||
(not= file-id (:current-file-id @st/state))
|
||||
(u/not-valid plugin-id :updateMain "Can only update a component from a copy in the current file")
|
||||
|
||||
(not (u/page-active? page-id))
|
||||
(u/not-valid plugin-id :updateMain "Cannot modify a page that is not currently active")
|
||||
|
||||
(not (and (ctk/instance-head? shape) (ctk/in-component-copy? shape)))
|
||||
(u/not-valid plugin-id :updateMain "The shape is not a component copy head")
|
||||
|
||||
(u/inside-component-copy? objects shape)
|
||||
(u/not-valid plugin-id :updateMain "Cannot update the main from a shape inside another component copy")
|
||||
|
||||
(or (nil? component) (:deleted component))
|
||||
(u/not-valid plugin-id :updateMain "The main component does not exist or is deleted")
|
||||
|
||||
:else
|
||||
(st/emit! (-> (dwl/update-component-sync id (:component-file shape))
|
||||
(se/add-event plugin-id))))))
|
||||
|
||||
:resetOverrides
|
||||
(fn []
|
||||
(let [shape (u/locate-shape file-id page-id id)]
|
||||
|
||||
@ -6,6 +6,12 @@
|
||||
|
||||
(ns frontend-tests.plugins.library-test
|
||||
(:require
|
||||
[app.common.files.changes-builder :as pcb]
|
||||
[app.common.logic.shapes :as cls]
|
||||
[app.common.test-helpers.components :as cthc]
|
||||
[app.common.test-helpers.files :as cthf]
|
||||
[app.common.test-helpers.ids-map :as thi]
|
||||
[app.common.test-helpers.shapes :as cths]
|
||||
[app.common.types.component :as ctk]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.event :as ev]
|
||||
@ -14,8 +20,10 @@
|
||||
[app.main.data.workspace.variants :as dwv]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.store :as st]
|
||||
[app.main.worker :as mw]
|
||||
[app.plugins.library :as library]
|
||||
[app.plugins.register :as r]
|
||||
[app.plugins.shape :as shape]
|
||||
[app.plugins.text :as text]
|
||||
[app.plugins.utils :as u]
|
||||
[app.util.object :as obj]
|
||||
@ -24,10 +32,163 @@
|
||||
[cuerdas.core :as str]
|
||||
[frontend-tests.helpers.async :as async]
|
||||
[frontend-tests.helpers.mock :as mock]
|
||||
[frontend-tests.helpers.state :as ths]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(def ^:private plugin-id "00000000-0000-0000-0000-000000000000")
|
||||
|
||||
(defn- file-with-component
|
||||
[]
|
||||
(-> (cthf/sample-file :restore-file :page-label :main-page)
|
||||
(cths/add-sample-shape :main :type :frame)
|
||||
(cths/add-sample-shape :child :parent-label :main)
|
||||
(cthc/make-component :restorable :main)
|
||||
(cthc/instantiate-component :restorable :copy)))
|
||||
|
||||
(defn- file-with-deleted-component
|
||||
[]
|
||||
(let [file (file-with-component)
|
||||
page (cthf/current-page file)
|
||||
changes (-> (pcb/empty-changes nil (:id page))
|
||||
(cls/generate-delete-shapes (:data file) page (:objects page) #{(thi/id :main)} {})
|
||||
second)]
|
||||
(cthf/apply-changes file changes)))
|
||||
|
||||
(t/deftest ^:async component-restore-preserves-the-users-view
|
||||
(doseq [scenario [:same-page :other-page :linked-library]]
|
||||
(let [file (cond-> (file-with-deleted-component)
|
||||
(= scenario :other-page) (cthf/add-sample-page :other-page))
|
||||
store (if (= scenario :linked-library)
|
||||
(ths/setup-store (cthf/sample-file :local-file)
|
||||
{:renderer :svg :libraries [file]})
|
||||
(ths/setup-store file {:renderer :svg}))
|
||||
view {:selected #{(thi/id :copy)}
|
||||
:zoom 2
|
||||
:vport {:x 100 :y 200 :width 800 :height 600}
|
||||
:vbox {:x 50 :y 100 :width 400 :height 300}}
|
||||
view-of #(select-keys % [:current-file-id :current-page-id :workspace-local])]
|
||||
(ptk/emit! store #(assoc % :workspace-local view))
|
||||
(ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true))
|
||||
(let [before (view-of @store)]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state store
|
||||
st/emit! (mock/stub (fn [& events] (apply ptk/emit! store events)))
|
||||
mw/ask! (mock/stub (fn [_] (rx/empty)))
|
||||
shape/lib-component-proxy library/lib-component-proxy}
|
||||
(let [copy (shape/shape-proxy plugin-id (:id file) (thi/id :main-page) (thi/id :copy))
|
||||
component (.component copy)]
|
||||
(t/is (true? (get-in @store [:files (:id file) :data :components (thi/id :restorable) :deleted])))
|
||||
(.restore component)
|
||||
(await (async/wait-for #(not (get-in @store [:files (:id file) :data :components (thi/id :restorable) :deleted]))
|
||||
"component restored"))
|
||||
(.restore component)
|
||||
(await (async/settle))
|
||||
(t/is (= (thi/id :main)
|
||||
(get-in @store [:files (:id file) :data :pages-index (thi/id :main-page) :objects (thi/id :main) :id])))
|
||||
(t/is (= (thi/id :child)
|
||||
(get-in @store [:files (:id file) :data :pages-index (thi/id :main-page) :objects (thi/id :child) :id])))
|
||||
(t/is (= before (view-of @store)))
|
||||
(t/is (= [(str (thi/id :restorable))]
|
||||
(mapv #(.-id %) (.-components (library/library-proxy plugin-id (:id file)))))))))))))
|
||||
|
||||
(t/deftest ^:async component-restore-checks-library-write-permission
|
||||
(let [file (file-with-deleted-component)
|
||||
store (ths/setup-store file {:renderer :svg})
|
||||
events (atom [])]
|
||||
(ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true))
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state store
|
||||
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||||
r/check-permission (mock/stub (constantly false))}
|
||||
(let [component (library/lib-component-proxy plugin-id (:id file) (thi/id :restorable))]
|
||||
(t/is (thrown-with-msg? js/Error #"library:write" (.restore component)))
|
||||
(await (async/settle))
|
||||
(t/is (empty? @events))
|
||||
(t/is (true? (get-in @store [:files (:id file) :data :components (thi/id :restorable) :deleted]))))))))
|
||||
|
||||
(t/deftest ^:async component-restore-rejects-a-missing-component
|
||||
(let [file (cthf/sample-file :restore-file)
|
||||
store (ths/setup-store file {:renderer :svg})]
|
||||
(ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true))
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state store}
|
||||
(let [component (library/lib-component-proxy plugin-id (:id file) (uuid/next))]
|
||||
(t/is (thrown-with-msg? js/Error #"Component does not exist" (.restore component))))))))
|
||||
|
||||
(def ^:private component-mutations
|
||||
[["remove" #(.remove %)]
|
||||
["duplicate" #(.duplicate %)]
|
||||
["annotation" #(set! (.-annotation %) "Usage")]])
|
||||
|
||||
(t/deftest ^:async component-mutations-check-library-write-permission
|
||||
(let [file (file-with-component)
|
||||
store (ths/setup-store file {:renderer :svg})
|
||||
before @store
|
||||
events (atom [])]
|
||||
(ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true))
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state store
|
||||
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||||
r/check-permission (mock/stub (constantly false))}
|
||||
(let [component (library/lib-component-proxy plugin-id (:id file) (thi/id :restorable))]
|
||||
(doseq [[operation mutate] component-mutations]
|
||||
(t/testing operation
|
||||
(t/is (thrown-with-msg? js/Error #"library:write" (mutate component)))))
|
||||
(await (async/settle))
|
||||
(t/is (empty? @events))
|
||||
(t/is (= (:files before) (:files @store))))))))
|
||||
|
||||
(t/deftest ^:async component-mutations-reject-linked-libraries
|
||||
(let [library (file-with-component)
|
||||
file (cthf/sample-file :consumer-file)
|
||||
store (ths/setup-store file {:renderer :svg :libraries [library]})
|
||||
before @store
|
||||
events (atom [])]
|
||||
(ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true))
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state store
|
||||
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))}
|
||||
(let [component (library/lib-component-proxy plugin-id (:id library) (thi/id :restorable))]
|
||||
(doseq [[operation mutate] component-mutations]
|
||||
(t/testing operation
|
||||
(t/is (thrown-with-msg? js/Error #"current file" (mutate component)))))
|
||||
(await (async/settle))
|
||||
(t/is (empty? @events))
|
||||
(t/is (= (:files before) (:files @store))))))))
|
||||
|
||||
(t/deftest ^:async component-mutations-reject-missing-components
|
||||
(let [file (cthf/sample-file :restore-file)
|
||||
store (ths/setup-store file {:renderer :svg})]
|
||||
(ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true))
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state store}
|
||||
(let [component (library/lib-component-proxy plugin-id (:id file) (uuid/next))]
|
||||
(doseq [[operation mutate] component-mutations]
|
||||
(t/testing operation
|
||||
(t/is (thrown-with-msg? js/Error #"Component does not exist" (mutate component))))))))))
|
||||
|
||||
(t/deftest ^:async update-main-checks-content-and-library-write-permissions
|
||||
(doseq [denied ["content:write" "library:write"]]
|
||||
(let [file (file-with-component)
|
||||
store (ths/setup-store file {:renderer :svg})
|
||||
events (atom [])]
|
||||
(ptk/emit! store #(assoc-in % [:plugins :flags plugin-id :throw-validation-errors] true))
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{st/state store
|
||||
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
|
||||
r/check-permission (mock/stub (fn [_ permission] (not= permission denied)))}
|
||||
(let [copy (shape/shape-proxy plugin-id (:id file) (thi/id :main-page) (thi/id :copy))]
|
||||
(t/is (thrown-with-msg? js/Error (re-pattern denied) (.updateMain copy)))
|
||||
(await (async/settle))
|
||||
(t/is (empty? @events))))))))
|
||||
|
||||
(t/deftest library-asset-proxies-expose-library-id
|
||||
(let [file-id (random-uuid)
|
||||
id (random-uuid)]
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- **plugin-types:** Added `LibraryComponent.restore()` to restore a deleted component and its main instance without changing the current page, selection, zoom, or viewport. Requires `library:write` permission.
|
||||
- **plugin-types:** Added `Shape.updateMain()`, `LibraryComponent.duplicate()`, writable component `annotation`, and `Library.deletedComponents`. Declared the existing `LibraryComponent.remove()` method.
|
||||
- **plugin-types:** Added `paddingType` (`'simple' | 'multiple'`) to flex and grid layouts and `marginType` (`'simple' | 'multiple'`) to layout children, exposing whether the four padding/margin sides are mirrored or honoured independently.
|
||||
- **plugin-types**: Added `waitForLayoutUpdate` to wait until pending layout updates have finished. It rejects when the optional timeout elapses, defaulting to 30 seconds so a wait never hangs.
|
||||
- **plugin-types**: Added `waitForLayoutUpdate` to the `Shape` interface to wait until the pending layout updates of a shape and its children have finished
|
||||
@ -9,6 +11,7 @@
|
||||
|
||||
### 🩹 Fixes
|
||||
|
||||
- **plugins-runtime:** `LibraryComponent.remove()` now rejects linked-library and missing component handles, and does nothing for an already deleted component.
|
||||
- **plugins-runtime**: An interaction obtained from `Shape.interactions` now keeps addressing that interaction instead of the position it held when the array was read. Removing every interaction of a shape from a single read removes all of them rather than leaving some behind, and writing through a held interaction after an earlier one is removed no longer lands on a different interaction.
|
||||
- **plugins-runtime**: `Shape.removeInteraction()` now rejects an interaction belonging to a different shape with a validation error, instead of removing whichever interaction sat at the same position on the target shape.
|
||||
- **plugins-runtime**: Writing `trigger`, `delay` or `action` on an interaction the shape no longer has now raises a validation error. The write used to be sent to the workspace with no position to apply it at, where it failed out of the plugin's reach: nothing was written and nothing was reported.
|
||||
|
||||
@ -273,6 +273,7 @@
|
||||
"createColor",
|
||||
"createComponent",
|
||||
"createTypography",
|
||||
"deletedComponents",
|
||||
"id",
|
||||
"name",
|
||||
"tokens",
|
||||
@ -287,9 +288,13 @@
|
||||
"opacity"
|
||||
],
|
||||
"LibraryComponent": [
|
||||
"annotation",
|
||||
"duplicate",
|
||||
"instance",
|
||||
"isVariant",
|
||||
"mainInstance",
|
||||
"remove",
|
||||
"restore",
|
||||
"transformInVariant"
|
||||
],
|
||||
"LibraryContext": [
|
||||
@ -454,6 +459,7 @@
|
||||
"swapComponent",
|
||||
"switchVariant",
|
||||
"tokens",
|
||||
"updateMain",
|
||||
"visible",
|
||||
"waitForLayoutUpdate",
|
||||
"width",
|
||||
@ -1124,6 +1130,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"updateMain": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"switchVariant": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
@ -1630,6 +1642,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"updateMain": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"switchVariant": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
@ -2774,6 +2792,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"updateMain": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"switchVariant": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
@ -4022,6 +4046,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"updateMain": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"switchVariant": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
@ -4612,6 +4642,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"updateMain": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"switchVariant": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
@ -4994,6 +5030,12 @@
|
||||
"type": "LibraryComponent",
|
||||
"array": true
|
||||
},
|
||||
"deletedComponents": {
|
||||
"decl": "Library",
|
||||
"kind": "get",
|
||||
"type": "LibraryComponent",
|
||||
"array": true
|
||||
},
|
||||
"tokens": {
|
||||
"decl": "Library",
|
||||
"kind": "get",
|
||||
@ -5154,6 +5196,30 @@
|
||||
}
|
||||
},
|
||||
"LibraryComponent": {
|
||||
"annotation": {
|
||||
"decl": "LibraryComponent",
|
||||
"kind": "getset",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"duplicate": {
|
||||
"decl": "LibraryComponent",
|
||||
"kind": "method",
|
||||
"type": "LibraryComponent",
|
||||
"array": false
|
||||
},
|
||||
"remove": {
|
||||
"decl": "LibraryComponent",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"restore": {
|
||||
"decl": "LibraryComponent",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"instance": {
|
||||
"decl": "LibraryComponent",
|
||||
"kind": "method",
|
||||
@ -5524,6 +5590,30 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"annotation": {
|
||||
"decl": "LibraryComponent",
|
||||
"kind": "getset",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"duplicate": {
|
||||
"decl": "LibraryComponent",
|
||||
"kind": "method",
|
||||
"type": "LibraryComponent",
|
||||
"array": false
|
||||
},
|
||||
"remove": {
|
||||
"decl": "LibraryComponent",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"restore": {
|
||||
"decl": "LibraryComponent",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"instance": {
|
||||
"decl": "LibraryComponent",
|
||||
"kind": "method",
|
||||
@ -6246,6 +6336,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"updateMain": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"switchVariant": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
@ -7088,6 +7184,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"updateMain": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"switchVariant": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
@ -7622,6 +7724,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"updateMain": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"switchVariant": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
@ -8222,6 +8330,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"updateMain": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"switchVariant": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
@ -8794,6 +8908,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"updateMain": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"switchVariant": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
@ -11098,6 +11218,12 @@
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"updateMain": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
"type": null,
|
||||
"array": false
|
||||
},
|
||||
"switchVariant": {
|
||||
"decl": "ShapeBase",
|
||||
"kind": "method",
|
||||
|
||||
@ -199,6 +199,66 @@ describe('Component instances', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('restore restores a deleted component without changing the view', async (ctx) => {
|
||||
const originalPage = ctx.penpot.currentPage;
|
||||
expect(originalPage).not.toBeNull();
|
||||
if (!originalPage) return;
|
||||
|
||||
const comp = makeComponent(ctx);
|
||||
const main = comp.mainInstance() as Board;
|
||||
const mainId = main.id;
|
||||
const childIds = main.children.map((child) => child.id);
|
||||
const inst = comp.instance();
|
||||
ctx.board.appendChild(inst);
|
||||
comp.remove();
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
expect(
|
||||
ctx.penpot.library.local.components.some((c) => c.id === comp.id),
|
||||
).toBe(false);
|
||||
expect(originalPage.getShapeById(mainId)).toBeNull();
|
||||
|
||||
const otherPage = ctx.penpot.createPage();
|
||||
try {
|
||||
await ctx.penpot.openPage(otherPage);
|
||||
const selected = ctx.penpot.createRectangle();
|
||||
(otherPage.root as Board).appendChild(selected);
|
||||
ctx.penpot.selection = [selected];
|
||||
ctx.penpot.viewport.zoom = 2;
|
||||
ctx.penpot.viewport.center = { x: 300, y: 200 };
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
|
||||
const zoom = ctx.penpot.viewport.zoom;
|
||||
const center = ctx.penpot.viewport.center;
|
||||
const deleted = inst.component();
|
||||
expect(deleted).not.toBeNull();
|
||||
if (!deleted) return;
|
||||
|
||||
deleted.restore();
|
||||
await waitFor(() =>
|
||||
ctx.penpot.library.local.components.some((c) => c.id === comp.id),
|
||||
);
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
|
||||
expect(
|
||||
ctx.penpot.library.local.components.some((c) => c.id === comp.id),
|
||||
).toBe(true);
|
||||
expect(originalPage.getShapeById(mainId)?.id).toBe(mainId);
|
||||
expect(
|
||||
(deleted.mainInstance() as Board).children.map((child) => child.id),
|
||||
).toEqual(childIds);
|
||||
expect(ctx.penpot.currentPage?.id).toBe(otherPage.id);
|
||||
expect(ctx.penpot.selection.map((shape) => shape.id)).toEqual([
|
||||
selected.id,
|
||||
]);
|
||||
expect(ctx.penpot.viewport.zoom).toBeCloseTo(zoom);
|
||||
expect(ctx.penpot.viewport.center.x).toBeCloseTo(center.x);
|
||||
expect(ctx.penpot.viewport.center.y).toBeCloseTo(center.y);
|
||||
} finally {
|
||||
await ctx.penpot.openPage(originalPage);
|
||||
otherPage.remove();
|
||||
}
|
||||
});
|
||||
|
||||
test('detach turns an instance into a basic shape', (ctx) => {
|
||||
const inst = instanceOf(ctx);
|
||||
inst.detach();
|
||||
@ -319,3 +379,177 @@ describe('Component instances', () => {
|
||||
expect(inst.isComponentInstance()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component assets', () => {
|
||||
test('duplicate creates an independent component with its annotation', async (ctx) => {
|
||||
const source = makeComponent(ctx);
|
||||
source.annotation = 'Button usage';
|
||||
const original = source.mainInstance() as Board;
|
||||
const duplicate = source.duplicate();
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
const main = duplicate.mainInstance() as Board;
|
||||
ctx.board.appendChild(main);
|
||||
|
||||
expect(duplicate.id).not.toBe(source.id);
|
||||
expect(main.id).not.toBe(original.id);
|
||||
expect(main.isComponentMainInstance()).toBe(true);
|
||||
expect(main.component()?.id).toBe(duplicate.id);
|
||||
expect(main.children).toHaveLength(original.children.length);
|
||||
expect(main.children[0].id).not.toBe(original.children[0].id);
|
||||
expect(duplicate.annotation).toBe('Button usage');
|
||||
duplicate.name = 'Duplicate button';
|
||||
expect(source.name).not.toBe(duplicate.name);
|
||||
});
|
||||
|
||||
test('annotations can be created, edited, and removed', async (ctx) => {
|
||||
const comp = makeComponent(ctx);
|
||||
expect(comp.annotation).toBeNull();
|
||||
comp.annotation = 'Use for primary actions';
|
||||
expect(comp.annotation).toBe('Use for primary actions');
|
||||
comp.annotation = 'Use for secondary actions';
|
||||
expect(comp.annotation).toBe('Use for secondary actions');
|
||||
expect(() => {
|
||||
comp.annotation = 42 as unknown as string;
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
comp.annotation = 'x'.repeat(301);
|
||||
}).toThrow();
|
||||
expect(comp.annotation).toBe('Use for secondary actions');
|
||||
comp.annotation = null;
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
expect(comp.annotation).toBeNull();
|
||||
});
|
||||
|
||||
test('deletedComponents lists and restores a component without surviving copies', async (ctx) => {
|
||||
const comp = makeComponent(ctx);
|
||||
const mainId = comp.mainInstance().id;
|
||||
const page = ctx.penpot.currentPage;
|
||||
comp.remove();
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
expect(page?.getShapeById(mainId)).toBeNull();
|
||||
expect(
|
||||
ctx.penpot.library.local.components.some((c) => c.id === comp.id),
|
||||
).toBe(false);
|
||||
|
||||
const deleted = ctx.penpot.library.local.deletedComponents.find(
|
||||
(c) => c.id === comp.id,
|
||||
);
|
||||
expect(deleted).toBeDefined();
|
||||
if (!deleted) return;
|
||||
deleted.remove();
|
||||
deleted.restore();
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
expect(page?.getShapeById(mainId)?.id).toBe(mainId);
|
||||
expect(
|
||||
ctx.penpot.library.local.components.some((c) => c.id === comp.id),
|
||||
).toBe(true);
|
||||
expect(
|
||||
ctx.penpot.library.local.deletedComponents.some((c) => c.id === comp.id),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('updateMain applies a copy override to the main and other copies', async (ctx) => {
|
||||
const childColor = (board: Board) => {
|
||||
const fill = board.children[0].fills[0];
|
||||
return typeof fill === 'string' ? fill : fill?.fillColor;
|
||||
};
|
||||
const comp = makeComponent(ctx);
|
||||
const main = comp.mainInstance() as Board;
|
||||
const copy = comp.instance() as Board;
|
||||
const other = comp.instance() as Board;
|
||||
ctx.board.appendChild(copy);
|
||||
ctx.board.appendChild(other);
|
||||
copy.children[0].fills = [{ fillColor: '#ff0000', fillOpacity: 1 }];
|
||||
copy.updateMain();
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
await waitFor(() => childColor(main) === '#ff0000');
|
||||
expect(childColor(main)).toBe('#ff0000');
|
||||
expect(childColor(other)).toBe('#ff0000');
|
||||
|
||||
main.children[0].fills = [{ fillColor: '#00ff00', fillOpacity: 1 }];
|
||||
await waitFor(() => childColor(copy) === '#00ff00');
|
||||
expect(childColor(copy)).toBe('#00ff00');
|
||||
});
|
||||
|
||||
test('updateMain rejects plain shapes, main instances, and copy children', (ctx) => {
|
||||
const rect = ctx.penpot.createRectangle();
|
||||
ctx.board.appendChild(rect);
|
||||
expect(() => rect.updateMain()).toThrow();
|
||||
const comp = makeComponent(ctx);
|
||||
expect(() => comp.mainInstance().updateMain()).toThrow();
|
||||
const copy = comp.instance() as Board;
|
||||
ctx.board.appendChild(copy);
|
||||
expect(() => copy.children[0].updateMain()).toThrow();
|
||||
});
|
||||
|
||||
test('deleted components reject duplication, annotation edits, and updateMain', async (ctx) => {
|
||||
const comp = makeComponent(ctx);
|
||||
const copy = comp.instance();
|
||||
ctx.board.appendChild(copy);
|
||||
comp.remove();
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
expect(() => comp.duplicate()).toThrow('deleted component');
|
||||
expect(() => {
|
||||
comp.annotation = 'Usage';
|
||||
}).toThrow('deleted component');
|
||||
expect(() => copy.updateMain()).toThrow('main component');
|
||||
});
|
||||
|
||||
test('updateMain rejects a copy on an inactive page and inside another copy', async (ctx) => {
|
||||
const main = nestedComponentMain(ctx);
|
||||
const outerCopy = main.component()?.instance() as Board;
|
||||
ctx.board.appendChild(outerCopy);
|
||||
expect(() => outerCopy.children[0].updateMain()).toThrow(
|
||||
'inside another component copy',
|
||||
);
|
||||
|
||||
const original = ctx.penpot.currentPage;
|
||||
expect(original).not.toBeNull();
|
||||
if (!original) return;
|
||||
const page = ctx.penpot.createPage();
|
||||
try {
|
||||
await ctx.penpot.openPage(page);
|
||||
expect(() => outerCopy.updateMain()).toThrow('not currently active');
|
||||
} finally {
|
||||
await ctx.penpot.openPage(original);
|
||||
page.remove();
|
||||
}
|
||||
});
|
||||
|
||||
test('component assets can be edited without activating their main instance page', async (ctx) => {
|
||||
const original = ctx.penpot.currentPage;
|
||||
expect(original).not.toBeNull();
|
||||
if (!original) return;
|
||||
const comp = makeComponent(ctx);
|
||||
const mainId = comp.mainInstance().id;
|
||||
const page = ctx.penpot.createPage();
|
||||
try {
|
||||
await ctx.penpot.openPage(page);
|
||||
const selected = ctx.penpot.createRectangle();
|
||||
(page.root as Board).appendChild(selected);
|
||||
ctx.penpot.selection = [selected];
|
||||
const zoom = ctx.penpot.viewport.zoom;
|
||||
const center = ctx.penpot.viewport.center;
|
||||
|
||||
comp.annotation = 'Edited from another page';
|
||||
const duplicate = comp.duplicate();
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
expect(original.getShapeById(duplicate.mainInstance().id)).not.toBeNull();
|
||||
expect(comp.annotation).toBe('Edited from another page');
|
||||
duplicate.remove();
|
||||
comp.remove();
|
||||
await ctx.penpot.waitForLayoutUpdate();
|
||||
expect(original.getShapeById(mainId)).toBeNull();
|
||||
expect(ctx.penpot.currentPage?.id).toBe(page.id);
|
||||
expect(ctx.penpot.selection.map((shape) => shape.id)).toEqual([
|
||||
selected.id,
|
||||
]);
|
||||
expect(ctx.penpot.viewport.zoom).toBeCloseTo(zoom);
|
||||
expect(ctx.penpot.viewport.center.x).toBeCloseTo(center.x);
|
||||
expect(ctx.penpot.viewport.center.y).toBeCloseTo(center.y);
|
||||
} finally {
|
||||
await ctx.penpot.openPage(original);
|
||||
page.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
48
plugins/libs/plugin-types/index.d.ts
vendored
48
plugins/libs/plugin-types/index.d.ts
vendored
@ -2668,6 +2668,12 @@ export interface Library extends PluginData {
|
||||
*/
|
||||
readonly components: LibraryComponent[];
|
||||
|
||||
/**
|
||||
* Deleted components, including individual deleted variants.
|
||||
* Use a component's `restore()` method to restore it.
|
||||
*/
|
||||
readonly deletedComponents: LibraryComponent[];
|
||||
|
||||
/**
|
||||
* A catalog of Design Tokens in the library.
|
||||
*
|
||||
@ -2763,6 +2769,39 @@ export interface LibraryColor extends LibraryElement {
|
||||
* This interface extends `LibraryElement` and includes properties specific to component elements.
|
||||
*/
|
||||
export interface LibraryComponent extends LibraryElement {
|
||||
/**
|
||||
* The component annotation, or null when none is set.
|
||||
* Set null to delete it. Text can contain at most 300 characters.
|
||||
* Writing requires `library:write` permission and a live component in the
|
||||
* current file's library.
|
||||
*/
|
||||
annotation: string | null;
|
||||
|
||||
/**
|
||||
* Duplicates this component and its main instance in the current file's library.
|
||||
* Requires `library:write` permission. The component must not be deleted.
|
||||
*/
|
||||
duplicate(): LibraryComponent;
|
||||
|
||||
/**
|
||||
* Deletes this component and its main instance from the current file's library.
|
||||
* Requires `library:write` permission. Does nothing if already deleted.
|
||||
* Surviving copies keep their link so the component can be restored later.
|
||||
*/
|
||||
remove(): void;
|
||||
|
||||
/**
|
||||
* Restores this component if it has been deleted, including its main instance.
|
||||
* Leaves the current page, selection, zoom, and viewport unchanged.
|
||||
* Requires `library:write` permission. Does nothing if already restored.
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* penpot.selection[0].component()?.restore();
|
||||
* ```
|
||||
*/
|
||||
restore(): void;
|
||||
|
||||
/**
|
||||
* Creates an instance of the component.
|
||||
* @return Returns a `Shape` object representing the instance of the component.
|
||||
@ -3980,6 +4019,15 @@ export interface ShapeBase extends PluginData {
|
||||
*/
|
||||
resetOverrides(): void;
|
||||
|
||||
/**
|
||||
* Applies this copy's overrides to its main component and synchronizes copies.
|
||||
* Similar to the editor's "update main component" action.
|
||||
* Requires `content:write` and `library:write` permissions.
|
||||
* The shape must be a component copy head on the active page of the current file,
|
||||
* outside any other component copy, with a main component that is not deleted.
|
||||
*/
|
||||
updateMain(): void;
|
||||
|
||||
/**
|
||||
* Switch a VariantComponent copy to the nearest one that has the specified property value
|
||||
* @param pos The position of the property to update
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user