2024-07-01 15:32:24 +02:00
import { shouldSendOnEnter } from './RossAscends-mods.js' ;
2024-06-25 01:05:35 +02:00
import { power _user } from './power-user.js' ;
2024-06-09 22:02:51 +02:00
import { removeFromArray , runAfterAnimation , uuidv4 } from './utils.js' ;
2024-04-09 00:42:33 +02:00
2024-05-25 00:44:09 +02:00
/** @readonly */
/** @enum {Number} */
2024-04-09 00:42:33 +02:00
export const POPUP _TYPE = {
2024-06-23 02:32:06 +02:00
/** 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 ,
2024-06-25 01:05:35 +02:00
/** Popup that displays an image to crop. Returns a cropped image in result. */
CROP : 5 ,
2024-04-09 00:42:33 +02:00
} ;
2024-05-25 00:44:09 +02:00
/** @readonly */
2024-06-09 22:02:51 +02:00
/** @enum {number?} */
2024-04-09 00:42:33 +02:00
export const POPUP _RESULT = {
2024-06-23 02:32:06 +02:00
AFFIRMATIVE : 1 ,
NEGATIVE : 0 ,
CANCELLED : null ,
2024-04-09 00:42:33 +02:00
} ;
2024-05-25 00:44:09 +02:00
/ * *
* @ typedef { object } PopupOptions
2024-06-23 12:26:52 +02:00
* @ 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
2024-06-30 20:49:16 +02:00
* @ property { 'slow' | 'fast' | 'none' ? } [ animation = 'slow' ] - Animation speed for the popup ( opening , closing , ... )
2024-06-23 12:26:52 +02:00
* @ 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 .
2024-06-27 02:28:25 +02:00
* @ property { CustomPopupInput [ ] ? } [ customInputs = null ] - Custom inputs to add to the popup . The display below the content and the input box , one by one .
2024-08-19 22:49:15 +02:00
* @ property { ( popup : Popup ) => Promise < boolean ? > | boolean ? } [ onClosing = null ] - Handler called before the popup closes , return ` false ` to cancel the close
2024-06-23 12:26:52 +02:00
* @ property { ( popup : Popup ) => void ? } [ onClose = null ] - Handler called after the popup closes , but before the DOM is cleaned up
2024-06-25 01:05:35 +02:00
* @ property { number ? } [ cropAspect = null ] - Aspect ratio for the crop popup
* @ property { string ? } [ cropImage = null ] - Image URL to display in the crop popup
2024-05-25 00:44:09 +02:00
* /
2024-04-09 00:42:33 +02:00
2024-05-25 00:44:09 +02:00
/ * *
* @ typedef { object } CustomPopupButton
* @ property { string } text - The text of the button
2024-06-25 10:54:59 +02:00
* @ 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 .
2024-05-25 00:44:09 +02:00
* @ 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
* /
2024-04-09 00:42:33 +02:00
2024-06-27 02:28:25 +02:00
/ * *
* @ 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 )
* /
2024-06-09 22:02:51 +02:00
/ * *
* @ 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 .
*
2024-07-26 18:17:32 +02:00
* @ param { string ? } header - The header text for the popup .
* @ param { string ? } text - The main text for the popup .
2024-06-09 22:02:51 +02:00
* @ 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 ;
} ,
2024-06-26 05:29:08 +02:00
/ * *
* Asynchronously displays a confirmation popup with the given header and text , returning the clicked result button value .
*
2024-06-27 01:01:43 +02:00
* @ param { string ? } header - The header text for the popup .
* @ param { string ? } text - The main text for the popup .
2024-06-26 05:29:08 +02:00
* @ 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 ;
2024-06-30 20:44:29 +02:00
} ,
2024-07-28 22:39:11 +02:00
/ * *
* Asynchronously displays a text 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 .
* /
text : async ( header , text , popupOptions = { } ) => {
const content = PopupUtils . BuildTextWithHeader ( header , text ) ;
const popup = new Popup ( content , POPUP _TYPE . TEXT , null , popupOptions ) ;
const result = await popup . show ( ) ;
if ( typeof result === 'string' || typeof result === 'boolean' ) throw new Error ( ` Invalid popup result. TEXT popups only support numbers, or null. Result: ${ result } ` ) ;
return result ;
} ,
2024-06-23 22:09:22 +02:00
} ;
2024-06-09 22:02:51 +02:00
2024-04-09 00:42:33 +02:00
export class Popup {
2024-06-27 02:39:59 +02:00
/** @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 ;
2024-04-09 00:42:33 +02:00
2024-08-19 22:49:15 +02:00
/** @type {(popup: Popup) => Promise<boolean?>|boolean?} */ onClosing ;
2024-06-22 04:54:13 +02:00
/** @type {(popup: Popup) => void?} */ onClose ;
2024-05-30 05:11:23 +02:00
/** @type {POPUP_RESULT|number} */ result ;
/** @type {any} */ value ;
2024-06-27 02:28:25 +02:00
/** @type {Map<string,boolean>?} */ inputResults ;
2024-06-25 01:05:35 +02:00
/** @type {any} */ cropData ;
2024-04-09 00:42:33 +02:00
2024-05-30 05:11:23 +02:00
/** @type {HTMLElement} */ lastFocus ;
2024-06-27 02:39:59 +02:00
/** @type {Promise<any>} */ # promise ;
/** @type {(result: any) => any} */ # resolver ;
2024-07-14 22:37:22 +02:00
/** @type {boolean} */ # isClosingPrevented ;
2024-04-09 00:42:33 +02:00
/ * *
2024-06-09 22:02:51 +02:00
* Constructs a new Popup object with the given text content , type , inputValue , and options
2024-05-25 00:44:09 +02:00
*
2024-06-09 22:02:51 +02:00
* @ param { JQuery < HTMLElement > | string | Element } content - Text content to display in the popup
2024-05-25 00:44:09 +02:00
* @ 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
2024-04-09 00:42:33 +02:00
* /
2024-06-30 22:30:15 +02:00
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 } = { } ) {
2024-06-09 22:02:51 +02:00
Popup . util . popups . push ( this ) ;
2024-05-30 05:11:23 +02:00
// Make this popup uniquely identifiable
this . id = uuidv4 ( ) ;
2024-04-09 00:42:33 +02:00
this . type = type ;
2024-06-22 04:54:13 +02:00
// Utilize event handlers being passed in
this . onClosing = onClosing ;
this . onClose = onClose ;
2024-04-09 00:42:33 +02:00
/**@type {HTMLTemplateElement}*/
2024-05-31 21:59:26 +02:00
const template = document . querySelector ( '#popup_template' ) ;
2024-04-09 00:42:33 +02:00
// @ts-ignore
2024-05-31 21:59:26 +02:00
this . dlg = template . content . cloneNode ( true ) . querySelector ( '.popup' ) ;
2024-06-09 22:02:51 +02:00
this . body = this . dlg . querySelector ( '.popup-body' ) ;
this . content = this . dlg . querySelector ( '.popup-content' ) ;
2024-06-27 02:28:25 +02:00
this . mainInput = this . dlg . querySelector ( '.popup-input' ) ;
this . inputControls = this . dlg . querySelector ( '.popup-inputs' ) ;
this . buttonControls = this . dlg . querySelector ( '.popup-controls' ) ;
2024-06-23 02:32:06 +02:00
this . okButton = this . dlg . querySelector ( '.popup-button-ok' ) ;
this . cancelButton = this . dlg . querySelector ( '.popup-button-cancel' ) ;
this . closeButton = this . dlg . querySelector ( '.popup-button-close' ) ;
2024-06-25 01:05:35 +02:00
this . cropWrap = this . dlg . querySelector ( '.popup-crop-wrap' ) ;
this . cropImage = this . dlg . querySelector ( '.popup-crop-image' ) ;
2024-05-30 05:11:23 +02:00
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' ) ;
2024-06-23 12:26:52 +02:00
if ( transparent ) this . dlg . classList . add ( 'transparent_dialogue_popup' ) ;
2024-05-30 05:11:23 +02:00
if ( allowHorizontalScrolling ) this . dlg . classList . add ( 'horizontal_scrolling_dialogue_popup' ) ;
if ( allowVerticalScrolling ) this . dlg . classList . add ( 'vertical_scrolling_dialogue_popup' ) ;
2024-06-30 20:49:16 +02:00
if ( animation ) this . dlg . classList . add ( 'popup--animation-' + animation ) ;
2024-04-09 00:42:33 +02:00
2024-05-25 00:44:09 +02:00
// If custom button captions are provided, we set them beforehand
2024-06-23 02:32:06 +02:00
this . okButton . textContent = typeof okButton === 'string' ? okButton : 'OK' ;
2024-06-27 03:01:07 +02:00
this . okButton . dataset . i18n = this . okButton . textContent ;
2024-06-23 02:32:06 +02:00
this . cancelButton . textContent = typeof cancelButton === 'string' ? cancelButton : template . getAttribute ( 'popup-button-cancel' ) ;
2024-06-27 03:01:07 +02:00
this . cancelButton . dataset . i18n = this . cancelButton . textContent ;
2024-05-25 00:44:09 +02:00
this . defaultResult = defaultResult ;
this . customButtons = customButtons ;
2024-05-30 05:11:23 +02:00
this . customButtons ? . forEach ( ( x , index ) => {
2024-05-25 00:44:09 +02:00
/** @type {CustomPopupButton} */
const button = typeof x === 'string' ? { text : x , result : index + 2 } : x ;
2024-05-30 05:11:23 +02:00
const buttonElement = document . createElement ( 'div' ) ;
2024-06-09 22:02:51 +02:00
buttonElement . classList . add ( 'menu_button' , 'popup-button-custom' , 'result-control' ) ;
2024-05-25 00:44:09 +02:00
buttonElement . classList . add ( ... ( button . classes ? ? [ ] ) ) ;
2024-07-12 00:29:16 +02:00
buttonElement . dataset . result = String ( button . result ) ; // This is expected to also write 'null' or 'staging', to indicate cancel and no action respectively
2024-05-25 00:44:09 +02:00
buttonElement . textContent = button . text ;
2024-06-27 03:01:07 +02:00
buttonElement . dataset . i18n = buttonElement . textContent ;
2024-05-30 05:11:23 +02:00
buttonElement . tabIndex = 0 ;
2024-05-25 00:44:09 +02:00
if ( button . appendAtEnd ) {
2024-06-27 02:28:25 +02:00
this . buttonControls . appendChild ( buttonElement ) ;
2024-05-25 00:44:09 +02:00
} else {
2024-06-27 02:28:25 +02:00
this . buttonControls . insertBefore ( buttonElement , this . okButton ) ;
2024-05-25 00:44:09 +02:00
}
2024-06-25 10:54:59 +02:00
if ( typeof button . action === 'function' ) {
buttonElement . addEventListener ( 'click' , button . action ) ;
}
2024-05-25 00:44:09 +02:00
} ) ;
2024-06-27 02:28:25 +02:00
this . customInputs = customInputs ;
this . customInputs ? . forEach ( input => {
if ( ! input . id || ! ( typeof input . id === 'string' ) ) {
2024-06-30 20:44:29 +02:00
console . warn ( 'Given custom input does not have a valid id set' ) ;
2024-06-27 02:28:25 +02:00
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 ;
2024-06-27 02:52:34 +02:00
labelText . dataset . i18n = input . label ;
2024-06-27 02:28:25 +02:00
label . appendChild ( labelText ) ;
if ( input . tooltip ) {
const tooltip = document . createElement ( 'div' ) ;
tooltip . classList . add ( 'fa-solid' , 'fa-circle-info' , 'opacity50p' ) ;
2024-06-27 03:01:07 +02:00
tooltip . title = input . tooltip ;
tooltip . dataset . i18n = '[title]' + input . tooltip ;
2024-06-27 02:28:25 +02:00
label . appendChild ( tooltip ) ;
}
this . inputControls . appendChild ( label ) ;
} ) ;
2024-05-25 00:44:09 +02:00
// Set the default button class
2024-06-27 02:28:25 +02:00
const defaultButton = this . buttonControls . querySelector ( ` [data-result=" ${ this . defaultResult } "] ` ) ;
2024-05-25 00:44:09 +02:00
if ( defaultButton ) defaultButton . classList . add ( 'menu_button_default' ) ;
2024-05-20 20:58:45 +02:00
2024-06-23 02:32:06 +02:00
// Styling differences depending on the popup type
// General styling for all types first, that might be overriden for specific types below
2024-06-27 02:28:25 +02:00
this . mainInput . style . display = 'none' ;
this . inputControls . style . display = customInputs ? 'block' : 'none' ;
2024-06-23 02:32:06 +02:00
this . closeButton . style . display = 'none' ;
2024-06-25 01:05:35 +02:00
this . cropWrap . style . display = 'none' ;
2024-06-23 02:32:06 +02:00
2024-04-13 14:24:49 +02:00
switch ( type ) {
2024-04-09 00:42:33 +02:00
case POPUP _TYPE . TEXT : {
2024-06-23 02:32:06 +02:00
if ( ! cancelButton ) this . cancelButton . style . display = 'none' ;
2024-04-09 00:42:33 +02:00
break ;
}
case POPUP _TYPE . CONFIRM : {
2024-06-23 02:32:06 +02:00
if ( ! okButton ) this . okButton . textContent = template . getAttribute ( 'popup-button-yes' ) ;
if ( ! cancelButton ) this . cancelButton . textContent = template . getAttribute ( 'popup-button-no' ) ;
2024-04-09 00:42:33 +02:00
break ;
}
case POPUP _TYPE . INPUT : {
2024-06-27 02:28:25 +02:00
this . mainInput . style . display = 'block' ;
2024-06-23 02:32:06 +02:00
if ( ! okButton ) this . okButton . textContent = template . getAttribute ( 'popup-button-save' ) ;
2024-04-09 00:42:33 +02:00
break ;
}
2024-06-23 02:32:06 +02:00
case POPUP _TYPE . DISPLAY : {
2024-06-27 02:28:25 +02:00
this . buttonControls . style . display = 'none' ;
2024-06-23 02:32:06 +02:00
this . closeButton . style . display = 'block' ;
2024-06-23 22:09:22 +02:00
break ;
2024-06-23 02:32:06 +02:00
}
2024-06-25 01:05:35 +02:00
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 ,
2024-06-25 01:18:10 +02:00
crop : ( event ) => {
2024-06-25 01:05:35 +02:00
this . cropData = event . detail ;
this . cropData . want _resize = ! power _user . never _resize _avatars ;
} ,
} ) ;
break ;
}
2024-04-09 00:42:33 +02:00
default : {
2024-05-30 05:11:23 +02:00
console . warn ( 'Unknown popup type.' , type ) ;
break ;
2024-04-09 00:42:33 +02:00
}
}
2024-06-27 02:28:25 +02:00
this . mainInput . value = inputValue ;
this . mainInput . rows = rows ? ? 1 ;
2024-04-09 00:42:33 +02:00
2024-06-09 22:02:51 +02:00
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 ;
2024-04-09 00:42:33 +02:00
} else {
2024-06-09 22:02:51 +02:00
console . warn ( 'Unknown popup text type. Should be jQuery, HTMLElement or string.' , content ) ;
2024-04-09 00:42:33 +02:00
}
2024-05-30 05:11:23 +02:00
// 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 ; } ) ;
2024-06-23 02:32:06 +02:00
// Bind event listeners for all result controls to their defined event type
2024-06-23 22:09:22 +02:00
this . dlg . querySelectorAll ( '[data-result]' ) . forEach ( resultControl => {
2024-06-23 02:32:06 +02:00
if ( ! ( resultControl instanceof HTMLElement ) ) return ;
2024-07-12 00:29:16 +02:00
// 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 ) ;
2024-06-23 02:32:06 +02:00
const type = resultControl . dataset . resultEvent || 'click' ;
2024-06-30 20:44:29 +02:00
resultControl . addEventListener ( type , async ( ) => await this . complete ( result ) ) ;
2024-06-23 02:32:06 +02:00
} ) ;
2024-06-20 21:43:13 +02:00
// Bind dialog listeners manually, so we can be sure context is preserved
2024-06-30 20:44:29 +02:00
const cancelListener = async ( evt ) => {
2024-06-20 21:43:13 +02:00
evt . preventDefault ( ) ;
evt . stopPropagation ( ) ;
2024-06-30 20:44:29 +02:00
await this . complete ( POPUP _RESULT . CANCELLED ) ;
2024-06-20 21:43:13 +02:00
} ;
2024-07-14 02:39:57 +02:00
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.
2024-07-15 00:46:45 +02:00
// We make sure that the modal on its 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.
// So here we just say that close should not happen if it was prevented.
2024-07-14 02:39:57 +02:00
const closeListener = async ( evt ) => {
2024-07-14 22:37:22 +02:00
if ( this . # isClosingPrevented ) {
2024-07-14 02:39:57 +02:00
evt . preventDefault ( ) ;
evt . stopPropagation ( ) ;
this . dlg . showModal ( ) ;
}
} ;
this . dlg . addEventListener ( 'close' , closeListener . bind ( this ) ) ;
2024-06-20 21:43:13 +02:00
2024-06-30 20:44:29 +02:00
const keyListener = async ( evt ) => {
2024-04-09 00:42:33 +02:00
switch ( evt . key ) {
2024-05-25 00:44:09 +02:00
case 'Enter' : {
2024-06-03 02:52:54 +02:00
// CTRL+Enter counts as a closing action, but all other modifiers (ALT, SHIFT) should not trigger this
if ( evt . altKey || evt . shiftKey )
2024-05-30 05:11:23 +02:00
return ;
// Check if we are the currently active popup
2024-05-31 21:59:26 +02:00
if ( this . dlg != document . activeElement ? . closest ( '.popup' ) )
2024-05-30 05:11:23 +02:00
return ;
2024-07-01 15:32:24 +02:00
// Check if the current focus is a result control. Only should we apply the complete action
2024-06-09 22:02:51 +02:00
const resultControl = document . activeElement ? . closest ( '.result-control' ) ;
2024-05-30 05:11:23 +02:00
if ( ! resultControl )
return ;
2024-07-01 15:32:24 +02:00
// 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 ;
2024-05-30 05:11:23 +02:00
evt . preventDefault ( ) ;
evt . stopPropagation ( ) ;
2024-06-30 20:44:29 +02:00
const result = Number ( document . activeElement . getAttribute ( 'data-result' ) ? ? this . defaultResult ) ;
2024-07-14 02:39:57 +02:00
// Call complete on the popup. Make sure that we handle `onClosing` cancels correctly and don't remove the listener then.
2024-06-30 20:44:29 +02:00
await this . complete ( result ) ;
2024-05-30 05:11:23 +02:00
2024-05-25 00:44:09 +02:00
break ;
}
2024-04-09 00:42:33 +02:00
}
2024-05-30 05:11:23 +02:00
2024-04-09 00:42:33 +02:00
} ;
2024-07-14 02:39:57 +02:00
this . dlg . addEventListener ( 'keydown' , keyListener . bind ( this ) ) ;
2024-04-09 00:42:33 +02:00
}
2024-05-25 00:44:09 +02:00
/ * *
* 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 .
* /
2024-04-09 00:42:33 +02:00
async show ( ) {
2024-05-30 05:11:23 +02:00
document . body . append ( this . dlg ) ;
2024-05-25 00:44:09 +02:00
2024-06-02 05:54:41 +02:00
// Run opening animation
this . dlg . setAttribute ( 'opening' , '' ) ;
2024-05-30 05:11:23 +02:00
this . dlg . showModal ( ) ;
2024-04-09 00:42:33 +02:00
2024-05-30 05:11:23 +02:00
// We need to fix the toastr to be present inside this dialog
fixToastrForDialogs ( ) ;
2024-04-09 00:42:33 +02:00
2024-06-02 05:54:41 +02:00
runAfterAnimation ( this . dlg , ( ) => {
this . dlg . removeAttribute ( 'opening' ) ;
2024-06-23 22:09:22 +02:00
} ) ;
2024-06-02 05:54:41 +02:00
2024-06-27 02:39:59 +02:00
this . # promise = new Promise ( ( resolve ) => {
this . # resolver = resolve ;
2024-04-09 00:42:33 +02:00
} ) ;
2024-06-27 02:39:59 +02:00
return this . # promise ;
2024-04-09 00:42:33 +02:00
}
2024-05-30 05:11:23 +02:00
setAutoFocus ( { applyAutoFocus = false } = { } ) {
/** @type {HTMLElement} */
let control ;
2024-04-09 00:42:33 +02:00
2024-05-30 05:11:23 +02:00
// 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 : {
2024-06-27 02:28:25 +02:00
control = this . mainInput ;
2024-05-30 05:11:23 +02:00
break ;
}
default :
// Select default button
2024-06-27 02:28:25 +02:00
control = this . buttonControls . querySelector ( ` [data-result=" ${ this . defaultResult } "] ` ) ;
2024-05-30 05:11:23 +02:00
break ;
2024-04-09 00:42:33 +02:00
}
}
2024-05-30 05:11:23 +02:00
if ( applyAutoFocus ) {
control . setAttribute ( 'autofocus' , '' ) ;
2024-06-23 02:32:06 +02:00
// 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 ;
2024-05-30 05:11:23 +02:00
} else {
control . focus ( ) ;
2024-04-09 00:42:33 +02:00
}
}
2024-05-25 00:44:09 +02:00
/ * *
2024-05-30 05:11:23 +02:00
* Completes the popup and sets its result and value
*
* The completion handling will make the popup return the result to the original show promise .
2024-05-25 00:44:09 +02:00
*
2024-05-30 05:11:23 +02:00
* 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
*
2024-07-14 02:39:57 +02:00
* < b > IMPORTANT : < / b > I f t h e p o p u p c l o s i n g w a s c a n c e l l e d v i a t h e ` o n C l o s i n g ` h a n d l e r , t h e r e t u r n v a l u e w i l l b e ` P r o m i s e < u n d e f i n e d > ` .
*
2024-05-30 05:11:23 +02:00
* @ param { POPUP _RESULT | number } result - The result of the popup ( either an existing ` POPUP_RESULT ` or a custom result value )
2024-06-30 20:44:29 +02:00
*
2024-07-14 02:39:57 +02:00
* @ 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 >
2024-05-25 00:44:09 +02:00
* /
2024-06-30 20:44:29 +02:00
async complete ( result ) {
2024-05-30 05:11:23 +02:00
// 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 ) {
2024-06-27 02:28:25 +02:00
if ( result >= POPUP _RESULT . AFFIRMATIVE ) value = this . mainInput . value ;
2024-06-02 00:09:25 +02:00
else if ( result === POPUP _RESULT . NEGATIVE ) value = false ;
else if ( result === POPUP _RESULT . CANCELLED ) value = null ;
2024-05-30 05:11:23 +02:00
else value = false ; // Might a custom negative value?
2024-05-25 00:44:09 +02:00
}
2024-05-30 05:11:23 +02:00
2024-06-25 01:05:35 +02:00
// 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 ;
}
2024-06-27 02:28:25 +02:00
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 ] ;
} ) ) ;
}
2024-05-30 05:11:23 +02:00
this . value = value ;
this . result = result ;
2024-06-22 04:54:13 +02:00
if ( this . onClosing ) {
2024-08-19 22:49:15 +02:00
const shouldClose = await this . onClosing ( this ) ;
2024-07-14 02:39:57 +02:00
if ( ! shouldClose ) {
2024-07-14 22:37:22 +02:00
this . # isClosingPrevented = true ;
2024-07-14 02:39:57 +02:00
// Set values back if we cancel out of closing the popup
this . value = undefined ;
this . result = undefined ;
this . inputResults = undefined ;
return undefined ;
}
2024-06-22 04:54:13 +02:00
}
2024-07-14 22:37:22 +02:00
this . # isClosingPrevented = false ;
2024-06-22 04:54:13 +02:00
2024-06-27 03:01:07 +02:00
Popup . util . lastResult = { value , result , inputResults : this . inputResults } ;
2024-06-27 02:39:59 +02:00
this . # hide ( ) ;
2024-06-30 20:44:29 +02:00
return this . # promise ;
2024-05-25 00:44:09 +02:00
}
2024-07-30 21:45:19 +02:00
async completeAffirmative ( ) {
return await this . complete ( POPUP _RESULT . AFFIRMATIVE ) ;
2024-06-25 14:30:13 +02:00
}
2024-07-30 21:45:19 +02:00
async completeNegative ( ) {
return await this . complete ( POPUP _RESULT . NEGATIVE ) ;
2024-06-25 14:30:13 +02:00
}
2024-07-30 21:45:19 +02:00
async completeCancelled ( ) {
return await this . complete ( POPUP _RESULT . CANCELLED ) ;
2024-06-25 14:30:13 +02:00
}
2024-05-25 00:44:09 +02:00
/ * *
* Hides the popup , using the internal resolver to return the value to the original show promise
* /
2024-06-27 02:39:59 +02:00
# hide ( ) {
2024-05-30 05:11:23 +02:00
// 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 ( ) ;
2024-06-22 04:54:13 +02:00
// Run a possible custom handler right before DOM removal
if ( this . onClose ) {
this . onClose ( this ) ;
}
2024-05-30 05:11:23 +02:00
// Remove it from the dom
this . dlg . remove ( ) ;
// Remove it from the popup references
2024-06-09 22:02:51 +02:00
removeFromArray ( Popup . util . popups , this ) ;
2024-05-30 05:11:23 +02:00
// If there is any popup below this one, see if we can set the focus
2024-06-09 22:02:51 +02:00
if ( Popup . util . popups . length > 0 ) {
2024-05-31 21:59:26 +02:00
const activeDialog = document . activeElement ? . closest ( '.popup' ) ;
2024-05-30 05:11:23 +02:00
const id = activeDialog ? . getAttribute ( 'data-id' ) ;
2024-06-09 22:02:51 +02:00
const popup = Popup . util . popups . find ( x => x . id == id ) ;
2024-05-30 05:11:23 +02:00
if ( popup ) {
if ( popup . lastFocus ) popup . lastFocus . focus ( ) ;
else popup . setAutoFocus ( ) ;
}
}
2024-04-09 00:42:33 +02:00
2024-06-27 02:39:59 +02:00
this . # resolver ( this . value ) ;
2024-06-27 00:27:55 +02:00
} ) ;
2024-04-09 00:42:33 +02:00
}
2024-06-09 22:02:51 +02:00
/ * *
* 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 = {
2024-06-27 03:01:07 +02:00
/** @readonly @type {Popup[]} Remember all popups */
2024-06-09 22:02:51 +02:00
popups : [ ] ,
2024-06-27 03:01:07 +02:00
/** @type {{value: any, result: POPUP_RESULT|number?, inputResults: Map<string, boolean>?}?} Last popup result */
2024-06-09 22:02:51 +02:00
lastResult : null ,
2024-06-11 02:22:46 +02:00
2024-06-11 02:25:01 +02:00
/** @returns {boolean} Checks if any modal popup dialog is open */
isPopupOpen ( ) {
2024-08-18 16:42:10 +02:00
return Popup . util . popups . filter ( x => x . dlg . hasAttribute ( 'open' ) ) . length > 0 ;
2024-06-11 02:22:46 +02:00
} ,
/ * *
2024-06-11 02:25:01 +02:00
* Returns the topmost modal layer in the document . If there is an open dialog popup ,
2024-06-11 02:22:46 +02:00
* it returns the dialog element . Otherwise , it returns the document body .
*
* @ return { HTMLElement } The topmost modal layer element
* /
getTopmostModalLayer ( ) {
return getTopmostModalLayer ( ) ;
} ,
2024-06-23 22:09:22 +02:00
} ;
2024-06-09 22:02:51 +02:00
}
class PopupUtils {
2024-06-27 01:01:43 +02:00
/ * *
* Builds popup content with header and text below
*
2024-07-26 18:17:32 +02:00
* @ param { string ? } header - The header to be added to the text
* @ param { string ? } text - The main text content
2024-06-27 01:01:43 +02:00
* /
2024-06-09 22:02:51 +02:00
static BuildTextWithHeader ( header , text ) {
2024-06-27 01:01:43 +02:00
if ( ! header ) {
return text ;
}
return ` <h3> ${ header } </h3>
2024-07-26 18:17:32 +02:00
$ { text ? ? '' } ` ; // Convert no text to empty string
2024-06-09 22:02:51 +02:00
}
2024-04-09 00:42:33 +02:00
}
/ * *
2024-06-09 22:02:51 +02:00
* Displays a blocking popup with a given content and type
2024-06-11 02:22:46 +02:00
*
2024-06-09 22:02:51 +02:00
* @ param { JQuery < HTMLElement > | string | Element } content - Content or text to display in the popup
2024-04-09 00:42:33 +02:00
* @ param { POPUP _TYPE } type
2024-05-25 00:44:09 +02:00
* @ 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
2024-04-09 00:42:33 +02:00
* /
2024-06-09 22:02:51 +02:00
export function callGenericPopup ( content , type , inputValue = '' , popupOptions = { } ) {
2024-04-09 00:42:33 +02:00
const popup = new Popup (
2024-06-09 22:02:51 +02:00
content ,
2024-04-09 00:42:33 +02:00
type ,
inputValue ,
2024-05-25 00:44:09 +02:00
popupOptions ,
2024-04-09 00:42:33 +02:00
) ;
return popup . show ( ) ;
}
2024-05-30 05:11:23 +02:00
2024-06-11 02:22:46 +02:00
/ * *
* 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 ;
}
2024-06-09 22:02:51 +02:00
/ * *
* 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
* /
2024-05-30 05:11:23 +02:00
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,
2024-06-30 23:23:39 +02:00
// we move the toastr back to the main body, or delete if its empty
2024-05-30 05:11:23 +02:00
if ( ! dlg && isAlreadyPresent ) {
2024-06-30 23:23:39 +02:00
if ( ! toastContainer . childNodes . length ) {
toastContainer . remove ( ) ;
} else {
document . body . appendChild ( toastContainer ) ;
toastContainer . classList . add ( 'toast-top-center' ) ;
}
2024-05-30 05:11:23 +02:00
}
}