✨ Add component management to plugins API (#12303)

This commit is contained in:
Alonso Torres 2026-10-14 15:26:24 +02:00 committed by GitHub
parent 24d3e62c62
commit 9f598fca5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 691 additions and 6 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",

View File

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

View File

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