0
Fork 0
mirror of https://github.com/penpot/penpot-plugins.git synced 2025-03-06 04:45:44 -05:00

feat: add ui.resize & ui.size api

This commit is contained in:
Juanfran 2025-01-23 16:09:22 +01:00
parent 32de075099
commit 815181d20a
6 changed files with 134 additions and 77 deletions

View file

@ -21,8 +21,33 @@ export interface Penpot
open: (
name: string,
url: string,
options?: { width: number; height: number }
options?: { width: number; height: number },
) => void;
size: {
/**
* Returns the size of the modal.
* @example
* ```js
* const size = penpot.ui.size;
* console.log(size);
* ```
*/
width: number;
height: number;
} | null;
/**
* Resizes the plugin UI.
* @param width The width of the modal.
* @param height The height of the modal.
* @example
* ```js
* penpot.ui.resize(300, 400);
* ```
* */
resize: (width: number, height: number) => void;
/**
* Sends a message to the plugin UI.
*
@ -89,7 +114,7 @@ export interface Penpot
on<T extends keyof EventsMap>(
type: T,
callback: (event: EventsMap[T]) => void,
props?: { [key: string]: unknown }
props?: { [key: string]: unknown },
): symbol;
/**
@ -858,7 +883,7 @@ export interface Context {
uploadMediaData(
name: string,
data: Uint8Array,
mimeType: string
mimeType: string,
): Promise<ImageData>;
/**
@ -1095,7 +1120,7 @@ export interface Context {
type?: 'css';
withPrelude?: boolean;
includeChildren?: boolean;
}
},
): string;
/**
@ -1111,7 +1136,7 @@ export interface Context {
addListener<T extends keyof EventsMap>(
type: T,
callback: (event: EventsMap[T]) => void,
props?: { [key: string]: unknown }
props?: { [key: string]: unknown },
): symbol;
/**
@ -1153,7 +1178,7 @@ export interface Context {
*/
alignHorizontal(
shapes: Shape[],
direction: 'left' | 'center' | 'right'
direction: 'left' | 'center' | 'right',
): void;
/**
@ -1454,7 +1479,7 @@ export interface File extends PluginData {
*/
export(
exportType: 'penpot' | 'zip',
libraryExportType?: 'all' | 'merge' | 'detach'
libraryExportType?: 'all' | 'merge' | 'detach',
): Promise<Uint8Array>;
/**
@ -2862,7 +2887,7 @@ export interface Page extends PluginData {
addRulerGuide(
orientation: RulerGuideOrientation,
value: number,
board?: Board
board?: Board,
): RulerGuide;
/**

View file

@ -40,7 +40,7 @@ export const validEvents = [
] as const;
export function createApi(
plugin: Awaited<ReturnType<typeof createPluginManager>>
plugin: Awaited<ReturnType<typeof createPluginManager>>,
) {
const checkPermission = (permission: Permissions) => {
if (!plugin.manifest.permissions.includes(permission)) {
@ -54,6 +54,14 @@ export function createApi(
plugin.openModal(name, url, options);
},
get size() {
return plugin.getModal()?.size() || null;
},
resize: (width: number, height: number) => {
return plugin.resizeModal(width, height);
},
sendMessage(message: unknown) {
const event = new CustomEvent('message', {
detail: message,
@ -112,7 +120,7 @@ export function createApi(
on<T extends keyof EventsMap>(
type: T,
callback: (event: EventsMap[T]) => void,
props?: { [key: string]: unknown }
props?: { [key: string]: unknown },
): symbol {
// z.function alter fn, so can't use it here
z.enum(validEvents).parse(type);
@ -254,7 +262,7 @@ export function createApi(
generateMarkup(
shapes: Shape[],
options?: { type?: 'html' | 'svg' }
options?: { type?: 'html' | 'svg' },
): string {
checkPermission('content:read');
return plugin.context.generateMarkup(shapes, options);
@ -266,7 +274,7 @@ export function createApi(
type?: 'css';
withPrelude?: boolean;
includeChildren?: boolean;
}
},
): string {
checkPermission('content:read');
return plugin.context.generateStyle(shapes, options);
@ -289,7 +297,7 @@ export function createApi(
alignHorizontal(
shapes: Shape[],
direction: 'left' | 'center' | 'right'
direction: 'left' | 'center' | 'right',
): void {
checkPermission('content:write');
plugin.context.alignHorizontal(shapes, direction);
@ -297,7 +305,7 @@ export function createApi(
alignVertical(
shapes: Shape[],
direction: 'top' | 'center' | 'bottom'
direction: 'top' | 'center' | 'bottom',
): void {
checkPermission('content:write');
plugin.context.alignVertical(shapes, direction);

View file

@ -9,6 +9,10 @@ describe('createModal', () => {
setTheme: vi.fn(),
style: {
setProperty: vi.fn(),
getPropertyValue: vi.fn(),
},
wrapper: {
style: {},
},
setAttribute: vi.fn(),
} as unknown as PluginModalElement;
@ -34,7 +38,7 @@ describe('createModal', () => {
'Test Modal',
'https://example.com',
theme,
options
options,
);
expect(createElementSpy).toHaveBeenCalledWith('plugin-modal');
@ -42,16 +46,16 @@ describe('createModal', () => {
expect(modal.style.setProperty).toHaveBeenCalledWith(
'--modal-block-start',
'40px'
'40px',
);
expect(modal.setAttribute).toHaveBeenCalledWith('title', 'Test Modal');
expect(modal.setAttribute).toHaveBeenCalledWith(
'iframe-src',
'https://example.com'
'https://example.com',
);
expect(modal.setAttribute).toHaveBeenCalledWith('width', '400');
expect(modal.setAttribute).toHaveBeenCalledWith('height', '600');
expect(modal.wrapper.style.width).toEqual('400px');
expect(modal.wrapper.style.height).toEqual('600px');
expect(appendChildSpy).toHaveBeenCalledWith(modal);
});
@ -61,8 +65,8 @@ describe('createModal', () => {
const modal = createModal('Test Modal', 'https://example.com', theme);
expect(modal.setAttribute).toHaveBeenCalledWith('width', '335');
expect(modal.setAttribute).toHaveBeenCalledWith('height', '590');
expect(modal.wrapper.style.width).toEqual('335px');
expect(modal.wrapper.style.height).toEqual('590px');
});
it('should limit modal dimensions to the window size', () => {
@ -76,20 +80,14 @@ describe('createModal', () => {
'Test Modal',
'https://example.com',
theme,
options
options,
);
const expectedWidth = 710; // 1000 - 270 (initialPosition.inlineEnd)
const expectedHeight = 760; // 800 - 40 (initialPosition.blockStart)
expect(modal.setAttribute).toHaveBeenCalledWith(
'width',
String(expectedWidth)
);
expect(modal.setAttribute).toHaveBeenCalledWith(
'height',
String(expectedHeight)
);
expect(modal.wrapper.style.width).toEqual(`${expectedWidth}px`);
expect(modal.wrapper.style.height).toEqual(`${expectedHeight}px`);
});
it('should apply minimum dimensions to the modal', () => {
@ -100,10 +98,10 @@ describe('createModal', () => {
'Test Modal',
'https://example.com',
theme,
options
options,
);
expect(modal.setAttribute).toHaveBeenCalledWith('width', '200');
expect(modal.setAttribute).toHaveBeenCalledWith('height', '200');
expect(modal.wrapper.style.width).toEqual('200px');
expect(modal.wrapper.style.height).toEqual('200px');
});
});

View file

@ -7,49 +7,32 @@ export function createModal(
url: string,
theme: Theme,
options?: OpenUIOptions,
allowDownloads?: boolean
allowDownloads?: boolean,
) {
const modal = document.createElement('plugin-modal') as PluginModalElement;
modal.setTheme(theme);
const minPluginWidth = 200;
const minPluginHeight = 200;
const defaultWidth = 335;
const defaultHeight = 590;
const maxWidth =
(options?.width ?? defaultWidth) > window.innerWidth
? window.innerWidth - 290
: options?.width ?? defaultWidth;
const { width } = resizeModal(modal, options?.width, options?.height);
const initialPosition = {
blockStart: 40,
// To be able to resize the element as expected the position must be absolute from the right.
// This value is the length of the window minus the width of the element plus the width of the design tab.
inlineStart: window.innerWidth - maxWidth - 290,
inlineStart: window.innerWidth - width - 290,
};
modal.style.setProperty(
'--modal-block-start',
`${initialPosition.blockStart}px`
`${initialPosition.blockStart}px`,
);
modal.style.setProperty(
'--modal-inline-start',
`${initialPosition.inlineStart}px`
`${initialPosition.inlineStart}px`,
);
const maxHeight = window.innerHeight - initialPosition.blockStart;
let width = Math.min(options?.width || defaultWidth, maxWidth);
let height = Math.min(options?.height || defaultHeight, maxHeight);
width = Math.max(width, minPluginWidth);
height = Math.max(height, minPluginHeight);
modal.setAttribute('title', name);
modal.setAttribute('iframe-src', url);
modal.setAttribute('width', String(width));
modal.setAttribute('height', String(height));
if (allowDownloads) {
modal.setAttribute('allow-downloads', 'true');
@ -59,3 +42,33 @@ export function createModal(
return modal;
}
export function resizeModal(
modal: PluginModalElement,
width: number = 335,
height: number = 590,
) {
const minPluginWidth = 200;
const minPluginHeight = 200;
const maxWidth = width > window.innerWidth ? window.innerWidth - 290 : width;
const blockStart = parseInt(
modal.style.getPropertyValue('--modal-block-start') || '40',
10,
);
const maxHeight = window.innerHeight - blockStart;
width = Math.min(width, maxWidth);
height = Math.min(height, maxHeight);
width = Math.max(width, minPluginWidth);
height = Math.max(height, minPluginHeight);
modal.wrapper.style.width = `${width}px`;
modal.wrapper.style.minWidth = `${width}px`;
modal.wrapper.style.height = `${height}px`;
modal.wrapper.style.minHeight = `${height}px`;
return { width, height };
}

View file

@ -4,6 +4,7 @@ const closeSvg = `
import type { Theme } from '@penpot/plugin-types';
import { dragHandler } from '../drag-handler.js';
import modalCss from './plugin.modal.css?inline';
import { resizeModal } from '../create-modal.js';
export class PluginModalElement extends HTMLElement {
constructor() {
@ -11,13 +12,19 @@ export class PluginModalElement extends HTMLElement {
this.attachShadow({ mode: 'open' });
}
#wrapper: HTMLElement | null = null;
#inner: HTMLElement | null = null;
wrapper = document.createElement('div');
#inner = document.createElement('div');
#dragEvents: ReturnType<typeof dragHandler> | null = null;
setTheme(theme: Theme) {
if (this.#wrapper) {
this.#wrapper.setAttribute('data-theme', theme);
if (this.wrapper) {
this.wrapper.setAttribute('data-theme', theme);
}
}
resize(width: number, height: number) {
if (this.wrapper) {
resizeModal(this, width, height);
}
}
@ -42,8 +49,6 @@ export class PluginModalElement extends HTMLElement {
connectedCallback() {
const title = this.getAttribute('title');
const iframeSrc = this.getAttribute('iframe-src');
const width = Number(this.getAttribute('width') || '300');
const height = Number(this.getAttribute('height') || '400');
const allowDownloads = this.getAttribute('allow-downloads') || false;
if (!title || !iframeSrc) {
@ -54,21 +59,14 @@ export class PluginModalElement extends HTMLElement {
throw new Error('Error creating shadow root');
}
this.#wrapper = document.createElement('div');
this.#inner = document.createElement('div');
this.#inner.classList.add('inner');
this.#wrapper.classList.add('wrapper');
this.#wrapper.style.inlineSize = `${width}px`;
this.#wrapper.style.minInlineSize = `${width}px`;
this.#wrapper.style.blockSize = `${height}px`;
this.#wrapper.style.minBlockSize = `${height}px`;
this.#wrapper.style.maxInlineSize = '90vw';
this.#wrapper.style.maxBlockSize = '90vh';
this.wrapper.classList.add('wrapper');
this.wrapper.style.maxInlineSize = '90vw';
this.wrapper.style.maxBlockSize = '90vh';
// move modal to the top
this.#dragEvents = dragHandler(this.#inner, this.#wrapper, () => {
this.#dragEvents = dragHandler(this.#inner, this.wrapper, () => {
this.calculateZIndex();
});
@ -92,7 +90,7 @@ export class PluginModalElement extends HTMLElement {
new CustomEvent('close', {
composed: true,
bubbles: true,
})
}),
);
});
@ -107,7 +105,7 @@ export class PluginModalElement extends HTMLElement {
'allow-modals',
'allow-popups',
'allow-popups-to-escape-sandbox',
'allow-storage-access-by-user-activation'
'allow-storage-access-by-user-activation',
);
if (allowDownloads) {
@ -119,7 +117,7 @@ export class PluginModalElement extends HTMLElement {
new CustomEvent('load', {
composed: true,
bubbles: true,
})
}),
);
});
@ -131,9 +129,9 @@ export class PluginModalElement extends HTMLElement {
iframe.contentWindow.postMessage((e as CustomEvent).detail, '*');
});
this.shadowRoot.appendChild(this.#wrapper);
this.shadowRoot.appendChild(this.wrapper);
this.#wrapper.appendChild(this.#inner);
this.wrapper.appendChild(this.#inner);
this.#inner.appendChild(header);
this.#inner.appendChild(iframe);
@ -144,6 +142,13 @@ export class PluginModalElement extends HTMLElement {
this.calculateZIndex();
}
size() {
const width = Number(this.wrapper.style.width.replace('px', '') || '300');
const height = Number(this.wrapper.style.height.replace('px', '') || '400');
return { width, height };
}
}
customElements.define('plugin-modal', PluginModalElement);

View file

@ -6,6 +6,7 @@ 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 } from './models/plugin.model.js';
import { openUISchema } from './models/open-ui-options.schema.js';
export async function createPluginManager(
context: Context,
@ -129,6 +130,13 @@ export async function createPluginManager(
close: closePlugin,
destroyListener,
openModal,
resizeModal: (width: number, height: number) => {
openUISchema.parse({ width, height });
if (modal) {
modal.resize(width, height);
}
},
getModal: () => modal,
registerListener,
registerMessageCallback,