0
Fork 0
mirror of https://github.com/penpot/penpot-plugins.git synced 2025-01-06 14:50:21 -05:00

feat(runtime): refactor plugin state

This commit is contained in:
Juanfran 2024-08-21 13:46:44 +02:00
parent 1371af998c
commit 16595c2c48
12 changed files with 1036 additions and 653 deletions

View file

@ -1,5 +1,4 @@
import type {
PenpotContext,
Penpot,
EventsMap,
PenpotPage,
@ -25,14 +24,10 @@ import type {
PenpotHistoryContext,
} from '@penpot/plugin-types';
import { Manifest, Permissions } from '../models/manifest.model.js';
import { Permissions } from '../models/manifest.model.js';
import { OpenUIOptions } from '../models/open-ui-options.model.js';
import openUIApi from './openUI.api.js';
import { z } from 'zod';
import type { PluginModalElement } from '../modal/plugin-modal.js';
import { getValidUrl } from '../parse-manifest.js';
type Callback<T> = (message: T) => void;
import { createPluginManager } from '../plugin-manager.js';
export const validEvents = [
'finish',
@ -44,64 +39,11 @@ export const validEvents = [
'contentsave',
] as const;
export let uiMessagesCallbacks: Callback<unknown>[] = [];
let modals = new Set<PluginModalElement>([]);
window.addEventListener('message', (event) => {
try {
for (const callback of uiMessagesCallbacks) {
callback(event.data);
}
} catch (err) {
console.error(err);
}
});
export function themeChange(theme: PenpotTheme) {
modals.forEach((modal) => {
modal.setTheme(theme);
});
}
export function createApi(
context: PenpotContext,
manifest: Manifest,
closed: () => void,
load: () => void
plugin: Awaited<ReturnType<typeof createPluginManager>>
) {
let modal: PluginModalElement | null = null;
// TODO: Remove when deprecating method `off`
let listeners: { [key: string]: Map<object, symbol> } = {};
const removeAllEventListeners = () => {
Object.entries(listeners).forEach(([, map]) => {
map.forEach((id) => {
context.removeListener(id);
});
});
uiMessagesCallbacks = [];
};
const closePlugin = () => {
removeAllEventListeners();
if (modal) {
modals.delete(modal);
modal.removeEventListener('close', closePlugin);
modal.remove();
}
modal = null;
closed();
};
const checkPermission = (permission: Permissions) => {
if (!manifest.permissions.includes(permission)) {
if (!plugin.manifest.permissions.includes(permission)) {
throw new Error(`Permission ${permission} is not granted`);
}
};
@ -109,25 +51,7 @@ export function createApi(
const penpot: Penpot = {
ui: {
open: (name: string, url: string, options?: OpenUIOptions) => {
const theme = context.getTheme() as 'light' | 'dark';
const modalUrl = getValidUrl(manifest.host, url);
if (modal?.getAttribute('iframe-src') === modalUrl) {
return;
}
modal = openUIApi(name, modalUrl, theme, options);
modal.setTheme(theme);
modal.addEventListener('close', closePlugin, {
once: true,
});
modal.addEventListener('load', load);
modals.add(modal);
plugin.openModal(name, url, options);
},
sendMessage(message: unknown) {
@ -135,12 +59,13 @@ export function createApi(
detail: message,
});
modal?.dispatchEvent(event);
plugin.getModal()?.dispatchEvent(event);
},
onMessage: <T>(callback: (message: T) => void) => {
z.function().parse(callback);
uiMessagesCallbacks.push(callback as Callback<unknown>);
plugin.registerMessageCallback(callback as (message: unknown) => void);
},
},
@ -181,8 +106,9 @@ export function createApi(
},
},
closePlugin,
closePlugin: () => {
plugin.close();
},
on<T extends keyof EventsMap>(
type: T,
callback: (event: EventsMap[T]) => void,
@ -195,107 +121,91 @@ export function createApi(
// To suscribe to events needs the read permission
checkPermission('content:read');
const id = context.addListener(type, callback, props);
if (!listeners[type]) {
listeners[type] = new Map<object, symbol>();
}
listeners[type].set(callback, id);
return id;
return plugin.registerListener(type, callback, props);
},
off<T extends keyof EventsMap>(
idtype: symbol | T,
callback?: (event: EventsMap[T]) => void
): void {
let listenerId: symbol | undefined;
if (typeof idtype === 'symbol') {
listenerId = idtype;
} else if (callback) {
listenerId = listeners[idtype as T].get(callback);
}
if (listenerId) {
context.removeListener(listenerId);
}
plugin.destroyListener(idtype, callback);
},
// Penpot State API
get root(): PenpotShape {
checkPermission('content:read');
return context.root;
return plugin.context.root;
},
get currentPage(): PenpotPage {
checkPermission('content:read');
return context.currentPage;
return plugin.context.currentPage;
},
get selection(): PenpotShape[] {
checkPermission('content:read');
return context.selection;
return plugin.context.selection;
},
set selection(value: PenpotShape[]) {
checkPermission('content:read');
context.selection = value;
plugin.context.selection = value;
},
get viewport(): PenpotViewport {
return context.viewport;
return plugin.context.viewport;
},
get history(): PenpotHistoryContext {
return context.history;
return plugin.context.history;
},
get library(): PenpotLibraryContext {
checkPermission('library:read');
return context.library;
return plugin.context.library;
},
get fonts(): PenpotFontsContext {
checkPermission('content:read');
return context.fonts;
return plugin.context.fonts;
},
get currentUser(): PenpotUser {
checkPermission('user:read');
return context.currentUser;
return plugin.context.currentUser;
},
get activeUsers(): PenpotActiveUser[] {
checkPermission('user:read');
return context.activeUsers;
return plugin.context.activeUsers;
},
getFile(): PenpotFile | null {
checkPermission('content:read');
return context.getFile();
return plugin.context.getFile();
},
getPage(): PenpotPage | null {
checkPermission('content:read');
return context.getPage();
return plugin.context.getPage();
},
getSelected(): string[] {
checkPermission('content:read');
return context.getSelected();
return plugin.context.getSelected();
},
getSelectedShapes(): PenpotShape[] {
checkPermission('content:read');
return context.getSelectedShapes();
return plugin.context.getSelectedShapes();
},
shapesColors(
shapes: PenpotShape[]
): (PenpotColor & PenpotColorShapeInfo)[] {
checkPermission('content:read');
return context.shapesColors(shapes);
return plugin.context.shapesColors(shapes);
},
replaceColor(
@ -304,36 +214,36 @@ export function createApi(
newColor: PenpotColor
) {
checkPermission('content:write');
return context.replaceColor(shapes, oldColor, newColor);
return plugin.context.replaceColor(shapes, oldColor, newColor);
},
getTheme(): PenpotTheme {
return context.getTheme();
return plugin.context.getTheme();
},
createFrame(): PenpotFrame {
checkPermission('content:write');
return context.createFrame();
return plugin.context.createFrame();
},
createRectangle(): PenpotRectangle {
checkPermission('content:write');
return context.createRectangle();
return plugin.context.createRectangle();
},
createEllipse(): PenpotEllipse {
checkPermission('content:write');
return context.createEllipse();
return plugin.context.createEllipse();
},
createText(text: string): PenpotText | null {
checkPermission('content:write');
return context.createText(text);
return plugin.context.createText(text);
},
createPath(): PenpotPath {
checkPermission('content:write');
return context.createPath();
return plugin.context.createPath();
},
createBoolean(
@ -341,32 +251,32 @@ export function createApi(
shapes: PenpotShape[]
): PenpotBool | null {
checkPermission('content:write');
return context.createBoolean(boolType, shapes);
return plugin.context.createBoolean(boolType, shapes);
},
createShapeFromSvg(svgString: string): PenpotGroup | null {
checkPermission('content:write');
return context.createShapeFromSvg(svgString);
return plugin.context.createShapeFromSvg(svgString);
},
group(shapes: PenpotShape[]): PenpotGroup | null {
checkPermission('content:write');
return context.group(shapes);
return plugin.context.group(shapes);
},
ungroup(group: PenpotGroup, ...other: PenpotGroup[]): void {
checkPermission('content:write');
context.ungroup(group, ...other);
plugin.context.ungroup(group, ...other);
},
uploadMediaUrl(name: string, url: string) {
checkPermission('content:write');
return context.uploadMediaUrl(name, url);
return plugin.context.uploadMediaUrl(name, url);
},
uploadMediaData(name: string, data: Uint8Array, mimeType: string) {
checkPermission('content:write');
return context.uploadMediaData(name, data, mimeType);
return plugin.context.uploadMediaData(name, data, mimeType);
},
generateMarkup(
@ -374,7 +284,7 @@ export function createApi(
options?: { type?: 'html' | 'svg' }
): string {
checkPermission('content:read');
return context.generateMarkup(shapes, options);
return plugin.context.generateMarkup(shapes, options);
},
generateStyle(
@ -386,27 +296,26 @@ export function createApi(
}
): string {
checkPermission('content:read');
return context.generateStyle(shapes, options);
return plugin.context.generateStyle(shapes, options);
},
openViewer(): void {
checkPermission('content:read');
context.openViewer();
plugin.context.openViewer();
},
createPage(): PenpotPage {
checkPermission('content:write');
return context.createPage();
return plugin.context.createPage();
},
openPage(page: PenpotPage): void {
checkPermission('content:read');
context.openPage(page);
plugin.context.openPage(page);
},
};
return {
penpot,
removeAllEventListeners,
};
}

View file

@ -2,7 +2,7 @@ import { z } from 'zod';
import { openUISchema } from '../models/open-ui-options.schema.js';
import { createModal } from '../create-modal.js';
export default z
export const openUIApi = z
.function()
.args(
z.string(),

View file

@ -1,59 +1,13 @@
import { expect, describe, vi } from 'vitest';
import { createApi, themeChange, uiMessagesCallbacks } from './index.js';
import openUIApi from './openUI.api.js';
import { createApi } from './index.js';
import type { PenpotFile } from '@penpot/plugin-types';
const mockUrl = 'http://fake.fake/';
vi.mock('./openUI.api', () => {
return {
default: vi.fn().mockImplementation(() => ({
addEventListener: vi
.fn()
.mockImplementation((type: string, fn: () => void) => {
if (type === 'load') {
fn();
}
}),
dispatchEvent: vi.fn(),
removeEventListener: vi.fn(),
remove: vi.fn(),
setAttribute: vi.fn(),
setTheme: vi.fn(),
getAttribute: () => {
return mockUrl;
},
})),
};
});
vi.hoisted(() => {
const addEventListenerMock = vi.fn();
window.addEventListener = addEventListenerMock;
});
describe('Plugin api', () => {
const mockContext = {
getFile: vi.fn(),
getPage: vi.fn(),
getSelected: vi.fn(),
getSelectedShapes: vi.fn(),
getTheme: vi.fn(() => 'dark'),
addListener: vi.fn().mockReturnValueOnce(Symbol()),
removeListener: vi.fn(),
};
let api: ReturnType<typeof createApi>;
const addEventListenerMock = vi.mocked(window.addEventListener);
const messageEvent = addEventListenerMock.mock.calls[0][1] as EventListener;
const closedCallback = vi.fn();
const loadCallback = vi.fn();
beforeEach(() => {
api = createApi(
mockContext as any,
{
function generateMockPluginManager() {
return {
manifest: {
pluginId: 'test',
name: 'test',
code: '',
@ -66,9 +20,31 @@ describe('Plugin api', () => {
'user:read',
],
},
closedCallback,
loadCallback
);
openModal: vi.fn(),
getModal: vi.fn(),
registerMessageCallback: vi.fn(),
close: vi.fn(),
registerListener: vi.fn(),
destroyListener: vi.fn(),
context: {
getFile: vi.fn(),
getPage: vi.fn(),
getSelected: vi.fn(),
getSelectedShapes: vi.fn(),
getTheme: vi.fn(() => 'dark'),
addListener: vi.fn().mockReturnValueOnce(Symbol()),
removeListener: vi.fn(),
},
};
}
let api: ReturnType<typeof createApi>;
let pluginManager: ReturnType<typeof generateMockPluginManager>;
beforeEach(() => {
pluginManager = generateMockPluginManager();
api = createApi(pluginManager as any);
});
afterEach(() => {
@ -76,239 +52,78 @@ describe('Plugin api', () => {
});
describe('ui', () => {
it('open', () => {
const name = 'test';
const url = 'http://fake.com/';
const options = { width: 100, height: 100 };
const openUIApiMock = vi.mocked(openUIApi);
describe.concurrent('permissions', () => {
const api = createApi({
...pluginManager,
permissions: [],
} as any);
api.penpot.ui.open(name, url, options);
it('on', () => {
const callback = vi.fn();
const modalMock = openUIApiMock.mock.results[0].value;
expect(() => {
api.penpot.on('filechange', callback);
}).toThrow();
expect(openUIApiMock).toHaveBeenCalledWith(name, url, 'dark', options);
expect(modalMock.addEventListener).toHaveBeenCalledWith(
'close',
expect.any(Function),
{ once: true }
);
expect(() => {
api.penpot.on('pagechange', callback);
}).toThrow();
expect(modalMock.addEventListener).toHaveBeenCalledWith(
'load',
expect.any(Function)
);
expect(loadCallback).toHaveBeenCalled();
});
it('sendMessage', () => {
const name = 'test';
const url = 'http://fake.com/';
const options = { width: 100, height: 100 };
const message = { test: 'test' };
const openUIApiMock = vi.mocked(openUIApi);
api.penpot.ui.open(name, url, options);
api.penpot.ui.sendMessage(message);
const modalMock = openUIApiMock.mock.results[0].value;
const eventPassedToDispatchEvent =
modalMock.dispatchEvent.mock.calls[0][0];
expect(modalMock.dispatchEvent).toHaveBeenCalled();
expect(eventPassedToDispatchEvent.type).toBe('message');
expect(eventPassedToDispatchEvent.detail).toBe(message);
});
it('onMessage', () => {
const message = new MessageEvent('message', {
data: {
test: 'test',
},
expect(() => {
api.penpot.on('selectionchange', callback);
}).toThrow();
});
const callback = vi.fn();
it('get states', () => {
expect(() => {
api.penpot.getFile();
}).toThrow();
api.penpot.ui.onMessage(callback);
messageEvent(message);
expect(() => {
api.penpot.getPage();
}).toThrow();
expect(uiMessagesCallbacks.length).toEqual(1);
expect(callback).toHaveBeenCalledWith(message.data);
});
});
describe('events', () => {
it('invalid event', () => {
expect(() => {
api.penpot.on('invalid' as any, vi.fn());
}).toThrow();
expect(() => {
api.penpot.getSelected();
}).toThrow();
});
});
it('pagechange', () => {
const callback = vi.fn();
const id = api.penpot.on('pagechange', callback);
expect(mockContext.addListener).toHaveBeenCalled();
expect((mockContext.addListener.mock as any).lastCall[0]).toBe(
'pagechange'
);
expect((mockContext.addListener.mock as any).lastCall[1]).toBe(callback);
api.penpot.off(id);
expect(mockContext.removeListener).toHaveBeenCalled();
expect((mockContext.removeListener.mock as any).lastCall[0]).toBe(id);
});
it('remove event on close plugin', () => {
const callback = vi.fn();
api.penpot.on('pagechange', callback);
api.penpot.closePlugin();
expect(mockContext.removeListener).toHaveBeenCalled();
});
});
describe.concurrent('permissions', () => {
const api = createApi(
{} as any,
{
it('get file state', () => {
const examplePage = {
name: 'test',
code: '',
permissions: [],
} as any,
() => {},
() => {}
);
id: '123',
};
it('on', () => {
const callback = vi.fn();
pluginManager.context.getPage.mockImplementation(() => examplePage);
expect(() => {
api.penpot.on('filechange', callback);
}).toThrow();
const pageState = api.penpot.getPage();
expect(() => {
api.penpot.on('pagechange', callback);
}).toThrow();
expect(() => {
api.penpot.on('selectionchange', callback);
}).toThrow();
expect(pageState).toEqual(examplePage);
});
it('get states', () => {
expect(() => {
api.penpot.getFile();
}).toThrow();
it('get page state', () => {
const exampleFile = {
name: 'test',
id: '123',
revn: 0,
} as PenpotFile;
expect(() => {
api.penpot.getPage();
}).toThrow();
pluginManager.context.getFile.mockImplementation(() => exampleFile);
expect(() => {
api.penpot.getSelected();
}).toThrow();
const fileState = api.penpot.getFile();
expect(fileState).toEqual(exampleFile);
});
});
it('get file state', () => {
const examplePage = {
name: 'test',
id: '123',
};
it('get selection', () => {
const selection = ['123'];
mockContext.getPage.mockImplementation(() => examplePage);
pluginManager.context.getSelected.mockImplementation(() => selection);
const pageState = api.penpot.getPage();
const currentSelection = api.penpot.getSelected();
expect(pageState).toEqual(examplePage);
});
it('get page state', () => {
const exampleFile = {
name: 'test',
id: '123',
revn: 0,
} as PenpotFile;
mockContext.getFile.mockImplementation(() => exampleFile);
const fileState = api.penpot.getFile();
expect(fileState).toEqual(exampleFile);
});
it('get selection', () => {
const selection = ['123'];
mockContext.getSelected.mockImplementation(() => selection);
const currentSelection = api.penpot.getSelected();
expect(currentSelection).toEqual(selection);
});
it('set theme refresh modal theme', () => {
const name = 'test';
const url = mockUrl;
const options = { width: 100, height: 100 };
const openUIApiMock = vi.mocked(openUIApi);
mockContext.getTheme.mockImplementation(() => 'light');
api.penpot.ui.open(name, url, options);
const modalMock = openUIApiMock.mock.results[0].value;
expect(modalMock.setTheme).toHaveBeenCalledWith('light');
expect(api.penpot.getTheme()).toBe('light');
themeChange('dark');
expect(modalMock.setTheme).toHaveBeenCalledWith('dark');
});
it('should not open twice the same url', () => {
const name = 'test';
const url = mockUrl;
const options = { width: 100, height: 100 };
const openUIApiMock = vi.mocked(openUIApi);
api.penpot.ui.open(name, url, options);
api.penpot.ui.open(name, url, options);
expect(openUIApiMock).toHaveBeenCalledTimes(1);
});
it('open twice diffennt url', () => {
const name = 'test';
const url = mockUrl + '1';
const options = { width: 100, height: 100 };
const openUIApiMock = vi.mocked(openUIApi);
api.penpot.ui.open(name, url, options);
api.penpot.ui.open(name, url, options);
expect(openUIApiMock).toHaveBeenCalledTimes(2);
});
it('close plugin', () => {
const name = 'test';
const url = mockUrl;
const options = { width: 100, height: 100 };
const openUIApiMock = vi.mocked(openUIApi);
const callback = vi.fn();
api.penpot.ui.open(name, url, options);
api.penpot.ui.onMessage(callback);
api.penpot.closePlugin();
const modalMock = openUIApiMock.mock.results[0].value;
expect(closedCallback).toHaveBeenCalled();
expect(modalMock.remove).toHaveBeenCalled();
expect(modalMock.removeEventListener).toHaveBeenCalled();
expect(callback).not.toHaveBeenCalled();
expect(uiMessagesCallbacks).toEqual([]);
expect(currentSelection).toEqual(selection);
});
});
});

View file

@ -0,0 +1,109 @@
import { describe, it, vi, expect, beforeEach, afterEach } from 'vitest';
import { createPlugin } from './create-plugin';
import { createPluginManager } from './plugin-manager.js';
import { createSandbox } from './create-sandbox.js';
import type { PenpotContext } from '@penpot/plugin-types';
import type { Manifest } from './models/manifest.model.js';
vi.mock('./plugin-manager.js', () => ({
createPluginManager: vi.fn(),
}));
vi.mock('./create-sandbox.js', () => ({
createSandbox: vi.fn(),
}));
describe('createPlugin', () => {
let mockContext: PenpotContext;
let manifest: Manifest;
let onCloseCallback: ReturnType<typeof vi.fn>;
let mockPluginManager: Awaited<ReturnType<typeof createPluginManager>>;
let mockSandbox: ReturnType<typeof createSandbox>;
beforeEach(() => {
manifest = {
pluginId: 'test-plugin',
name: 'Test Plugin',
host: 'https://example.com',
code: '',
permissions: [
'content:read',
'content:write',
'library:read',
'library:write',
'user:read',
],
};
mockPluginManager = {
close: vi.fn(),
openModal: vi.fn(),
getModal: vi.fn(),
registerListener: vi.fn(),
registerMessageCallback: vi.fn(),
sendMessage: vi.fn(),
destroyListener: vi.fn(),
context: mockContext,
manifest,
timeouts: new Set(),
code: 'console.log("Plugin running");',
} as unknown as Awaited<ReturnType<typeof createPluginManager>>;
mockSandbox = {
evaluate: vi.fn(),
cleanGlobalThis: vi.fn(),
compartment: {},
} as unknown as ReturnType<typeof createSandbox>;
mockContext = {
addListener: vi.fn(),
removeListener: vi.fn(),
getTheme: vi.fn().mockReturnValue('light'),
} as unknown as PenpotContext;
onCloseCallback = vi.fn();
vi.mocked(createPluginManager).mockResolvedValue(mockPluginManager);
vi.mocked(createSandbox).mockReturnValue(mockSandbox);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should create the plugin manager and sandbox, then evaluate the plugin code', async () => {
const result = await createPlugin(mockContext, manifest, onCloseCallback);
expect(createPluginManager).toHaveBeenCalledWith(
mockContext,
manifest,
expect.any(Function),
expect.any(Function)
);
expect(createSandbox).toHaveBeenCalledWith(mockPluginManager);
expect(mockSandbox.evaluate).toHaveBeenCalled();
expect(result).toEqual({
plugin: mockPluginManager,
compartment: mockSandbox,
});
});
it('should clean globalThis and call onCloseCallback when plugin is closed', async () => {
await createPlugin(mockContext, manifest, onCloseCallback);
const onClose = vi.mocked(createPluginManager).mock.calls[0][2];
onClose();
expect(mockSandbox.cleanGlobalThis).toHaveBeenCalled();
expect(onCloseCallback).toHaveBeenCalled();
});
it('should re-evaluate the plugin code when the modal is reloaded', async () => {
await createPlugin(mockContext, manifest, onCloseCallback);
const onReloadModal = vi.mocked(createPluginManager).mock.calls[0][3];
onReloadModal('');
expect(mockSandbox.evaluate).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,30 @@
import type { PenpotContext } from '@penpot/plugin-types';
import type { Manifest } from './models/manifest.model.js';
import { createPluginManager } from './plugin-manager.js';
import { createSandbox } from './create-sandbox.js';
export async function createPlugin(
context: PenpotContext,
manifest: Manifest,
onCloseCallback: () => void
) {
const plugin = await createPluginManager(
context,
manifest,
function onClose() {
sandbox.cleanGlobalThis();
onCloseCallback();
},
function onReloadModal() {
sandbox.evaluate();
}
);
const sandbox = createSandbox(plugin);
sandbox.evaluate();
return {
plugin,
compartment: sandbox,
};
}

View file

@ -0,0 +1,125 @@
import { describe, it, vi, expect, beforeEach, afterEach } from 'vitest';
import { createSandbox } from './create-sandbox.js';
import { createApi } from './api';
import { ses } from './ses.js';
import type { createPluginManager } from './plugin-manager';
vi.mock('./api', () => ({
createApi: vi.fn(),
}));
vi.mock('./ses.js', () => ({
ses: {
hardenIntrinsics: vi.fn(),
createCompartment: vi.fn().mockImplementation((publicApi) => {
return {
evaluate: vi.fn(),
globalThis: publicApi,
};
}),
harden: vi.fn().mockImplementation((obj) => obj),
},
}));
describe('createSandbox', () => {
let mockPlugin: Awaited<ReturnType<typeof createPluginManager>>;
const compartmentMock = vi.mocked(ses.createCompartment);
function getLastCompartment() {
return compartmentMock.mock.results[compartmentMock.mock.results.length - 1]
.value;
}
beforeEach(() => {
mockPlugin = {
code: 'console.log("Plugin running");',
timeouts: new Set<ReturnType<typeof setTimeout>>(),
} as unknown as Awaited<ReturnType<typeof createPluginManager>>;
vi.mocked(createApi).mockReturnValue({
penpot: {
closePlugin: vi.fn(),
},
} as unknown as ReturnType<typeof createApi>);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should harden intrinsics and create the plugin API', () => {
createSandbox(mockPlugin);
expect(ses.hardenIntrinsics).toHaveBeenCalled();
expect(createApi).toHaveBeenCalledWith(mockPlugin);
expect(ses.harden).toHaveBeenCalledWith(expect.any(Object));
});
it('should evaluate the plugin code in the compartment', () => {
const sandbox = createSandbox(mockPlugin);
const compartment = getLastCompartment();
sandbox.evaluate();
expect(compartment.evaluate).toHaveBeenCalledWith(mockPlugin.code);
});
it('should add timeouts to the plugin and clean them on clearTimeout', () => {
const sandbox = createSandbox(mockPlugin);
const handler = vi.fn();
const timeoutId = sandbox.compartment.globalThis['setTimeout'](
handler,
1000
);
expect(timeoutId).toBeDefined();
expect(mockPlugin.timeouts.has(timeoutId)).toBe(true);
sandbox.compartment.globalThis['clearTimeout'](timeoutId);
expect(mockPlugin.timeouts.has(timeoutId)).toBe(false);
});
it('should clean the globalThis on cleanGlobalThis', () => {
const sandbox = createSandbox(mockPlugin);
const compartment = getLastCompartment();
sandbox.cleanGlobalThis();
expect(Object.keys(compartment.globalThis).length).toBe(0);
});
it('should ensure fetch requests omit credentials', async () => {
const sandbox = createSandbox(mockPlugin);
const fetchSpy = vi
.spyOn(window, 'fetch')
.mockResolvedValue(new Response());
await sandbox.compartment.globalThis['fetch']('https://example.com/api', {
method: 'GET',
credentials: 'include',
});
expect(fetchSpy).toHaveBeenCalledWith('https://example.com/api', {
method: 'GET',
credentials: 'omit',
});
fetchSpy.mockRestore();
});
it('should prevent using the api after closing the plugin', async () => {
const sandbox = createSandbox(mockPlugin);
expect(
Object.keys(sandbox.compartment.globalThis).filter((it) => !!it).length
).toBeGreaterThan(0);
sandbox.cleanGlobalThis();
expect(
Object.keys(sandbox.compartment.globalThis).filter((it) => !!it).length
).toBe(0);
});
});

View file

@ -0,0 +1,56 @@
import type { Penpot } from '@penpot/plugin-types';
import type { createPluginManager } from './plugin-manager';
import { createApi } from './api';
import { ses } from './ses.js';
export function createSandbox(
plugin: Awaited<ReturnType<typeof createPluginManager>>
) {
ses.hardenIntrinsics();
const pluginApi = createApi(plugin);
const publicPluginApi = {
penpot: ses.harden(pluginApi.penpot) as Penpot,
fetch: ses.harden((...args: Parameters<typeof fetch>) => {
const requestArgs: RequestInit = {
...args[1],
credentials: 'omit',
};
return fetch(args[0], requestArgs);
}),
console: ses.harden(window.console),
Math: ses.harden(Math),
setTimeout: ses.harden(
(...[handler, timeout]: Parameters<typeof setTimeout>) => {
const timeoutId = setTimeout(() => {
handler();
}, timeout);
plugin.timeouts.add(timeoutId);
return timeoutId;
}
) as typeof setTimeout,
clearTimeout: ses.harden((id: ReturnType<typeof setTimeout>) => {
clearTimeout(id);
plugin.timeouts.delete(id);
}),
};
const compartment = ses.createCompartment(publicPluginApi);
return {
evaluate: () => {
compartment.evaluate(plugin.code);
},
cleanGlobalThis: () => {
Object.keys(publicPluginApi).forEach((key) => {
delete compartment.globalThis[key];
});
},
compartment,
};
}

View file

@ -1,73 +1,59 @@
import { describe, it, vi, expect, beforeEach, afterEach } from 'vitest';
import { loadPlugin, setContextBuilder } from './load-plugin.js';
import { loadManifestCode } from './parse-manifest.js';
import { createApi, themeChange } from './api/index.js';
import type { PenpotContext, PenpotTheme } from '@penpot/plugin-types';
import {
loadPlugin,
ɵloadPlugin,
ɵloadPluginByUrl,
setContextBuilder,
getPlugins,
} from './load-plugin';
import { loadManifest } from './parse-manifest';
import { createPlugin } from './create-plugin';
import type { PenpotContext } from '@penpot/plugin-types';
import type { Manifest } from './models/manifest.model.js';
vi.mock('./parse-manifest.js', () => ({
loadManifestCode: vi.fn(),
vi.mock('./parse-manifest', () => ({
loadManifest: vi.fn(),
}));
vi.mock('./api/index.js', () => ({
createApi: vi.fn(),
themeChange: vi.fn(),
vi.mock('./create-plugin', () => ({
createPlugin: vi.fn(),
}));
const evaluateMock = vi.fn();
vi.mock('./ses.js', () => ({
ses: {
hardenIntrinsics: vi.fn().mockReturnValue(null),
createCompartment: vi.fn().mockImplementation((publicApi) => {
return {
evaluate: evaluateMock,
globalThis: publicApi,
};
}),
harden: vi.fn().mockImplementation((obj) => obj),
},
}));
describe('loadPlugin', () => {
vi.spyOn(globalThis, 'clearTimeout');
vi.spyOn(globalThis.console, 'error').mockImplementation(() => {});
describe('plugin-loader', () => {
let mockContext: PenpotContext;
let manifest: Manifest = {
pluginId: 'test-plugin',
name: 'Test Plugin',
host: '',
code: '',
permissions: [
'content:read',
'content:write',
'library:read',
'library:write',
'user:read',
],
};
let mockApi: ReturnType<typeof createApi>;
let addListenerMock: ReturnType<typeof vi.fn>;
let manifest: Manifest;
let mockPluginApi: Awaited<ReturnType<typeof createPlugin>>;
let mockClose: ReturnType<typeof vi.fn>;
beforeEach(() => {
addListenerMock = vi.fn();
manifest = {
pluginId: 'test-plugin',
name: 'Test Plugin',
host: '',
code: '',
permissions: [
'content:read',
'content:write',
'library:read',
'library:write',
'user:read',
],
};
mockClose = vi.fn();
mockPluginApi = {
plugin: {
close: mockClose,
sendMessage: vi.fn(),
},
} as unknown as Awaited<ReturnType<typeof createPlugin>>;
mockContext = {
addListener: addListenerMock,
addListener: vi.fn(),
removeListener: vi.fn(),
} as unknown as PenpotContext;
mockApi = {
penpot: {
closePlugin: vi.fn(),
},
removeAllEventListeners: vi.fn(),
} as unknown as ReturnType<typeof createApi>;
vi.mocked(createApi).mockReturnValue(mockApi);
vi.mocked(loadManifestCode).mockResolvedValue(
'console.log("Plugin loaded");'
);
vi.mocked(createPlugin).mockResolvedValue(mockPluginApi);
setContextBuilder(() => mockContext);
});
@ -75,114 +61,79 @@ describe('loadPlugin', () => {
vi.clearAllMocks();
});
it('should set up the context and load the manifest code', async () => {
it('should load and initialize a plugin', async () => {
await loadPlugin(manifest);
expect(loadManifestCode).toHaveBeenCalledWith(manifest);
expect(createApi).toHaveBeenCalledWith(
expect(createPlugin).toHaveBeenCalledWith(
mockContext,
manifest,
expect.any(Function)
);
expect(mockPluginApi.plugin.close).not.toHaveBeenCalled();
expect(getPlugins()).toHaveLength(1);
});
it('should close all plugins before loading a new one', async () => {
await loadPlugin(manifest);
await loadPlugin(manifest);
expect(mockClose).toHaveBeenCalledTimes(1);
expect(createPlugin).toHaveBeenCalledTimes(2);
});
it('should remove the plugin from the list on close', async () => {
await loadPlugin(manifest);
const closeCallback = vi.mocked(createPlugin).mock.calls[0][2];
closeCallback();
expect(getPlugins()).toHaveLength(0);
});
it('should handle errors and close all plugins', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(createPlugin).mockRejectedValue(
new Error('Plugin creation failed')
);
await loadPlugin(manifest);
expect(getPlugins()).toHaveLength(0);
expect(consoleSpy).toHaveBeenCalled();
});
it('should handle messages sent to plugins', async () => {
await loadPlugin(manifest);
window.dispatchEvent(new MessageEvent('message', { data: 'test-message' }));
expect(mockPluginApi.plugin.sendMessage).toHaveBeenCalledWith(
'test-message'
);
});
it('should load plugin using ɵloadPlugin', async () => {
await ɵloadPlugin(manifest);
expect(createPlugin).toHaveBeenCalledWith(
mockContext,
manifest,
expect.any(Function),
expect.any(Function)
);
});
it('should handle theme change events', async () => {
await loadPlugin(manifest);
it('should load plugin by URL using ɵloadPluginByUrl', async () => {
const manifestUrl = 'https://example.com/manifest.json';
vi.mocked(loadManifest).mockResolvedValue(manifest);
const themeChangeListener = addListenerMock.mock.calls
.find((call) => call[0] === 'themechange')
?.at(1);
await ɵloadPluginByUrl(manifestUrl);
const mockTheme: PenpotTheme = 'dark';
themeChangeListener(mockTheme);
expect(themeChange).toHaveBeenCalledWith(mockTheme);
});
it('should close all plugins when a new plugin is loaded', async () => {
await loadPlugin(manifest);
await loadPlugin(manifest);
expect(mockApi.penpot.closePlugin).toHaveBeenCalledTimes(1);
});
it('should remove finish event listener on plugin finish', async () => {
await loadPlugin(manifest);
const finishListener = addListenerMock.mock.calls
.find((call) => call[0] === 'finish')
?.at(1);
finishListener();
expect(mockContext.removeListener).toHaveBeenCalled();
});
it('shoud clean setTimeout when plugin is closed', async () => {
const plugin = await loadPlugin(manifest);
if (!plugin) {
throw new Error('Plugin not loaded');
}
plugin.publicPluginApi.setTimeout(() => {}, 1000);
plugin.publicPluginApi.setTimeout(() => {}, 1000);
expect(plugin.timeouts.size).toBe(2);
const closedCallback = vi.mocked(createApi).mock.calls[0][2];
closedCallback();
expect(plugin.timeouts.size).toBe(0);
expect(clearTimeout).toHaveBeenCalledTimes(2);
});
it('should close plugin on evaluation error', async () => {
evaluateMock.mockImplementationOnce(() => {
throw new Error('Evaluation error');
});
await loadPlugin(manifest);
expect(mockApi.penpot.closePlugin).toHaveBeenCalled();
expect(console.error).toHaveBeenCalled();
});
it('should prevent using the api after closing the plugin', async () => {
const plugin = await loadPlugin(manifest);
if (!plugin) {
throw new Error('Plugin not loaded');
}
expect(
Object.keys(plugin.compartment.globalThis).filter((it) => !!it).length
).toBeGreaterThan(0);
const closedCallback = vi.mocked(createApi).mock.calls[0][2];
closedCallback();
expect(
Object.keys(plugin.compartment.globalThis).filter((it) => !!it).length
).toBe(0);
});
it('should re-evaluate and remove listeners if plugin reload', async () => {
const plugin = await loadPlugin(manifest);
if (!plugin) {
throw new Error('Plugin not loaded');
}
const onLoad = vi.mocked(createApi).mock.calls[0][3];
// regular load
await onLoad();
// reload
await onLoad();
expect(mockApi.removeAllEventListeners).toHaveBeenCalledOnce();
expect(evaluateMock).toHaveBeenCalledTimes(2);
expect(loadManifestCode).toHaveBeenCalledTimes(2);
expect(loadManifest).toHaveBeenCalledWith(manifestUrl);
expect(createPlugin).toHaveBeenCalledWith(
mockContext,
manifest,
expect.any(Function)
);
});
});

View file

@ -1,13 +1,10 @@
import type { Penpot, PenpotContext, PenpotTheme } from '@penpot/plugin-types';
import type { PenpotContext } from '@penpot/plugin-types';
import { createApi } from './api/index.js';
import { loadManifest, loadManifestCode } from './parse-manifest.js';
import { loadManifest } from './parse-manifest.js';
import { Manifest } from './models/manifest.model.js';
import * as api from './api/index.js';
import { ses } from './ses.js';
import { createPlugin } from './create-plugin.js';
let createdApis: ReturnType<typeof createApi>[] = [];
const multiPlugin = false;
let plugins: Awaited<ReturnType<typeof createPlugin>>[] = [];
export type ContextBuilder = (id: string) => PenpotContext;
@ -17,14 +14,26 @@ export function setContextBuilder(builder: ContextBuilder) {
contextBuilder = builder;
}
export const getPlugins = () => plugins;
const closeAllPlugins = () => {
createdApis.forEach((pluginApi) => {
pluginApi.penpot.closePlugin();
plugins.forEach((pluginApi) => {
pluginApi.plugin.close();
});
createdApis = [];
plugins = [];
};
window.addEventListener('message', (event) => {
try {
for (const it of plugins) {
it.plugin.sendMessage(event.data);
}
} catch (err) {
console.error(err);
}
});
export const loadPlugin = async function (manifest: Manifest) {
try {
const context = contextBuilder && contextBuilder(manifest.pluginId);
@ -33,99 +42,17 @@ export const loadPlugin = async function (manifest: Manifest) {
return;
}
context.addListener('themechange', (e: PenpotTheme) => api.themeChange(e));
const listenerId: symbol = context.addListener('finish', () => {
closeAllPlugins();
closeAllPlugins();
context?.removeListener(listenerId);
const plugin = await createPlugin(context, manifest, () => {
plugins = plugins.filter((api) => api !== plugin);
});
const code = await loadManifestCode(manifest);
ses.hardenIntrinsics();
if (createdApis && !multiPlugin) {
closeAllPlugins();
}
const onClose = () => {
createdApis = createdApis.filter((api) => api !== pluginApi);
timeouts.forEach(clearTimeout);
timeouts.clear();
// Remove all public API from globalThis
Object.keys(publicPluginApi).forEach((key) => {
delete c.globalThis[key];
});
};
let loaded = false;
const onLoad = async () => {
if (!loaded) {
loaded = true;
return;
}
pluginApi.removeAllEventListeners();
const code = await loadManifestCode(manifest);
c.evaluate(code);
};
const pluginApi = createApi(context, manifest, onClose, onLoad);
createdApis.push(pluginApi);
const timeouts = new Set<ReturnType<typeof setTimeout>>();
const publicPluginApi = {
penpot: ses.harden(pluginApi.penpot) as Penpot,
fetch: ses.harden((...args: Parameters<typeof fetch>) => {
const requestArgs: RequestInit = {
...args[1],
credentials: 'omit',
};
return fetch(args[0], requestArgs);
}),
console: ses.harden(window.console),
Math: ses.harden(Math),
setTimeout: ses.harden(
(...[handler, timeout]: Parameters<typeof setTimeout>) => {
const timeoutId = setTimeout(() => {
handler();
}, timeout);
timeouts.add(timeoutId);
return timeoutId;
}
) as typeof setTimeout,
clearTimeout: ses.harden((id: ReturnType<typeof setTimeout>) => {
clearTimeout(id);
timeouts.delete(id);
}),
};
const c = ses.createCompartment(publicPluginApi);
c.evaluate(code);
return {
compartment: c,
publicPluginApi,
timeouts,
context,
};
plugins.push(plugin);
} catch (error) {
closeAllPlugins();
console.error(error);
}
return;
};
export const ɵloadPlugin = async function (manifest: Manifest) {

View file

@ -0,0 +1,12 @@
import { EventsMap } from '@penpot/plugin-types';
export type RegisterListener = <K extends keyof EventsMap>(
type: K,
event: (arg: EventsMap[K]) => void,
props?: { [key: string]: unknown }
) => symbol;
export type DestroyListener = <K extends keyof EventsMap>(
id: symbol | K,
event?: (arg: EventsMap[K]) => void
) => void;

View file

@ -0,0 +1,284 @@
import { describe, it, vi, expect, beforeEach, afterEach } from 'vitest';
import { createPluginManager } from './plugin-manager';
import { loadManifestCode, getValidUrl } from './parse-manifest.js';
import { PluginModalElement } from './modal/plugin-modal.js';
import { openUIApi } from './api/openUI.api.js';
import type { PenpotContext, PenpotTheme } from '@penpot/plugin-types';
import type { Manifest } from './models/manifest.model.js';
vi.mock('./parse-manifest.js', () => ({
loadManifestCode: vi.fn(),
getValidUrl: vi.fn(),
}));
vi.mock('./api/openUI.api.js', () => ({
openUIApi: vi.fn(),
}));
describe('createPluginManager', () => {
let mockContext: PenpotContext;
let manifest: Manifest;
let onCloseCallback: ReturnType<typeof vi.fn>;
let onReloadModal: ReturnType<typeof vi.fn>;
let mockModal: {
setTheme: ReturnType<typeof vi.fn>;
remove: ReturnType<typeof vi.fn>;
addEventListener: ReturnType<typeof vi.fn>;
removeEventListener: ReturnType<typeof vi.fn>;
getAttribute: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
manifest = {
pluginId: 'test-plugin',
name: 'Test Plugin',
host: 'https://example.com',
code: '',
permissions: [
'content:read',
'content:write',
'library:read',
'library:write',
'user:read',
],
};
mockModal = {
setTheme: vi.fn(),
remove: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
getAttribute: vi.fn(),
};
vi.mocked(openUIApi).mockReturnValue(
mockModal as unknown as PluginModalElement
);
mockContext = {
addListener: vi.fn().mockReturnValue(Symbol()),
removeListener: vi.fn(),
getTheme: vi.fn().mockReturnValue('light'),
} as unknown as PenpotContext;
onCloseCallback = vi.fn();
onReloadModal = vi.fn();
vi.mocked(loadManifestCode).mockResolvedValue(
'console.log("Plugin loaded");'
);
vi.mocked(getValidUrl).mockReturnValue('https://example.com/plugin');
});
afterEach(() => {
vi.clearAllMocks();
});
it('should load the plugin and set up listeners', async () => {
await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal
);
expect(loadManifestCode).toHaveBeenCalledWith(manifest);
expect(mockContext.addListener).toHaveBeenCalledWith(
'themechange',
expect.any(Function)
);
expect(mockContext.addListener).toHaveBeenCalledWith(
'finish',
expect.any(Function)
);
});
it('should open a modal with the correct URL and theme', async () => {
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal
);
pluginManager.openModal('Test Modal', '/test-url', {
width: 400,
height: 300,
});
expect(getValidUrl).toHaveBeenCalledWith(manifest.host, '/test-url');
expect(openUIApi).toHaveBeenCalledWith(
'Test Modal',
'https://example.com/plugin',
'light',
{ width: 400, height: 300 }
);
expect(mockModal.setTheme).toHaveBeenCalledWith('light');
expect(mockModal.addEventListener).toHaveBeenCalledWith(
'close',
expect.any(Function),
{ once: true }
);
expect(mockModal.addEventListener).toHaveBeenCalledWith(
'load',
expect.any(Function)
);
});
it('should not open a new modal if the URL has not changed', async () => {
mockModal.getAttribute.mockReturnValue('https://example.com/plugin');
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal
);
pluginManager.openModal('Test Modal', '/test-url');
pluginManager.openModal('Test Modal', '/test-url');
expect(openUIApi).toHaveBeenCalledTimes(1);
});
it('should handle theme changes and update the modal theme', async () => {
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal
);
pluginManager.openModal('Test Modal', '/test-url');
const themeChangeCallback = vi
.mocked(mockContext.addListener)
.mock.calls.find((call) => call[0] === 'themechange')?.[1];
if (!themeChangeCallback) {
throw new Error('Theme change callback not found');
}
themeChangeCallback('dark' as PenpotTheme);
expect(mockModal.setTheme).toHaveBeenCalledWith('dark');
});
it('should remove all event listeners and close the plugin', async () => {
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal
);
pluginManager.openModal('Test Modal', '/test-url');
pluginManager.close();
expect(mockContext.removeListener).toHaveBeenCalled();
expect(mockModal.removeEventListener).toHaveBeenCalledWith(
'close',
expect.any(Function)
);
expect(mockModal.remove).toHaveBeenCalled();
expect(onCloseCallback).toHaveBeenCalled();
});
it('shoud clean setTimeout when plugin is closed', async () => {
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal
);
pluginManager.timeouts.add(setTimeout(() => {}, 1000));
pluginManager.timeouts.add(setTimeout(() => {}, 1000));
expect(pluginManager.timeouts.size).toBe(2);
pluginManager.close();
expect(pluginManager.timeouts.size).toBe(0);
});
it('should reload the modal when reloaded', async () => {
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal
);
await pluginManager.openModal('Test Modal', '/test-url');
const loadCallback = mockModal.addEventListener.mock.calls.find((call) => {
return call[0] === 'load';
});
if (loadCallback) {
// initial load
await loadCallback[1]();
// reload
await loadCallback[1]();
expect(onReloadModal).toHaveBeenCalledWith(
'console.log("Plugin loaded");'
);
}
});
it('should register and trigger message callbacks', async () => {
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal
);
const callback = vi.fn();
pluginManager.registerMessageCallback(callback);
pluginManager.sendMessage('Test Message');
expect(callback).toHaveBeenCalledWith('Test Message');
});
it('should register and remove listeners', async () => {
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal
);
const callback = vi.fn();
const listenerId = pluginManager.registerListener('themechange', callback);
expect(mockContext.addListener).toHaveBeenCalledWith(
'themechange',
expect.any(Function),
undefined
);
pluginManager.destroyListener(listenerId);
expect(mockContext.removeListener).toHaveBeenCalledWith(listenerId);
});
it('should clean up all event listeners on close', async () => {
const pluginManager = await createPluginManager(
mockContext,
manifest,
onCloseCallback,
onReloadModal
);
pluginManager.close();
expect(mockContext.removeListener).toHaveBeenCalled();
expect(onCloseCallback).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,165 @@
import type { PenpotContext, PenpotTheme } from '@penpot/plugin-types';
import { getValidUrl, loadManifestCode } from './parse-manifest.js';
import { Manifest } from './models/manifest.model.js';
import { PluginModalElement } from './modal/plugin-modal.js';
import { openUIApi } from './api/openUI.api.js';
import { OpenUIOptions } from './models/open-ui-options.model.js';
import { RegisterListener, DestroyListener } from './models/plugin.model.js';
export async function createPluginManager(
context: PenpotContext,
manifest: Manifest,
onCloseCallback: () => void,
onReloadModal: (code: string) => void
) {
let code = await loadManifestCode(manifest);
let loaded = false;
let destroyed = false;
let modal: PluginModalElement | null = null;
let uiMessagesCallbacks: ((message: unknown) => void)[] = [];
const timeouts = new Set<ReturnType<typeof setTimeout>>();
const themeChangeId = context.addListener(
'themechange',
(theme: PenpotTheme) => {
modal?.setTheme(theme);
}
);
const listenerId: symbol = context.addListener('finish', () => {
closePlugin();
context?.removeListener(listenerId);
});
// TODO: Remove when deprecating method `off`
let listeners: { [key: string]: Map<object, symbol> } = {};
const removeAllEventListeners = () => {
context.removeListener(themeChangeId);
Object.entries(listeners).forEach(([, map]) => {
map.forEach((id) => {
destroyListener(id);
});
});
uiMessagesCallbacks = [];
listeners = {};
};
const closePlugin = () => {
removeAllEventListeners();
timeouts.forEach(clearTimeout);
timeouts.clear();
if (modal) {
modal.removeEventListener('close', closePlugin);
modal.remove();
modal = null;
}
destroyed = true;
onCloseCallback();
};
const onLoadModal = async () => {
if (!loaded) {
loaded = true;
return;
}
removeAllEventListeners();
code = await loadManifestCode(manifest);
onReloadModal(code);
};
const openModal = (name: string, url: string, options?: OpenUIOptions) => {
const theme = context.getTheme() as 'light' | 'dark';
const modalUrl = getValidUrl(manifest.host, url);
if (modal?.getAttribute('iframe-src') === modalUrl) {
return;
}
modal = openUIApi(name, modalUrl, theme, options);
modal.setTheme(theme);
modal.addEventListener('close', closePlugin, {
once: true,
});
modal.addEventListener('load', onLoadModal);
};
const registerMessageCallback = (callback: (message: unknown) => void) => {
uiMessagesCallbacks.push(callback);
};
const registerListener: RegisterListener = (type, callback, props) => {
const id = context.addListener(
type,
(...params) => {
// penpot has a debounce to run the events, so some events can be triggered after the plugin is closed
if (destroyed) {
return;
}
callback(...params);
},
props
);
if (!listeners[type]) {
listeners[type] = new Map<object, symbol>();
}
listeners[type].set(callback, id);
return id;
};
const destroyListener: DestroyListener = (idtype, callback) => {
let listenerId: symbol | undefined;
if (typeof idtype === 'symbol') {
listenerId = idtype;
} else if (callback) {
listenerId = listeners[idtype].get(callback);
}
if (listenerId) {
context.removeListener(listenerId);
}
};
return {
close: closePlugin,
destroyListener,
openModal,
getModal: () => modal,
registerListener,
registerMessageCallback,
sendMessage: (message: unknown) => {
uiMessagesCallbacks.forEach((callback) => callback(message));
},
get manifest() {
return manifest;
},
get context() {
return context;
},
get timeouts() {
return timeouts;
},
get code() {
return code;
},
};
}