diff --git a/apps/poc-state-plugin/src/plugin.ts b/apps/poc-state-plugin/src/plugin.ts index 9e81c3f..abccc61 100644 --- a/apps/poc-state-plugin/src/plugin.ts +++ b/apps/poc-state-plugin/src/plugin.ts @@ -121,6 +121,16 @@ function createRect() { const center = penpot.viewport.center; shape.x = center.x; shape.y = center.y; + + penpot.on( + 'shapechange', + (s) => { + console.log('change', s.name, s.x, s.y); + }, + { + shapeId: shape.id, + } + ); } function moveX(data: { id: string }) { diff --git a/libs/plugin-types/index.d.ts b/libs/plugin-types/index.d.ts index 13eb0ac..65ae76a 100644 --- a/libs/plugin-types/index.d.ts +++ b/libs/plugin-types/index.d.ts @@ -78,17 +78,19 @@ export interface Penpot * @param type The event type to listen for. * @param callback The callback function to execute when the event is triggered. * @param props The properties for the current event handler. Only makes sense for specific events. + * @return the listener id that can be used to call `off` and cancel the listener * * @example * ```js * penpot.on('pagechange', () => {...do something}). * ``` */ - on: ( + on( type: T, callback: (event: EventsMap[T]) => void, - props?: Map - ) => void; + props?: { [key: string]: unknown } + ): symbol; + /** * Removes an event listener for the specified event type. * @@ -99,11 +101,25 @@ export interface Penpot * ```js * penpot.off('pagechange', () => {...do something}). * ``` + * @deprecated this method should not be used. Use instead off sending the `listenerId` (return value from `on` method) */ - off: ( + off( type: T, - callback: (event: EventsMap[T]) => void - ) => void; + callback?: (event: EventsMap[T]) => void + ): void; + + /** + * Removes an event listener for the specified event type. + * + * @param listenerId the id returned by the `on` method when the callback was set + * + * @example + * ```js + * const listenerId = penpot.on('contentsave', () => console.log("Changed")); + * penpot.off(listenerId); + * ``` + */ + off(listenerId: symbol): void; } /** @@ -1948,6 +1964,17 @@ export interface EventsMap { * The `finish` event is triggered when some operation is finished. */ finish: string; + + /** + * This event will triger whenever the shape in the props change. It's mandatory to send + * with the props an object like `{ shapeId: '' }` + */ + shapechange: PenpotShape; + + /** + * The `contentsave` event will trigger when the content file changes. + */ + contentsave: void; } /** @@ -2739,7 +2766,7 @@ export interface PenpotContext { addListener( type: T, callback: (event: EventsMap[T]) => void, - props?: Map + props?: { [key: string]: unknown } ): symbol; /** diff --git a/libs/plugins-runtime/src/lib/api/index.ts b/libs/plugins-runtime/src/lib/api/index.ts index cf9afc6..151150d 100644 --- a/libs/plugins-runtime/src/lib/api/index.ts +++ b/libs/plugins-runtime/src/lib/api/index.ts @@ -39,13 +39,16 @@ export const validEvents = [ 'filechange', 'selectionchange', 'themechange', + 'shapechange', + 'contentsave', ] as const; export let uiMessagesCallbacks: Callback[] = []; let modals = new Set([]); -const eventListeners: Map[]> = new Map(); +// TODO: Remove when deprecating method `off` +let listeners: { [key: string]: Map } = {}; window.addEventListener('message', (event) => { try { @@ -57,17 +60,10 @@ window.addEventListener('message', (event) => { } }); -export function triggerEvent( - type: keyof EventsMap, - message: EventsMap[keyof EventsMap] -) { - if (type === 'themechange') { - modals.forEach((modal) => { - modal.setTheme(message as PenpotTheme); - }); - } - const listeners = eventListeners.get(type) || []; - listeners.forEach((listener) => listener(message)); +export function themeChange(theme: PenpotTheme) { + modals.forEach((modal) => { + modal.setTheme(theme); + }); } export function createApi(context: PenpotContext, manifest: Manifest): Penpot { @@ -166,8 +162,9 @@ export function createApi(context: PenpotContext, manifest: Manifest): Penpot { on( type: T, - callback: (event: EventsMap[T]) => void - ): void { + callback: (event: EventsMap[T]) => void, + props?: { [key: string]: unknown } + ): symbol { // z.function alter fn, so can't use it here z.enum(validEvents).parse(type); z.function().parse(callback); @@ -175,24 +172,30 @@ export function createApi(context: PenpotContext, manifest: Manifest): Penpot { // To suscribe to events needs the read permission checkPermission('content:read'); - const listeners = eventListeners.get(type) || []; - listeners.push(callback as Callback); - eventListeners.set(type, listeners); + const id = context.addListener(type, callback, props); + + if (!listeners[type]) { + listeners[type] = new Map(); + } + listeners[type].set(callback, id); + return id; }, off( - type: T, - callback: (event: EventsMap[T]) => void + idtype: symbol | T, + callback?: (event: EventsMap[T]) => void ): void { - z.enum(validEvents).parse(type); - z.function().parse(callback); + let listenerId: symbol | undefined; - const listeners = eventListeners.get(type) || []; + if (typeof idtype === 'symbol') { + listenerId = idtype; + } else if (callback) { + listenerId = listeners[idtype as T].get(callback); + } - eventListeners.set( - type, - listeners.filter((listener) => listener !== callback) - ); + if (listenerId) { + context.removeListener(listenerId); + } }, // Penpot State API diff --git a/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts b/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts index 9edbe0b..9120011 100644 --- a/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts +++ b/libs/plugins-runtime/src/lib/api/plugin-api.spec.ts @@ -1,5 +1,5 @@ import { expect, describe, vi } from 'vitest'; -import { createApi, triggerEvent, uiMessagesCallbacks } from './index.js'; +import { createApi, themeChange, uiMessagesCallbacks } from './index.js'; import openUIApi from './openUI.api.js'; import type { PenpotFile } from '@penpot/plugin-types'; @@ -28,6 +28,8 @@ describe('Plugin api', () => { getSelected: vi.fn(), getSelectedShapes: vi.fn(), getTheme: vi.fn(() => 'dark'), + addListener: vi.fn(() => Symbol()), + removeListener: vi.fn(), }; const api = createApi(mockContext as any, { @@ -116,62 +118,15 @@ describe('Plugin api', () => { it('pagechange', () => { const callback = vi.fn(); - api.on('pagechange', callback); + const id = api.on('pagechange', callback); + expect(mockContext.addListener).toHaveBeenCalled(); + expect(mockContext.addListener.mock.calls[0][0]).toBe('pagechange'); + expect(mockContext.addListener.mock.calls[0][1]).toBe(callback); - triggerEvent('pagechange', 'test' as any); - - api.off('pagechange', callback); - - triggerEvent('pagechange', 'test' as any); - - expect(callback).toHaveBeenCalledWith('test'); - expect(callback).toHaveBeenCalledTimes(1); + api.off(id); + expect(mockContext.removeListener).toHaveBeenCalled(); + expect(mockContext.removeListener.mock.calls[0][0]).toBe(id); }); - - it('filechange', () => { - const callback = vi.fn(); - - api.on('filechange', callback); - - triggerEvent('filechange', 'test' as any); - - api.off('filechange', callback); - - triggerEvent('filechange', 'test' as any); - - expect(callback).toHaveBeenCalledWith('test'); - expect(callback).toHaveBeenCalledTimes(1); - }); - }); - - it('selectionchange', () => { - const callback = vi.fn(); - - api.on('selectionchange', callback); - - triggerEvent('selectionchange', 'test' as any); - - api.off('selectionchange', callback); - - triggerEvent('selectionchange', 'test' as any); - - expect(callback).toHaveBeenCalledWith('test'); - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('themechange', () => { - const callback = vi.fn(); - - api.on('themechange', callback); - - triggerEvent('themechange', 'light'); - - api.off('themechange', callback); - - triggerEvent('themechange', 'light'); - - expect(callback).toHaveBeenCalledWith('light'); - expect(callback).toHaveBeenCalledTimes(1); }); describe.concurrent('permissions', () => { @@ -266,7 +221,7 @@ describe('Plugin api', () => { expect(modalMock.setTheme).toHaveBeenCalledWith('light'); expect(api.getTheme()).toBe('light'); - triggerEvent('themechange', 'dark' as any); + themeChange('dark'); expect(modalMock.setTheme).toHaveBeenCalledWith('dark'); }); diff --git a/libs/plugins-runtime/src/lib/load-plugin.ts b/libs/plugins-runtime/src/lib/load-plugin.ts index 1604823..114e80e 100644 --- a/libs/plugins-runtime/src/lib/load-plugin.ts +++ b/libs/plugins-runtime/src/lib/load-plugin.ts @@ -1,4 +1,4 @@ -import type { PenpotContext } from '@penpot/plugin-types'; +import type { PenpotContext, PenpotTheme } from '@penpot/plugin-types'; import { createApi } from './api/index.js'; import { loadManifest, loadManifestCode } from './parse-manifest.js'; @@ -25,9 +25,7 @@ export const ɵloadPlugin = async function (manifest: Manifest) { return; } - for (const event of api.validEvents) { - context.addListener(event, api.triggerEvent.bind(null, event)); - } + context.addListener('themechange', (e: PenpotTheme) => api.themeChange(e)); const code = await loadManifestCode(manifest);