mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2024-12-12 17:36:22 +01:00
7b7c1121bb
- Reset value/result on canceled closing - Fix close event still firing and closing the popup on multiple close/cancel calls, even though it *shouldn't* have happened - Remove manual removal of popup event listeners. Not needed, if they are only subscribed to controls inside the dialog or the dialog itself. That's cleaned up automatically. Is confusing otherwise anyway.
670 lines
30 KiB
JavaScript
670 lines
30 KiB
JavaScript
import { shouldSendOnEnter } from './RossAscends-mods.js';
|
|
import { power_user } from './power-user.js';
|
|
import { removeFromArray, runAfterAnimation, uuidv4 } from './utils.js';
|
|
|
|
/** @readonly */
|
|
/** @enum {Number} */
|
|
export const POPUP_TYPE = {
|
|
/** Main popup type. Containing any content displayed, with buttons below. Can also contain additional input controls. */
|
|
TEXT: 1,
|
|
/** Popup mainly made to confirm something, answering with a simple Yes/No or similar. Focus on the button controls. */
|
|
CONFIRM: 2,
|
|
/** Popup who's main focus is the input text field, which is displayed here. Can contain additional content above. Return value for this is the input string. */
|
|
INPUT: 3,
|
|
/** Popup without any button controls. Used to simply display content, with a small X in the corner. */
|
|
DISPLAY: 4,
|
|
/** Popup that displays an image to crop. Returns a cropped image in result. */
|
|
CROP: 5,
|
|
};
|
|
|
|
/** @readonly */
|
|
/** @enum {number?} */
|
|
export const POPUP_RESULT = {
|
|
AFFIRMATIVE: 1,
|
|
NEGATIVE: 0,
|
|
CANCELLED: null,
|
|
};
|
|
|
|
/**
|
|
* @typedef {object} PopupOptions
|
|
* @property {string|boolean?} [okButton=null] - Custom text for the OK button, or `true` to use the default (If set, the button will always be displayed, no matter the type of popup)
|
|
* @property {string|boolean?} [cancelButton=null] - Custom text for the Cancel button, or `true` to use the default (If set, the button will always be displayed, no matter the type of popup)
|
|
* @property {number?} [rows=1] - The number of rows for the input field
|
|
* @property {boolean?} [wide=false] - Whether to display the popup in wide mode (wide screen, 1/1 aspect ratio)
|
|
* @property {boolean?} [wider=false] - Whether to display the popup in wider mode (just wider, no height scaling)
|
|
* @property {boolean?} [large=false] - Whether to display the popup in large mode (90% of screen)
|
|
* @property {boolean?} [transparent=false] - Whether to display the popup in transparent mode (no background, border, shadow or anything, only its content)
|
|
* @property {boolean?} [allowHorizontalScrolling=false] - Whether to allow horizontal scrolling in the popup
|
|
* @property {boolean?} [allowVerticalScrolling=false] - Whether to allow vertical scrolling in the popup
|
|
* @property {'slow'|'fast'|'none'?} [animation='slow'] - Animation speed for the popup (opening, closing, ...)
|
|
* @property {POPUP_RESULT|number?} [defaultResult=POPUP_RESULT.AFFIRMATIVE] - The default result of this popup when Enter is pressed. Can be changed from `POPUP_RESULT.AFFIRMATIVE`.
|
|
* @property {CustomPopupButton[]|string[]?} [customButtons=null] - Custom buttons to add to the popup. If only strings are provided, the buttons will be added with default options, and their result will be in order from `2` onward.
|
|
* @property {CustomPopupInput[]?} [customInputs=null] - Custom inputs to add to the popup. The display below the content and the input box, one by one.
|
|
* @property {(popup: Popup) => boolean?} [onClosing=null] - Handler called before the popup closes, return `false` to cancel the close
|
|
* @property {(popup: Popup) => void?} [onClose=null] - Handler called after the popup closes, but before the DOM is cleaned up
|
|
* @property {number?} [cropAspect=null] - Aspect ratio for the crop popup
|
|
* @property {string?} [cropImage=null] - Image URL to display in the crop popup
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} CustomPopupButton
|
|
* @property {string} text - The text of the button
|
|
* @property {POPUP_RESULT|number?} [result] - The result of the button - can also be a custom result value to make be able to find out that this button was clicked. If no result is specified, this button will **not** close the popup.
|
|
* @property {string[]|string?} [classes] - Optional custom CSS classes applied to the button
|
|
* @property {()=>void?} [action] - Optional action to perform when the button is clicked
|
|
* @property {boolean?} [appendAtEnd] - Whether to append the button to the end of the popup - by default it will be prepended
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} CustomPopupInput
|
|
* @property {string} id - The id for the html element
|
|
* @property {string} label - The label text for the input
|
|
* @property {string?} [tooltip=null] - Optional tooltip icon displayed behind the label
|
|
* @property {boolean?} [defaultState=false] - The default state when opening the popup (false if not set)
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} ShowPopupHelper
|
|
* Local implementation of the helper functionality to show several popups.
|
|
*
|
|
* Should be called via `Popup.show.xxxx()`.
|
|
*/
|
|
const showPopupHelper = {
|
|
/**
|
|
* Asynchronously displays an input popup with the given header and text, and returns the user's input.
|
|
*
|
|
* @param {string} header - The header text for the popup.
|
|
* @param {string} text - The main text for the popup.
|
|
* @param {string} [defaultValue=''] - The default value for the input field.
|
|
* @param {PopupOptions} [popupOptions={}] - Options for the popup.
|
|
* @return {Promise<string?>} A Promise that resolves with the user's input.
|
|
*/
|
|
input: async (header, text, defaultValue = '', popupOptions = {}) => {
|
|
const content = PopupUtils.BuildTextWithHeader(header, text);
|
|
const popup = new Popup(content, POPUP_TYPE.INPUT, defaultValue, popupOptions);
|
|
const value = await popup.show();
|
|
return value ? String(value) : null;
|
|
},
|
|
|
|
/**
|
|
* Asynchronously displays a confirmation popup with the given header and text, returning the clicked result button value.
|
|
*
|
|
* @param {string?} header - The header text for the popup.
|
|
* @param {string?} text - The main text for the popup.
|
|
* @param {PopupOptions} [popupOptions={}] - Options for the popup.
|
|
* @return {Promise<POPUP_RESULT>} A Promise that resolves with the result of the user's interaction.
|
|
*/
|
|
confirm: async (header, text, popupOptions = {}) => {
|
|
const content = PopupUtils.BuildTextWithHeader(header, text);
|
|
const popup = new Popup(content, POPUP_TYPE.CONFIRM, null, popupOptions);
|
|
const result = await popup.show();
|
|
if (typeof result === 'string' || typeof result === 'boolean') throw new Error(`Invalid popup result. CONFIRM popups only support numbers, or null. Result: ${result}`);
|
|
return result;
|
|
},
|
|
};
|
|
|
|
export class Popup {
|
|
/** @readonly @type {POPUP_TYPE} */ type;
|
|
|
|
/** @readonly @type {string} */ id;
|
|
|
|
/** @readonly @type {HTMLDialogElement} */ dlg;
|
|
/** @readonly @type {HTMLDivElement} */ body;
|
|
/** @readonly @type {HTMLDivElement} */ content;
|
|
/** @readonly @type {HTMLTextAreaElement} */ mainInput;
|
|
/** @readonly @type {HTMLDivElement} */ inputControls;
|
|
/** @readonly @type {HTMLDivElement} */ buttonControls;
|
|
/** @readonly @type {HTMLDivElement} */ okButton;
|
|
/** @readonly @type {HTMLDivElement} */ cancelButton;
|
|
/** @readonly @type {HTMLDivElement} */ closeButton;
|
|
/** @readonly @type {HTMLDivElement} */ cropWrap;
|
|
/** @readonly @type {HTMLImageElement} */ cropImage;
|
|
/** @readonly @type {POPUP_RESULT|number?} */ defaultResult;
|
|
/** @readonly @type {CustomPopupButton[]|string[]?} */ customButtons;
|
|
/** @readonly @type {CustomPopupInput[]} */ customInputs;
|
|
|
|
/** @type {(popup: Popup) => boolean?} */ onClosing;
|
|
/** @type {(popup: Popup) => void?} */ onClose;
|
|
|
|
/** @type {POPUP_RESULT|number} */ result;
|
|
/** @type {any} */ value;
|
|
/** @type {Map<string,boolean>?} */ inputResults;
|
|
/** @type {any} */ cropData;
|
|
|
|
/** @type {HTMLElement} */ lastFocus;
|
|
|
|
/** @type {Promise<any>} */ #promise;
|
|
/** @type {(result: any) => any} */ #resolver;
|
|
|
|
/**
|
|
* Constructs a new Popup object with the given text content, type, inputValue, and options
|
|
*
|
|
* @param {JQuery<HTMLElement>|string|Element} content - Text content to display in the popup
|
|
* @param {POPUP_TYPE} type - The type of the popup
|
|
* @param {string} [inputValue=''] - The initial value of the input field
|
|
* @param {PopupOptions} [options={}] - Additional options for the popup
|
|
*/
|
|
constructor(content, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, wider = false, large = false, transparent = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, animation = 'fast', defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null, customInputs = null, onClosing = null, onClose = null, cropAspect = null, cropImage = null } = {}) {
|
|
Popup.util.popups.push(this);
|
|
|
|
// Make this popup uniquely identifiable
|
|
this.id = uuidv4();
|
|
this.type = type;
|
|
|
|
// Utilize event handlers being passed in
|
|
this.onClosing = onClosing;
|
|
this.onClose = onClose;
|
|
|
|
/**@type {HTMLTemplateElement}*/
|
|
const template = document.querySelector('#popup_template');
|
|
// @ts-ignore
|
|
this.dlg = template.content.cloneNode(true).querySelector('.popup');
|
|
this.body = this.dlg.querySelector('.popup-body');
|
|
this.content = this.dlg.querySelector('.popup-content');
|
|
this.mainInput = this.dlg.querySelector('.popup-input');
|
|
this.inputControls = this.dlg.querySelector('.popup-inputs');
|
|
this.buttonControls = this.dlg.querySelector('.popup-controls');
|
|
this.okButton = this.dlg.querySelector('.popup-button-ok');
|
|
this.cancelButton = this.dlg.querySelector('.popup-button-cancel');
|
|
this.closeButton = this.dlg.querySelector('.popup-button-close');
|
|
this.cropWrap = this.dlg.querySelector('.popup-crop-wrap');
|
|
this.cropImage = this.dlg.querySelector('.popup-crop-image');
|
|
|
|
this.dlg.setAttribute('data-id', this.id);
|
|
if (wide) this.dlg.classList.add('wide_dialogue_popup');
|
|
if (wider) this.dlg.classList.add('wider_dialogue_popup');
|
|
if (large) this.dlg.classList.add('large_dialogue_popup');
|
|
if (transparent) this.dlg.classList.add('transparent_dialogue_popup');
|
|
if (allowHorizontalScrolling) this.dlg.classList.add('horizontal_scrolling_dialogue_popup');
|
|
if (allowVerticalScrolling) this.dlg.classList.add('vertical_scrolling_dialogue_popup');
|
|
if (animation) this.dlg.classList.add('popup--animation-' + animation);
|
|
|
|
// If custom button captions are provided, we set them beforehand
|
|
this.okButton.textContent = typeof okButton === 'string' ? okButton : 'OK';
|
|
this.okButton.dataset.i18n = this.okButton.textContent;
|
|
this.cancelButton.textContent = typeof cancelButton === 'string' ? cancelButton : template.getAttribute('popup-button-cancel');
|
|
this.cancelButton.dataset.i18n = this.cancelButton.textContent;
|
|
|
|
this.defaultResult = defaultResult;
|
|
this.customButtons = customButtons;
|
|
this.customButtons?.forEach((x, index) => {
|
|
/** @type {CustomPopupButton} */
|
|
const button = typeof x === 'string' ? { text: x, result: index + 2 } : x;
|
|
|
|
const buttonElement = document.createElement('div');
|
|
buttonElement.classList.add('menu_button', 'popup-button-custom', 'result-control');
|
|
buttonElement.classList.add(...(button.classes ?? []));
|
|
buttonElement.dataset.result = String(button.result); // This is expected to also write 'null' or 'staging', to indicate cancel and no action respectively
|
|
buttonElement.textContent = button.text;
|
|
buttonElement.dataset.i18n = buttonElement.textContent;
|
|
buttonElement.tabIndex = 0;
|
|
|
|
if (button.appendAtEnd) {
|
|
this.buttonControls.appendChild(buttonElement);
|
|
} else {
|
|
this.buttonControls.insertBefore(buttonElement, this.okButton);
|
|
}
|
|
|
|
if (typeof button.action === 'function') {
|
|
buttonElement.addEventListener('click', button.action);
|
|
}
|
|
});
|
|
|
|
this.customInputs = customInputs;
|
|
this.customInputs?.forEach(input => {
|
|
if (!input.id || !(typeof input.id === 'string')) {
|
|
console.warn('Given custom input does not have a valid id set');
|
|
return;
|
|
}
|
|
|
|
const label = document.createElement('label');
|
|
label.classList.add('checkbox_label', 'justifyCenter');
|
|
label.setAttribute('for', input.id);
|
|
const inputElement = document.createElement('input');
|
|
inputElement.type = 'checkbox';
|
|
inputElement.id = input.id;
|
|
inputElement.checked = input.defaultState ?? false;
|
|
label.appendChild(inputElement);
|
|
const labelText = document.createElement('span');
|
|
labelText.innerText = input.label;
|
|
labelText.dataset.i18n = input.label;
|
|
label.appendChild(labelText);
|
|
|
|
if (input.tooltip) {
|
|
const tooltip = document.createElement('div');
|
|
tooltip.classList.add('fa-solid', 'fa-circle-info', 'opacity50p');
|
|
tooltip.title = input.tooltip;
|
|
tooltip.dataset.i18n = '[title]' + input.tooltip;
|
|
label.appendChild(tooltip);
|
|
}
|
|
|
|
this.inputControls.appendChild(label);
|
|
});
|
|
|
|
// Set the default button class
|
|
const defaultButton = this.buttonControls.querySelector(`[data-result="${this.defaultResult}"]`);
|
|
if (defaultButton) defaultButton.classList.add('menu_button_default');
|
|
|
|
// Styling differences depending on the popup type
|
|
// General styling for all types first, that might be overriden for specific types below
|
|
this.mainInput.style.display = 'none';
|
|
this.inputControls.style.display = customInputs ? 'block' : 'none';
|
|
this.closeButton.style.display = 'none';
|
|
this.cropWrap.style.display = 'none';
|
|
|
|
switch (type) {
|
|
case POPUP_TYPE.TEXT: {
|
|
if (!cancelButton) this.cancelButton.style.display = 'none';
|
|
break;
|
|
}
|
|
case POPUP_TYPE.CONFIRM: {
|
|
if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-yes');
|
|
if (!cancelButton) this.cancelButton.textContent = template.getAttribute('popup-button-no');
|
|
break;
|
|
}
|
|
case POPUP_TYPE.INPUT: {
|
|
this.mainInput.style.display = 'block';
|
|
if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-save');
|
|
break;
|
|
}
|
|
case POPUP_TYPE.DISPLAY: {
|
|
this.buttonControls.style.display = 'none';
|
|
this.closeButton.style.display = 'block';
|
|
break;
|
|
}
|
|
case POPUP_TYPE.CROP: {
|
|
this.cropWrap.style.display = 'block';
|
|
this.cropImage.src = cropImage;
|
|
if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-crop');
|
|
$(this.cropImage).cropper({
|
|
aspectRatio: cropAspect ?? 2 / 3,
|
|
autoCropArea: 1,
|
|
viewMode: 2,
|
|
rotatable: false,
|
|
crop: (event) => {
|
|
this.cropData = event.detail;
|
|
this.cropData.want_resize = !power_user.never_resize_avatars;
|
|
},
|
|
});
|
|
break;
|
|
}
|
|
default: {
|
|
console.warn('Unknown popup type.', type);
|
|
break;
|
|
}
|
|
}
|
|
|
|
this.mainInput.value = inputValue;
|
|
this.mainInput.rows = rows ?? 1;
|
|
|
|
this.content.innerHTML = '';
|
|
if (content instanceof jQuery) {
|
|
$(this.content).append(content);
|
|
} else if (content instanceof HTMLElement) {
|
|
this.content.append(content);
|
|
} else if (typeof content == 'string') {
|
|
this.content.innerHTML = content;
|
|
} else {
|
|
console.warn('Unknown popup text type. Should be jQuery, HTMLElement or string.', content);
|
|
}
|
|
|
|
// Already prepare the auto-focus control by adding the "autofocus" attribute, this should be respected by showModal()
|
|
this.setAutoFocus({ applyAutoFocus: true });
|
|
|
|
// Set focus event that remembers the focused element
|
|
this.dlg.addEventListener('focusin', (evt) => { if (evt.target instanceof HTMLElement && evt.target != this.dlg) this.lastFocus = evt.target; });
|
|
|
|
// Bind event listeners for all result controls to their defined event type
|
|
this.dlg.querySelectorAll('[data-result]').forEach(resultControl => {
|
|
if (!(resultControl instanceof HTMLElement)) return;
|
|
// If no value was set, we exit out and don't bind an action
|
|
if (String(resultControl.dataset.result) === String(undefined)) return;
|
|
|
|
// Make sure that both `POPUP_RESULT` numbers and also `null` as 'cancelled' are supported
|
|
const result = String(resultControl.dataset.result) === String(null) ? null
|
|
: Number(resultControl.dataset.result);
|
|
|
|
if (result !== null && isNaN(result)) throw new Error('Invalid result control. Result must be a number. ' + resultControl.dataset.result);
|
|
const type = resultControl.dataset.resultEvent || 'click';
|
|
resultControl.addEventListener(type, async () => await this.complete(result));
|
|
});
|
|
|
|
// Bind dialog listeners manually, so we can be sure context is preserved
|
|
const cancelListener = async (evt) => {
|
|
evt.preventDefault();
|
|
evt.stopPropagation();
|
|
await this.complete(POPUP_RESULT.CANCELLED);
|
|
};
|
|
this.dlg.addEventListener('cancel', cancelListener.bind(this));
|
|
|
|
// Don't ask me why this is needed. I don't get it. But we have to keep it.
|
|
// We make sure that the modal on it's own doesn't hide. Dunno why, if onClosing is triggered multiple times through the cancel event, and stopped
|
|
// It seems to just call 'close' on the dialog even if the 'cancel' event was prevented.
|
|
// Here, we just say that close should not happen if the dalog has no result.
|
|
const closeListener = async (evt) => {
|
|
if (this.result === undefined) {
|
|
evt.preventDefault();
|
|
evt.stopPropagation();
|
|
this.dlg.showModal();
|
|
}
|
|
};
|
|
this.dlg.addEventListener('close', closeListener.bind(this));
|
|
|
|
const keyListener = async (evt) => {
|
|
switch (evt.key) {
|
|
case 'Enter': {
|
|
// CTRL+Enter counts as a closing action, but all other modifiers (ALT, SHIFT) should not trigger this
|
|
if (evt.altKey || evt.shiftKey)
|
|
return;
|
|
|
|
// Check if we are the currently active popup
|
|
if (this.dlg != document.activeElement?.closest('.popup'))
|
|
return;
|
|
|
|
// Check if the current focus is a result control. Only should we apply the complete action
|
|
const resultControl = document.activeElement?.closest('.result-control');
|
|
if (!resultControl)
|
|
return;
|
|
|
|
// Check if we are inside an input type text or a textarea field and send on enter is disabled
|
|
const textarea = document.activeElement?.closest('textarea');
|
|
if (textarea instanceof HTMLTextAreaElement && !shouldSendOnEnter())
|
|
return;
|
|
const input = document.activeElement?.closest('input[type="text"]');
|
|
if (input instanceof HTMLInputElement && !shouldSendOnEnter())
|
|
return;
|
|
|
|
evt.preventDefault();
|
|
evt.stopPropagation();
|
|
const result = Number(document.activeElement.getAttribute('data-result') ?? this.defaultResult);
|
|
|
|
// Call complete on the popup. Make sure that we handle `onClosing` cancels correctly and don't remove the listener then.
|
|
await this.complete(result);
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
};
|
|
this.dlg.addEventListener('keydown', keyListener.bind(this));
|
|
}
|
|
|
|
/**
|
|
* Asynchronously shows the popup element by appending it to the document body,
|
|
* setting its display to 'block' and focusing on the input if the popup type is INPUT.
|
|
*
|
|
* @returns {Promise<string|number|boolean?>} A promise that resolves with the value of the popup when it is completed.
|
|
*/
|
|
async show() {
|
|
document.body.append(this.dlg);
|
|
|
|
// Run opening animation
|
|
this.dlg.setAttribute('opening', '');
|
|
|
|
this.dlg.showModal();
|
|
|
|
// We need to fix the toastr to be present inside this dialog
|
|
fixToastrForDialogs();
|
|
|
|
runAfterAnimation(this.dlg, () => {
|
|
this.dlg.removeAttribute('opening');
|
|
});
|
|
|
|
this.#promise = new Promise((resolve) => {
|
|
this.#resolver = resolve;
|
|
});
|
|
return this.#promise;
|
|
}
|
|
|
|
setAutoFocus({ applyAutoFocus = false } = {}) {
|
|
/** @type {HTMLElement} */
|
|
let control;
|
|
|
|
// Try to find if we have an autofocus control already present
|
|
control = this.dlg.querySelector('[autofocus]');
|
|
|
|
// If not, find the default control for this popup type
|
|
if (!control) {
|
|
switch (this.type) {
|
|
case POPUP_TYPE.INPUT: {
|
|
control = this.mainInput;
|
|
break;
|
|
}
|
|
default:
|
|
// Select default button
|
|
control = this.buttonControls.querySelector(`[data-result="${this.defaultResult}"]`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (applyAutoFocus) {
|
|
control.setAttribute('autofocus', '');
|
|
// Manually enable tabindex too, as this might only be applied by the interactable functionality in the background, but too late for HTML autofocus
|
|
// interactable only gets applied when inserted into the DOM
|
|
control.tabIndex = 0;
|
|
} else {
|
|
control.focus();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Completes the popup and sets its result and value
|
|
*
|
|
* The completion handling will make the popup return the result to the original show promise.
|
|
*
|
|
* There will be two different types of result values:
|
|
* - popup with `POPUP_TYPE.INPUT` will return the input value - or `false` on negative and `null` on cancelled
|
|
* - All other will return the result value as provided as `POPUP_RESULT` or a custom number value
|
|
*
|
|
* <b>IMPORTANT:</b> If the popup closing was cancelled via the `onClosing` handler, the return value will be `Promise<undefined>`.
|
|
*
|
|
* @param {POPUP_RESULT|number} result - The result of the popup (either an existing `POPUP_RESULT` or a custom result value)
|
|
*
|
|
* @returns {Promise<string|number|boolean|undefined?>} A promise that resolves with the value of the popup when it is completed. <b>Returns `undefined` if the closing action was cancelled.</b>
|
|
*/
|
|
async complete(result) {
|
|
// In all cases besides INPUT the popup value should be the result
|
|
/** @type {POPUP_RESULT|number|boolean|string?} */
|
|
let value = result;
|
|
// Input type have special results, so the input can be accessed directly without the need to save the popup and access both result and value
|
|
if (this.type === POPUP_TYPE.INPUT) {
|
|
if (result >= POPUP_RESULT.AFFIRMATIVE) value = this.mainInput.value;
|
|
else if (result === POPUP_RESULT.NEGATIVE) value = false;
|
|
else if (result === POPUP_RESULT.CANCELLED) value = null;
|
|
else value = false; // Might a custom negative value?
|
|
}
|
|
|
|
// Cropped image should be returned as a data URL
|
|
if (this.type === POPUP_TYPE.CROP) {
|
|
value = result >= POPUP_RESULT.AFFIRMATIVE
|
|
? $(this.cropImage).data('cropper').getCroppedCanvas().toDataURL('image/jpeg')
|
|
: null;
|
|
}
|
|
|
|
if (this.customInputs?.length) {
|
|
this.inputResults = new Map(this.customInputs.map(input => {
|
|
/** @type {HTMLInputElement} */
|
|
const inputControl = this.dlg.querySelector(`#${input.id}`);
|
|
return [inputControl.id, inputControl.checked];
|
|
}));
|
|
}
|
|
|
|
this.value = value;
|
|
this.result = result;
|
|
|
|
if (this.onClosing) {
|
|
const shouldClose = this.onClosing(this);
|
|
if (!shouldClose) {
|
|
// Set values back if we cancel out of closing the popup
|
|
this.value = undefined;
|
|
this.result = undefined;
|
|
this.inputResults = undefined;
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
Popup.util.lastResult = { value, result, inputResults: this.inputResults };
|
|
this.#hide();
|
|
|
|
return this.#promise;
|
|
}
|
|
|
|
/**
|
|
* Hides the popup, using the internal resolver to return the value to the original show promise
|
|
*/
|
|
#hide() {
|
|
// We close the dialog, first running the animation
|
|
this.dlg.setAttribute('closing', '');
|
|
|
|
// Once the hiding starts, we need to fix the toastr to the layer below
|
|
fixToastrForDialogs();
|
|
|
|
// After the dialog is actually completely closed, remove it from the DOM
|
|
runAfterAnimation(this.dlg, () => {
|
|
// Call the close on the dialog
|
|
this.dlg.close();
|
|
|
|
// Run a possible custom handler right before DOM removal
|
|
if (this.onClose) {
|
|
this.onClose(this);
|
|
}
|
|
|
|
// Remove it from the dom
|
|
this.dlg.remove();
|
|
|
|
// Remove it from the popup references
|
|
removeFromArray(Popup.util.popups, this);
|
|
|
|
// If there is any popup below this one, see if we can set the focus
|
|
if (Popup.util.popups.length > 0) {
|
|
const activeDialog = document.activeElement?.closest('.popup');
|
|
const id = activeDialog?.getAttribute('data-id');
|
|
const popup = Popup.util.popups.find(x => x.id == id);
|
|
if (popup) {
|
|
if (popup.lastFocus) popup.lastFocus.focus();
|
|
else popup.setAutoFocus();
|
|
}
|
|
}
|
|
|
|
this.#resolver(this.value);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Show a popup with any of the given helper methods. Use `await` to make them blocking.
|
|
*/
|
|
static show = showPopupHelper;
|
|
|
|
/**
|
|
* Utility for popup and popup management.
|
|
*
|
|
* Contains the list of all currently open popups, and it'll remember the result of the last closed popup.
|
|
*/
|
|
static util = {
|
|
/** @readonly @type {Popup[]} Remember all popups */
|
|
popups: [],
|
|
|
|
/** @type {{value: any, result: POPUP_RESULT|number?, inputResults: Map<string, boolean>?}?} Last popup result */
|
|
lastResult: null,
|
|
|
|
/** @returns {boolean} Checks if any modal popup dialog is open */
|
|
isPopupOpen() {
|
|
return Popup.util.popups.length > 0;
|
|
},
|
|
|
|
/**
|
|
* Returns the topmost modal layer in the document. If there is an open dialog popup,
|
|
* it returns the dialog element. Otherwise, it returns the document body.
|
|
*
|
|
* @return {HTMLElement} The topmost modal layer element
|
|
*/
|
|
getTopmostModalLayer() {
|
|
return getTopmostModalLayer();
|
|
},
|
|
};
|
|
}
|
|
|
|
class PopupUtils {
|
|
/**
|
|
* Builds popup content with header and text below
|
|
*
|
|
* @param {string} header - The header to be added to the text
|
|
* @param {string} text - The main text content
|
|
*/
|
|
static BuildTextWithHeader(header, text) {
|
|
if (!header) {
|
|
return text;
|
|
}
|
|
return `<h3>${header}</h3>
|
|
${text}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Displays a blocking popup with a given content and type
|
|
*
|
|
* @param {JQuery<HTMLElement>|string|Element} content - Content or text to display in the popup
|
|
* @param {POPUP_TYPE} type
|
|
* @param {string} inputValue - Value to set the input to
|
|
* @param {PopupOptions} [popupOptions={}] - Options for the popup
|
|
* @returns {Promise<POPUP_RESULT|string|boolean?>} The value for this popup, which can either be the popup retult or the input value if chosen
|
|
*/
|
|
export function callGenericPopup(content, type, inputValue = '', popupOptions = {}) {
|
|
const popup = new Popup(
|
|
content,
|
|
type,
|
|
inputValue,
|
|
popupOptions,
|
|
);
|
|
return popup.show();
|
|
}
|
|
|
|
/**
|
|
* Returns the topmost modal layer in the document. If there is an open dialog,
|
|
* it returns the dialog element. Otherwise, it returns the document body.
|
|
*
|
|
* @return {HTMLElement} The topmost modal layer element
|
|
*/
|
|
export function getTopmostModalLayer() {
|
|
const dlg = Array.from(document.querySelectorAll('dialog[open]:not([closing])')).pop();
|
|
if (dlg instanceof HTMLElement) return dlg;
|
|
return document.body;
|
|
}
|
|
|
|
/**
|
|
* Fixes the issue with toastr not displaying on top of the dialog by moving the toastr container inside the dialog or back to the main body
|
|
*/
|
|
export function fixToastrForDialogs() {
|
|
// Hacky way of getting toastr to actually display on top of the popup...
|
|
|
|
const dlg = Array.from(document.querySelectorAll('dialog[open]:not([closing])')).pop();
|
|
|
|
let toastContainer = document.getElementById('toast-container');
|
|
const isAlreadyPresent = !!toastContainer;
|
|
if (!toastContainer) {
|
|
toastContainer = document.createElement('div');
|
|
toastContainer.setAttribute('id', 'toast-container');
|
|
if (toastr.options.positionClass) toastContainer.classList.add(toastr.options.positionClass);
|
|
}
|
|
|
|
// Check if toastr is already a child. If not, we need to move it inside this dialog.
|
|
// This is either the existing toastr container or the newly created one.
|
|
if (dlg && !dlg.contains(toastContainer)) {
|
|
dlg?.appendChild(toastContainer);
|
|
return;
|
|
}
|
|
|
|
// Now another case is if we only have one popup and that is currently closing. In that case the toastr container exists,
|
|
// but we don't have an open dialog to move it into. It's just inside the existing one that will be gone in milliseconds.
|
|
// To prevent new toasts from being showing up in there and then vanish in an instant,
|
|
// we move the toastr back to the main body, or delete if its empty
|
|
if (!dlg && isAlreadyPresent) {
|
|
if (!toastContainer.childNodes.length) {
|
|
toastContainer.remove();
|
|
} else {
|
|
document.body.appendChild(toastContainer);
|
|
toastContainer.classList.add('toast-top-center');
|
|
}
|
|
}
|
|
}
|