mercury_editor-2.0.x-dev/source/js/dialog.drupal.js
source/js/dialog.drupal.js
(($, Drupal, drupalSettings) => {
// TODO: This does not seem to be used, but keeping for reference.
// drupalSettings.mercuryDialog = {
// autoOpen: true,
// autoResize: false,
// dialogClass: '',
// buttonClass: 'button',
// buttonPrimaryClass: 'button--primary',
// };
function dispatchDialogEvent(eventType, dialog, element, settings) {
// Use the jQuery if the DrupalDialogEvent is not defined for BC.
if (typeof DrupalDialogEvent === 'undefined') {
$(window).trigger(`dialog:${eventType}`, [dialog, $(element), settings]);
} else {
// eslint-disable-next-line no-undef
const event = new DrupalDialogEvent(eventType, dialog, settings || {});
element.dispatchEvent(event);
}
}
Drupal.mercuryDialog = (element, baseOptions) => {
// Override the jQuery dialog method to return the element, preventing
// uncaught errors from core jQuery dialog being thrown.
const $element = $(element);
$element.dialog = () => $element;
let dialogElement;
let undef;
// The main dialog object that will be decorated with functions and returned.
const dialog = {
open: false,
returnValue: undef,
};
/**
* Determines which element to append the dialog to. Defaults to the <body>
* @param {Element} appendTo The element to append the dialog to.
* @return {jQuery} The element to append the dialog to.
*/
function _appendTo(appendTo) {
if (appendTo && (appendTo.jquery || appendTo.nodeType)) {
return $(appendTo);
}
return $(document)
.find(appendTo || 'body')
.eq(0);
}
/**
* Create a button pane similar to jQuery UI dialog.
* @param {Array} buttons The buttons to create.
*/
function _createButtonPane(buttons) {
const existing = dialogElement.querySelector('.me-dialog__buttonpane');
if (existing) {
existing.remove();
}
const uiDialogButtonPane = document.createElement('div');
uiDialogButtonPane.setAttribute('slot', 'footer');
uiDialogButtonPane.classList.add('me-dialog__buttonpane');
dialogElement.appendChild(uiDialogButtonPane);
if (
$.isEmptyObject(buttons) ||
(Array.isArray(buttons) && !buttons.length)
) {
return;
}
buttons.forEach((props) => {
const button = document.createElement('button');
button.classList = props.class;
button.classList.add('button');
button.appendChild(document.createTextNode(props.text));
button.addEventListener('click', props.click);
uiDialogButtonPane.appendChild(button);
});
}
/**
* ResizeObserver callback that runs when the shadow dialog element resizes.
*
* @param {ResizeObserverEntry[]} entries The entries to observe.
*/
function onDockResize(entries) {
entries.forEach((entry) => {
const dockElement = entry.target;
const dockDirection = dockElement.getAttribute('data-dock');
if (!dockElement.open || !dockDirection || dockDirection === 'none') {
return;
}
const { width } = entry.contentRect;
const { height } = entry.contentRect;
// Dispatch custom event with resize data
const resizeEvent = new CustomEvent('mercury:dockResize', {
detail: { width, height },
bubbles: true,
});
dialogElement.dispatchEvent(resizeEvent);
});
}
const dockObserver = new ResizeObserver(onDockResize);
/**
* MutationObserver callback that will start the resize observer when the dialog opens.
*/
function onDialogMutate() {
// @todo: We may only want to attach this if the dialog is transitioning to an open state.
dockObserver.observe(dialogElement.shadowRoot.querySelector('dialog'));
}
const dialogMutationObserver = new MutationObserver(onDialogMutate);
/**
* Converts a numeric only value to a px based CSS value.
* @param {*} value The value to convert.
* @return {string} A CSS length value.
*/
function getCSSLength(value) {
if (typeof value === 'undefined' || !/^\d+$/.test(value)) {
return value;
}
return `${value}px`;
}
/**
* Apply options to the <mercury-dialog> element.
* @param {Object} options The options to apply.
* @return {Element} The <mercury-dialog> element.
*/
function applyOptions(options) {
// Attributes that will be set on the <mercury-dialog> element.
const attributeOptions = [
'title',
'modal',
'dock',
'push',
'resizable',
'moveable',
];
// Set these options as HTML attributes on the element.
attributeOptions.forEach((option) => {
if (typeof options[option] !== 'undefined') {
dialogElement.setAttribute(option, options[option]);
}
});
// Set the dialog classes.
if (options.dialogClass) {
dialogElement.classList.add(...options.dialogClass.split(' '));
}
const dialogElements = {
'ui-dialog': dialogElement,
'ui-dialog-titlebar': dialogElement.shadowRoot.querySelector(
'header[slot="header"]',
),
'ui-dialog-title': dialogElement.shadowRoot.querySelector(
'h1.me-dialog__title',
),
'ui-dialog-content': dialogElement.shadowRoot.querySelector('main'),
'ui-dialog-buttonpane':
dialogElement.shadowRoot.querySelector('div[slot="footer"]'),
'ui-dialog-buttonset':
dialogElement.shadowRoot.querySelector('div[slot="footer"]'),
};
Object.keys(options.classes || {}).forEach((key) => {
if (dialogElements[key] && options.classes[key]) {
dialogElements[key].classList.add(...options.classes[key].split(' '));
}
});
const dock = options.dock || dialogElement.getAttribute('dock');
if (dock === 'right') {
// Options for the main Mercury Editor tray.
const isTrayCollapsed =
localStorage.getItem('mercury-dialog-dock-collapsed') === 'true';
if (!isTrayCollapsed) {
const dialogWidth = options.width;
const dialogHeight = options.height;
if (
options?.defaultWidth &&
!document.documentElement.style.getPropertyValue(
'--me-dialog-dock-right-width',
)
) {
document.documentElement.style.setProperty(
'--me-dialog-dock-right-width',
getCSSLength(options.defaultWidth),
);
}
if (dialogWidth) {
document.documentElement.style.setProperty(
'--me-dialog-dock-right-width',
getCSSLength(dialogWidth),
);
}
if (dialogHeight) {
document.documentElement.style.setProperty(
'--me-dialog-dock-right-height',
getCSSLength(dialogHeight),
);
}
} else {
document.documentElement.style.setProperty(
'--me-dialog-dock-right-width',
'10px',
);
}
} else {
// Options for all other dialogs.
if (options.width) {
document.documentElement.style.setProperty(
'--me-dialog-width',
getCSSLength(options.width),
);
}
if (options.height) {
document.documentElement.style.setProperty(
'--me-dialog-height',
getCSSLength(options.height),
);
}
}
// TODO: Determine if we need to persist the width and height of docked dialogs.
// if (options.dock) {
// if (savedWidth && ['right', 'left'].includes(options.dock)) {
// dialogElement.setAttribute('width', savedWidth);
// }
// if (savedHeight && ['top', 'bottom'].includes(options.dock)) {
// dialogElement.setAttribute('height', savedHeight);
// }
// }
if (options.drupalAutoButtons && !options.buttons) {
options.buttons = Drupal.behaviors.mercuryDialog.prepareDialogButtons(
$(dialogElement),
);
}
if (options.buttons && options.buttons.length) {
_createButtonPane(options.buttons);
}
return dialogElement;
}
/**
* Initializes the dialog element.
* @param {Object} settings The settings to apply to the dialog.
*/
function init(settings) {
// Wrap the element in a <mercury-dialog> if it isn't already.
if (element.tagName !== 'MERCURY-DIALOG') {
const wrapper = $('<mercury-dialog>')
.append($element)
.appendTo(_appendTo(settings.appendTo));
[dialogElement] = wrapper;
} else {
dialogElement = element;
}
applyOptions(settings);
}
/**
* Closes the dialog element.
* @param {Mixed} value The value to return from the dialog.
*/
function closeDialog(value) {
dispatchDialogEvent('beforeclose', dialog, $element.get(0));
// Stop observing height and width changes.
dockObserver.disconnect();
dialogMutationObserver.disconnect();
Drupal.detachBehaviors(element, null, 'unload');
element.close();
dialog.returnValue = value;
dispatchDialogEvent('afterclose', dialog, $element.get(0));
$element.remove();
}
/**
* Initializes and opens a dialog element.
* @param {Object} settings Dialog settings mimicking jQuery UI for compatibility.
*/
function openDialog(settings) {
settings = {
...drupalSettings.dialog,
...drupalSettings.mercuryEditor,
...baseOptions,
...settings,
};
dispatchDialogEvent('beforecreate', dialog, $element.get(0), settings);
init(settings);
dialogElement[settings.modal ? 'showModal' : 'show']();
// Set autoResize to false to prevent Drupal core's jQuery dialog from
// attempting to resize, which would throw an error.
const originalResizeSetting = settings.autoResize;
settings.autoResize = false;
dispatchDialogEvent('aftercreate', dialog, $element.get(0), settings);
settings.autoResize = originalResizeSetting;
// Add a mutation observer to the dialog element.
dialogMutationObserver.observe(dialogElement, {
childList: true,
attributes: true,
});
dialogElement.addEventListener('close', () => {
closeDialog();
});
}
dialog.show = () => {
openDialog({ modal: false });
};
dialog.showModal = () => {
openDialog({ modal: true });
};
dialog.applyOptions = (dialogOptions) => {
init(dialogOptions);
};
dialog.close = closeDialog;
return dialog;
};
Drupal.behaviors.mercuryDialog = {
attach: (context) => {
// Provide a known 'drupal-mercury-dialog' DOM element for Drupal-based modal
// dialogs. Non-modal dialogs are responsible for creating their own
// elements, since there can be multiple non-modal dialogs at a time.
if (!$('#drupal-mercury-dialog').length) {
// Add 'ui-front' jQuery UI class so jQuery UI widgets like autocomplete
// sit on top of dialogs. For more information see
// http://api.jqueryui.com/theming/stacking-elements/.
// @todo: .ui-front just sets a z-index of 100 which is not high enough
// to overlay Gin's Toolbar.
$(
'<mercury-dialog id="drupal-mercury-dialog"></mercury-dialog>',
).appendTo('body');
}
// Special behaviors specific when attaching content within a dialog.
// These behaviors usually fire after a validation error inside a dialog.
const $dialog = $(context).closest('mercury-dialog');
if ($dialog.length) {
$dialog.trigger('dialogButtonsChange');
}
},
prepareDialogButtons: function prepareDialogButtons($dialog) {
const buttons = [];
const dialogElement = $dialog[0];
const allFormActions = dialogElement.querySelectorAll('.form-actions');
if (allFormActions.length === 0) {
return buttons;
}
const formActions = allFormActions[allFormActions.length - 1];
formActions
.querySelectorAll('input[type=submit], a.button, a.action-link')
.forEach((button) => {
button.style.display = 'none';
buttons.push({
text: button.textContent || button.getAttribute('value'),
class: button.className,
click: function click(e) {
if (button.tagName.toLowerCase() === 'a') {
button.click();
} else {
['mousedown', 'mouseup', 'click'].forEach((type) => {
button.dispatchEvent(
new MouseEvent(type, {
bubbles: true,
cancelable: true,
view: window,
}),
);
});
}
e.preventDefault();
},
});
});
return buttons;
},
};
// Moves Layout Paragraphs form buttons into the dialog button pane.
function moveFormButtonsToDialog(event, dialog, $dialog) {
if ($dialog[0].tagName !== 'MERCURY-DIALOG') {
return;
}
if ($dialog.attr('id').indexOf('lpb-dialog-') === 0) {
const buttons =
Drupal.behaviors.mercuryDialog.prepareDialogButtons($dialog);
if (buttons.length) {
Drupal.mercuryDialog($dialog[0]).applyOptions({ buttons });
}
}
}
$(window).on('dialog:aftercreate', moveFormButtonsToDialog);
/**
* ResizeObserver callback that resizes the parent iframe based on
* the height of the child document html element.
*
* @param {HTMLIFrameElement} iframe The iframe element to resize.
* @return {Function} The callback function.
*/
function onBodyResize(iframe) {
return (entries) => {
// Resize the parent iframe based on the html's border box height.
if (iframe && entries.length) {
iframe.style.height = `${entries[0].borderBoxSize[0].blockSize + 1}px`;
iframe.style.width = `${entries[0].borderBoxSize[0].inlineSize + 1}px`;
}
};
}
/**
* Set a max-width on the iframe's <body> to match the dialog's
* max-width to prevent horizontal scrolling.
*
* @param {HTMLIFrameElement} iframe
* The iframe element within a mercury dialog.
* @param {HTMLBodyElement} framedBody
* The body element within the iframe.
*/
function setFrameBodyMaxWidth(iframe, framedBody) {
const dialogStyles = window.getComputedStyle(
iframe.closest('mercury-dialog').shadowRoot.querySelector('dialog'),
);
const dialogMainStyles = window.getComputedStyle(
iframe.closest('mercury-dialog').shadowRoot.querySelector('main'),
);
framedBody.style.maxWidth = `calc(${dialogStyles.getPropertyValue('max-width')} - ${dialogMainStyles.getPropertyValue('padding-left')} - ${dialogMainStyles.getPropertyValue('padding-right')} - 2px)`;
}
/**
* Resizes an iFrame to match the height of its inner <body> element.
* @param {HTMLIFrameElement} iframe The iframe element to resize.
*/
function resizeIframe(iframe) {
const framedBody = iframe.contentWindow.document.body;
framedBody.style.width = 'max-content';
framedBody.style.height = 'fit-content';
setFrameBodyMaxWidth(iframe, framedBody);
// Observe changes to the iframe's inner <body> dimensions.
new ResizeObserver(onBodyResize(iframe)).observe(framedBody, {
box: 'border-box',
});
}
function updateIframeSize(event, dialog, $dialog) {
if ($dialog[0].tagName !== 'MERCURY-DIALOG') {
return;
}
const iframe = $dialog[0].querySelector('iframe');
if (!iframe) {
return;
}
$dialog[0].style.setProperty('--me-dialog-height-default', 'fit-content');
const framedBody = iframe?.contentWindow?.document?.body;
iframe.onload = () => {
resizeIframe(iframe);
setFrameBodyMaxWidth(iframe, framedBody);
};
if (framedBody) {
window.addEventListener('resize', () => {
setFrameBodyMaxWidth(iframe, framedBody);
});
}
}
$(window).on('dialog:aftercreate', updateIframeSize);
// Store open modals.
const modalStack = [];
// The following event handlers are used to manage the modal stack.
// Since native dialog['modal'] elements live in the browser's top-level,
// we need to make sure any jQuery ui modals that are opened from within
// a mercury-dialog element get nested within the top-level modal.
// Otherwise, the jQuery dialog will be obscured by the mercury-dialog modal.
// See https://developer.chrome.com/blog/what-is-the-top-layer/
function addModalToStack(event, dialog, $dialog) {
if ($dialog[0].tagName !== 'MERCURY-DIALOG') {
return;
}
if (
$dialog[0].hasAttribute('modal') &&
$dialog[0].getAttribute('modal') !== 'false'
) {
modalStack.push($dialog);
}
}
$(window).on('dialog:aftercreate', addModalToStack);
function removeModalFromStack(event, dialog, $dialog) {
if ($dialog[0].tagName !== 'MERCURY-DIALOG') {
return;
}
if (
$dialog[0].hasAttribute('modal') &&
$dialog[0].getAttribute('modal') !== 'false'
) {
const index = modalStack.indexOf($dialog);
if (index > -1) {
modalStack.splice(index, 1);
}
}
}
$(window).on('dialog:beforeclose', removeModalFromStack);
function nestDialogInModal(event, dialog, $dialog) {
if ($dialog[0].tagName !== 'MERCURY-DIALOG') {
return;
}
if (modalStack.length > 0) {
const $parent = $dialog.parent('.ui-dialog');
const $overlay = $parent.next('.ui-widget-overlay');
modalStack.slice(-1)[0].append([$parent, $overlay]);
}
}
$(window).on('dialog:aftercreate', nestDialogInModal);
})(jQuery, Drupal, drupalSettings);
