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:
parent
1371af998c
commit
16595c2c48
12 changed files with 1036 additions and 653 deletions
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
109
libs/plugins-runtime/src/lib/create-plugin.spec.ts
Normal file
109
libs/plugins-runtime/src/lib/create-plugin.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
30
libs/plugins-runtime/src/lib/create-plugin.ts
Normal file
30
libs/plugins-runtime/src/lib/create-plugin.ts
Normal 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,
|
||||
};
|
||||
}
|
125
libs/plugins-runtime/src/lib/create-sandbox.spec.ts
Normal file
125
libs/plugins-runtime/src/lib/create-sandbox.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
56
libs/plugins-runtime/src/lib/create-sandbox.ts
Normal file
56
libs/plugins-runtime/src/lib/create-sandbox.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) {
|
||||
|
|
12
libs/plugins-runtime/src/lib/models/plugin.model.ts
Normal file
12
libs/plugins-runtime/src/lib/models/plugin.model.ts
Normal 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;
|
284
libs/plugins-runtime/src/lib/plugin-manager.spec.ts
Normal file
284
libs/plugins-runtime/src/lib/plugin-manager.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
165
libs/plugins-runtime/src/lib/plugin-manager.ts
Normal file
165
libs/plugins-runtime/src/lib/plugin-manager.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
Loading…
Reference in a new issue