Eva Marco fdf1684565
🐛 Fix plugin modal z index (#11109)
* 🐛 Fix z-index on plugin modals

* 🐛 Fix CI
2026-08-06 12:49:52 +02:00

199 lines
5.7 KiB
TypeScript

const closeSvg = `
<svg width="16" height="16"xmlns="http://www.w3.org/2000/svg" fill="none"><g class="fills"><rect rx="0" ry="0" width="16" height="16" class="frame-background"/></g><g class="frame-children"><path d="M11.997 3.997 8 8l-3.997 4.003m-.006-8L8 8l4.003 3.997" class="fills"/><g class="strokes"><path d="M11.997 3.997 8 8l-3.997 4.003m-.006-8L8 8l4.003 3.997" style="fill: none; stroke-width: 1; stroke: rgb(143, 157, 163); stroke-opacity: 1; stroke-linecap: round;" class="stroke-shape"/></g></g></svg>`;
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';
const MIN_Z_INDEX = 300;
const Z_INDEX_VAR = '--z-index-set';
export class PluginModalElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
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);
}
}
resize(width: number, height: number) {
if (this.wrapper) {
resizeModal(this, width, height);
}
}
disconnectedCallback() {
this.#dragEvents?.();
}
calculateZIndex() {
const modals = document.querySelectorAll<HTMLElement>('plugin-modal');
const zIndexModals = Array.from(modals)
.filter((modal) => modal !== this)
.map((modal) => {
return Number(modal.style.zIndex);
});
// Read the application z-index scale via the inherited CSS custom property
// `--z-index-set` (defined on :root). Custom properties pierce shadow DOM
// boundaries, so the value is available even though the modal uses a Shadow
// root. Falls back to MIN_Z_INDEX when the variable is unset or unparseable
// (e.g. when the runtime is used outside the Penpot app shell).
const declared = getComputedStyle(this)
.getPropertyValue(Z_INDEX_VAR)
.trim();
const parsed = Number(declared);
const baseZIndex =
Number.isFinite(parsed) && parsed > 0 ? parsed : MIN_Z_INDEX;
const maxZIndex = Math.max(...zIndexModals, baseZIndex);
this.style.zIndex = (maxZIndex + 1).toString();
}
connectedCallback() {
const title = this.getAttribute('title');
const iframeSrc = this.getAttribute('iframe-src');
const allowDownloads = this.getAttribute('allow-downloads') || false;
const allowClipboardRead =
this.getAttribute('allow-clipboard-read') || false;
const allowClipboardWrite =
this.getAttribute('allow-clipboard-write') || false;
if (!title || !iframeSrc) {
throw new Error('title and iframe-src attributes are required');
}
if (!this.shadowRoot) {
throw new Error('Error creating shadow root');
}
this.#inner.classList.add('inner');
this.wrapper.classList.add('wrapper');
this.wrapper.style.maxInlineSize = '90vw';
this.wrapper.style.maxBlockSize = '90vh';
const header = document.createElement('div');
header.classList.add('header');
const h1 = document.createElement('h1');
h1.textContent = title;
header.appendChild(h1);
const closeButton = document.createElement('button');
closeButton.setAttribute('type', 'button');
closeButton.innerHTML = `<div class="close">${closeSvg}</div>`;
closeButton.addEventListener('click', () => {
if (!this.shadowRoot) {
return;
}
this.shadowRoot.dispatchEvent(
new CustomEvent('close', {
composed: true,
bubbles: true,
}),
);
});
header.appendChild(closeButton);
const iframe = document.createElement('iframe');
iframe.src = iframeSrc;
const allowList: string[] = [];
if (allowClipboardRead) allowList.push('clipboard-read');
if (allowClipboardWrite) allowList.push('clipboard-write');
iframe.allow = allowList.join('; ');
iframe.sandbox.add(
'allow-scripts',
'allow-forms',
'allow-modals',
'allow-popups',
'allow-popups-to-escape-sandbox',
'allow-storage-access-by-user-activation',
'allow-same-origin',
);
if (allowDownloads) {
iframe.sandbox.add('allow-downloads');
}
iframe.addEventListener('load', () => {
this.shadowRoot?.dispatchEvent(
new CustomEvent('load', {
composed: true,
bubbles: true,
}),
);
});
// move modal to the top
this.#dragEvents = dragHandler(
header,
this.wrapper,
() => {
this.calculateZIndex();
},
{
start: () => {
this.wrapper.classList.add('is-dragging');
},
end: () => {
this.wrapper.classList.remove('is-dragging');
},
},
);
this.addEventListener('message', (e: Event) => {
if (!iframe.contentWindow) {
return;
}
try {
iframe.contentWindow.postMessage((e as CustomEvent).detail, '*');
} catch (err) {
console.error(
'plugin modal: failed to send message to iframe via postMessage.',
err,
);
}
});
this.shadowRoot.appendChild(this.wrapper);
this.wrapper.appendChild(this.#inner);
this.#inner.appendChild(header);
this.#inner.appendChild(iframe);
const style = document.createElement('style');
style.textContent = modalCss;
this.shadowRoot.appendChild(style);
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);