| 1 | import dialogPolyfill from '../lib/dialog-polyfill.esm.js'; |
| 2 | import { shouldSendOnEnter } from './RossAscends-mods.js'; |
| 3 | import { t } from './i18n.js'; |
| 4 | import { power_user, toastPositionClasses } from './power-user.js'; |
| 5 | import { clamp, removeFromArray, runAfterAnimation, uuidv4 } from './utils.js'; |
| 6 | |
| 7 | /** @readonly */ |
| 8 | /** @enum {Number} */ |
| 9 | export const POPUP_TYPE = { |
| 10 | /** Main popup type. Containing any content displayed, with buttons below. Can also contain additional input controls. */ |
| 11 | TEXT: 1, |
| 12 | /** Popup mainly made to confirm something, answering with a simple Yes/No or similar. Focus on the button controls. */ |
| 13 | CONFIRM: 2, |
| 14 | /** 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. */ |
| 15 | INPUT: 3, |
| 16 | /** Popup without any button controls. Used to simply display content, with a small X in the corner. */ |
| 17 | DISPLAY: 4, |
| 18 | /** Popup that displays an image to crop. Returns a cropped image in result. */ |
| 19 | CROP: 5, |
| 20 | }; |
| 21 | |
| 22 | /** @readonly */ |
| 23 | /** @enum {number?} */ |
| 24 | export const POPUP_RESULT = { |
| 25 | AFFIRMATIVE: 1, |
| 26 | NEGATIVE: 0, |
| 27 | CANCELLED: null, |
| 28 | CUSTOM1: 1001, |
| 29 | CUSTOM2: 1002, |
| 30 | CUSTOM3: 1003, |
| 31 | CUSTOM4: 1004, |
| 32 | CUSTOM5: 1005, |
| 33 | CUSTOM6: 1006, |
| 34 | CUSTOM7: 1007, |
| 35 | CUSTOM8: 1008, |
| 36 | CUSTOM9: 1009, |
| 37 | }; |
| 38 | |
| 39 | /** |
| 40 | * @typedef {object} PopupOptions |
| 41 | * @property {string|boolean?} [okButton=null] - Custom text for the OK button. A set text will always show the button. `true` or `false` to explicitly show or hide the button. `null` will leave the behavior and display of the button unchanged, based on the popup type. |
| 42 | * @property {string|boolean?} [cancelButton=null] - Custom text for the Cancel button. A set text will always show the button. `true` or `false` to explicitly show or hide the button. `null` will leave the behavior and display of the button unchanged, based on the popup type. |
| 43 | * @property {number?} [rows=1] - The number of rows for the input field |
| 44 | * @property {string?} [placeholder=null] - Placeholder text for the main interactive element (input field for INPUT type). For other popup types, use tooltip for additional hints or to describe content elements. |
| 45 | * @property {string?} [tooltip=null] - Tooltip text shown on hover for the main interactive element or content area |
| 46 | * @property {boolean?} [wide=false] - Whether to display the popup in wide mode (wide screen, 1/1 aspect ratio) |
| 47 | * @property {boolean?} [wider=false] - Whether to display the popup in wider mode (just wider, no height scaling) |
| 48 | * @property {boolean?} [large=false] - Whether to display the popup in large mode (90% of screen) |
| 49 | * @property {boolean?} [transparent=false] - Whether to display the popup in transparent mode (no background, border, shadow or anything, only its content) |
| 50 | * @property {boolean?} [allowHorizontalScrolling=false] - Whether to allow horizontal scrolling in the popup |
| 51 | * @property {boolean?} [allowVerticalScrolling=false] - Whether to allow vertical scrolling in the popup |
| 52 | * @property {boolean?} [leftAlign=false] - Whether the popup content should be left-aligned by default |
| 53 | * @property {'slow'|'fast'|'none'?} [animation='slow'] - Animation speed for the popup (opening, closing, ...) |
| 54 | * @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`. |
| 55 | * @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. |
| 56 | * @property {CustomPopupInput[]?} [customInputs=null] - Custom inputs to add to the popup. The display below the content and the input box, one by one. |
| 57 | * @property {boolean} [allowEscapeClose=true] - If true, allows closing the popup with the Escape key, returning `POPUP_RESULT.CANCELLED`. If false, requires double-escape to force close with a confirmation to prevent accidental closure. |
| 58 | * @property {(popup: Popup) => Promise<boolean?>|boolean?} [onClosing=null] - Handler called before the popup closes, return `false` to cancel the close |
| 59 | * @property {(popup: Popup) => Promise<void?>|void?} [onClose=null] - Handler called after the popup closes, but before the DOM is cleaned up |
| 60 | * @property {(popup: Popup) => Promise<void?>|void?} [onOpen=null] - Handler called after the popup opens |
| 61 | * @property {number?} [cropAspect=null] - Aspect ratio for the crop popup |
| 62 | * @property {string?} [cropImage=null] - Image URL to display in the crop popup |
| 63 | */ |
| 64 | |
| 65 | /** |
| 66 | * @typedef {object} CustomPopupButton |
| 67 | * @property {string} text - The text of the button |
| 68 | * @property {string?} [tooltip] - Optional tooltip text displayed when hovering over the button |
| 69 | * @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. |
| 70 | * @property {string[]|string?} [classes] - Optional custom CSS classes applied to the button |
| 71 | * @property {string?} [icon] - Optional Font Awesome icon class (e.g. 'fa-wand-magic-sparkles') to display before the text |
| 72 | * @property {()=>void?} [action] - Optional action to perform when the button is clicked |
| 73 | * @property {boolean?} [appendAtEnd] - Whether to append the button to the end of the popup - by default it will be prepended |
| 74 | */ |
| 75 | |
| 76 | /** |
| 77 | * @typedef {object} CustomPopupInput |
| 78 | * @property {string} id - The id for the html element |
| 79 | * @property {string} label - The label text for the input |
| 80 | * @property {string?} [tooltip=null] - Optional tooltip to be displayed. Default placeholder in input controls, tooltip icon behind the checkbox for those. |
| 81 | * @property {boolean|string|undefined} [defaultState=false] - The default state when opening the popup (false if not set) |
| 82 | * @property {('checkbox'|'text'|'textarea'|'number')?} [type='checkbox'] - The type of the input (default is checkbox) |
| 83 | * @property {number?} [rows=1] - The number of rows for the input field, if the input is 'textarea' |
| 84 | * @property {number?} [min] - The minimum value for number inputs |
| 85 | * @property {number?} [max] - The maximum value for number inputs |
| 86 | * @property {number?} [step] - The step value for number inputs |
| 87 | * @property {boolean?} [disabled=false] - Whether the input should be disabled |
| 88 | */ |
| 89 | |
| 90 | /** |
| 91 | * @typedef {object} ShowPopupHelper |
| 92 | * Local implementation of the helper functionality to show several popups. |
| 93 | * |
| 94 | * Should be called via `Popup.show.xxxx()`. |
| 95 | */ |
| 96 | const showPopupHelper = { |
| 97 | /** |
| 98 | * Asynchronously displays an input popup with the given header and text, and returns the user's input. |
| 99 | * |
| 100 | * @param {string?} header - The header text for the popup. |
| 101 | * @param {string?} [text] - The main text for the popup. |
| 102 | * @param {string} [defaultValue=''] - The default value for the input field. |
| 103 | * @param {PopupOptions} [popupOptions={}] - Options for the popup. |
| 104 | * @return {Promise<string?>} A Promise that resolves with the user's input. |
| 105 | */ |
| 106 | input: async (header, text, defaultValue = '', popupOptions = {}) => { |
| 107 | const content = PopupUtils.BuildTextWithHeader(header, text); |
| 108 | const popup = new Popup(content, POPUP_TYPE.INPUT, defaultValue, popupOptions); |
| 109 | const value = await popup.show(); |
| 110 | // Return values: If empty string, we explicitly handle that as returning that empty string as "success" provided. |
| 111 | // Otherwise, all non-truthy values (false, null, undefined) are treated as "cancel" and return null. |
| 112 | if (value === '') return ''; |
| 113 | return value ? String(value) : null; |
| 114 | }, |
| 115 | |
| 116 | /** |
| 117 | * Asynchronously displays a confirmation popup with the given header and text, returning the clicked result button value. |
| 118 | * |
| 119 | * @param {string?} header - The header text for the popup. |
| 120 | * @param {string?} [text] - The main text for the popup. |
| 121 | * @param {PopupOptions} [popupOptions={}] - Options for the popup. |
| 122 | * @return {Promise<POPUP_RESULT?>} A Promise that resolves with the result of the user's interaction. |
| 123 | */ |
| 124 | confirm: async (header, text, popupOptions = {}) => { |
| 125 | const content = PopupUtils.BuildTextWithHeader(header, text); |
| 126 | const popup = new Popup(content, POPUP_TYPE.CONFIRM, null, popupOptions); |
| 127 | const result = await popup.show(); |
| 128 | if (typeof result === 'string' || typeof result === 'boolean') throw new Error(`Invalid popup result. CONFIRM popups only support numbers, or null. Result: ${result}`); |
| 129 | return result; |
| 130 | }, |
| 131 | /** |
| 132 | * Asynchronously displays a text popup with the given header and text, returning the clicked result button value. |
| 133 | * |
| 134 | * @param {string?} header - The header text for the popup. |
| 135 | * @param {string?} text - The main text for the popup. |
| 136 | * @param {PopupOptions} [popupOptions={}] - Options for the popup. |
| 137 | * @return {Promise<POPUP_RESULT?>} A Promise that resolves with the result of the user's interaction. |
| 138 | */ |
| 139 | text: async (header, text, popupOptions = {}) => { |
| 140 | const content = PopupUtils.BuildTextWithHeader(header, text); |
| 141 | const popup = new Popup(content, POPUP_TYPE.TEXT, null, popupOptions); |
| 142 | const result = await popup.show(); |
| 143 | if (typeof result === 'string' || typeof result === 'boolean') throw new Error(`Invalid popup result. TEXT popups only support numbers, or null. Result: ${result}`); |
| 144 | return result; |
| 145 | }, |
| 146 | }; |
| 147 | |
| 148 | export class Popup { |
| 149 | /** @readonly @type {POPUP_TYPE} */ type; |
| 150 | |
| 151 | /** @readonly @type {string} */ id; |
| 152 | |
| 153 | /** @readonly @type {HTMLDialogElement} */ dlg; |
| 154 | /** @readonly @type {HTMLDivElement} */ body; |
| 155 | /** @readonly @type {HTMLDivElement} */ content; |
| 156 | /** @readonly @type {HTMLTextAreaElement} */ mainInput; |
| 157 | /** @readonly @type {HTMLDivElement} */ inputControls; |
| 158 | /** @readonly @type {HTMLDivElement} */ buttonControls; |
| 159 | /** @readonly @type {HTMLDivElement} */ okButton; |
| 160 | /** @readonly @type {HTMLDivElement} */ cancelButton; |
| 161 | /** @readonly @type {HTMLDivElement} */ closeButton; |
| 162 | /** @readonly @type {HTMLDivElement} */ cropWrap; |
| 163 | /** @readonly @type {HTMLImageElement} */ cropImage; |
| 164 | /** @readonly @type {POPUP_RESULT|number?} */ defaultResult; |
| 165 | /** @readonly @type {CustomPopupButton[]|string[]?} */ customButtons; |
| 166 | /** @readonly @type {CustomPopupInput[]} */ customInputs; |
| 167 | |
| 168 | /** @type {(popup: Popup) => Promise<boolean?>|boolean?} */ onClosing; |
| 169 | /** @type {(popup: Popup) => Promise<void?>|void?} */ onClose; |
| 170 | /** @type {(popup: Popup) => Promise<void?>|void?} */ onOpen; |
| 171 | |
| 172 | /** @type {POPUP_RESULT|number} */ result; |
| 173 | /** @type {any} */ value; |
| 174 | /** @type {Map<string,string|boolean>?} */ inputResults; |
| 175 | /** @type {any} */ cropData; |
| 176 | |
| 177 | /** @type {HTMLElement} */ lastFocus; |
| 178 | |
| 179 | /** @type {Promise<any>} */ #promise; |
| 180 | /** @type {(result: any) => any} */ #resolver; |
| 181 | |
| 182 | /** @type {boolean} */ #allowEscapeClose; |
| 183 | /** @type {boolean} */ #isClosingPrevented; |
| 184 | /** @type {number} */ #lastEscapePress = 0; |
| 185 | /** @type {boolean} */ #isShowingForceCloseConfirm = false; |
| 186 | |
| 187 | /** |
| 188 | * Constructs a new Popup object with the given text content, type, inputValue, and options |
| 189 | * |
| 190 | * @param {JQuery<HTMLElement>|string|Element} content - Text content to display in the popup |
| 191 | * @param {POPUP_TYPE} type - The type of the popup |
| 192 | * @param {string} [inputValue=''] - The initial value of the input field |
| 193 | * @param {PopupOptions} [options={}] - Additional options for the popup |
| 194 | */ |
| 195 | constructor(content, type, inputValue = '', { |
| 196 | okButton = null, |
| 197 | cancelButton = null, |
| 198 | rows = 1, |
| 199 | placeholder = null, |
| 200 | tooltip = null, |
| 201 | wide = false, |
| 202 | wider = false, |
| 203 | large = false, |
| 204 | transparent = false, |
| 205 | allowHorizontalScrolling = false, |
| 206 | allowVerticalScrolling = false, |
| 207 | leftAlign = false, |
| 208 | animation = 'fast', |
| 209 | defaultResult = POPUP_RESULT.AFFIRMATIVE, |
| 210 | customButtons = null, |
| 211 | customInputs = null, |
| 212 | allowEscapeClose = true, |
| 213 | onClosing = null, |
| 214 | onClose = null, |
| 215 | onOpen = null, |
| 216 | cropAspect = null, |
| 217 | cropImage = null, |
| 218 | } = {}) { |
| 219 | Popup.util.popups.push(this); |
| 220 | |
| 221 | // Make this popup uniquely identifiable |
| 222 | this.id = uuidv4(); |
| 223 | this.type = type; |
| 224 | |
| 225 | // Setup some args being passed in as private properties |
| 226 | this.#allowEscapeClose = allowEscapeClose; |
| 227 | |
| 228 | // Utilize event handlers being passed in |
| 229 | this.onClosing = onClosing; |
| 230 | this.onClose = onClose; |
| 231 | this.onOpen = onOpen; |
| 232 | |
| 233 | /**@type {HTMLTemplateElement}*/ |
| 234 | const template = document.querySelector('#popup_template'); |
| 235 | // @ts-ignore |
| 236 | this.dlg = template.content.cloneNode(true).querySelector('.popup'); |
| 237 | if (!this.dlg.showModal) { |
| 238 | this.dlg.classList.add('poly_dialog'); |
| 239 | dialogPolyfill.registerDialog(this.dlg); |
| 240 | // Force a vertical reposition after the content |
| 241 | // (like crop image) has been set |
| 242 | const resizeObserver = new ResizeObserver((entries) => { |
| 243 | for (const entry of entries) { |
| 244 | dialogPolyfill.reposition(entry.target); |
| 245 | } |
| 246 | }); |
| 247 | resizeObserver.observe(this.dlg); |
| 248 | } |
| 249 | this.body = this.dlg.querySelector('.popup-body'); |
| 250 | this.content = this.dlg.querySelector('.popup-content'); |
| 251 | this.mainInput = this.dlg.querySelector('.popup-input'); |
| 252 | this.inputControls = this.dlg.querySelector('.popup-inputs'); |
| 253 | this.buttonControls = this.dlg.querySelector('.popup-controls'); |
| 254 | this.okButton = this.dlg.querySelector('.popup-button-ok'); |
| 255 | this.cancelButton = this.dlg.querySelector('.popup-button-cancel'); |
| 256 | this.closeButton = this.dlg.querySelector('.popup-button-close'); |
| 257 | this.cropWrap = this.dlg.querySelector('.popup-crop-wrap'); |
| 258 | this.cropImage = this.dlg.querySelector('.popup-crop-image'); |
| 259 | |
| 260 | this.dlg.setAttribute('data-id', this.id); |
| 261 | if (wide) this.dlg.classList.add('wide_dialogue_popup'); |
| 262 | if (wider) this.dlg.classList.add('wider_dialogue_popup'); |
| 263 | if (large) this.dlg.classList.add('large_dialogue_popup'); |
| 264 | if (transparent) this.dlg.classList.add('transparent_dialogue_popup'); |
| 265 | if (allowHorizontalScrolling) this.dlg.classList.add('horizontal_scrolling_dialogue_popup'); |
| 266 | if (allowVerticalScrolling) this.dlg.classList.add('vertical_scrolling_dialogue_popup'); |
| 267 | if (leftAlign) this.dlg.classList.add('left_aligned_dialogue_popup'); |
| 268 | if (animation) this.dlg.classList.add('popup--animation-' + animation); |
| 269 | |
| 270 | // If custom button captions are provided, we set them beforehand |
| 271 | this.okButton.textContent = typeof okButton === 'string' ? okButton : 'OK'; |
| 272 | this.okButton.dataset.i18n = this.okButton.textContent; |
| 273 | this.cancelButton.textContent = typeof cancelButton === 'string' ? cancelButton : template.getAttribute('popup-button-cancel'); |
| 274 | this.cancelButton.dataset.i18n = this.cancelButton.textContent; |
| 275 | |
| 276 | /** @param {HTMLElement} control @param {string} text Sets the title attribute and translation, if text is provided */ |
| 277 | function setTitleFromTooltip(control, text) { |
| 278 | if (!text) return; |
| 279 | control.title = text; |
| 280 | if (!control.dataset.i18n) { |
| 281 | control.dataset.i18n = '[title]' + text; // Don't override an existing translation of main text with title translation |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | this.defaultResult = defaultResult; |
| 286 | this.customButtons = customButtons; |
| 287 | this.customButtons?.forEach((x, index) => { |
| 288 | /** @type {CustomPopupButton} */ |
| 289 | const button = typeof x === 'string' ? { text: x, result: index + 2 } : x; |
| 290 | |
| 291 | const buttonElement = document.createElement('div'); |
| 292 | buttonElement.classList.add('menu_button', 'popup-button-custom', 'result-control'); |
| 293 | buttonElement.classList.add(...(button.classes ?? [])); |
| 294 | buttonElement.dataset.result = String(button.result); // This is expected to also write 'null' or 'staging', to indicate cancel and no action respectively |
| 295 | buttonElement.tabIndex = 0; |
| 296 | |
| 297 | if (button.icon) { |
| 298 | const icon = document.createElement('i'); |
| 299 | icon.className = `fa-solid ${button.icon}`; |
| 300 | buttonElement.appendChild(icon); |
| 301 | const textSpan = document.createElement('span'); |
| 302 | textSpan.textContent = button.text; |
| 303 | textSpan.dataset.i18n = button.text; |
| 304 | buttonElement.classList.add('menu_button_icon'); |
| 305 | buttonElement.appendChild(textSpan); |
| 306 | } else { |
| 307 | buttonElement.textContent = button.text; |
| 308 | buttonElement.dataset.i18n = buttonElement.textContent; |
| 309 | } |
| 310 | setTitleFromTooltip(buttonElement, button.tooltip); |
| 311 | |
| 312 | if (button.appendAtEnd) { |
| 313 | this.buttonControls.appendChild(buttonElement); |
| 314 | } else { |
| 315 | this.buttonControls.insertBefore(buttonElement, this.okButton); |
| 316 | } |
| 317 | |
| 318 | if (typeof button.action === 'function') { |
| 319 | buttonElement.addEventListener('click', button.action); |
| 320 | } |
| 321 | }); |
| 322 | |
| 323 | this.customInputs = customInputs; |
| 324 | this.customInputs?.forEach(input => { |
| 325 | if (!input.id || !(typeof input.id === 'string')) { |
| 326 | console.warn('Given custom input does not have a valid id set'); |
| 327 | return; |
| 328 | } |
| 329 | |
| 330 | if (!input.type || input.type === 'checkbox') { |
| 331 | const label = document.createElement('label'); |
| 332 | label.classList.add('checkbox_label', 'justifyCenter'); |
| 333 | label.setAttribute('for', input.id); |
| 334 | const inputElement = document.createElement('input'); |
| 335 | inputElement.type = 'checkbox'; |
| 336 | inputElement.id = input.id; |
| 337 | inputElement.checked = Boolean(input.defaultState ?? false); |
| 338 | inputElement.disabled = Boolean(input.disabled ?? false); |
| 339 | label.appendChild(inputElement); |
| 340 | const labelText = document.createElement('span'); |
| 341 | labelText.innerText = input.label; |
| 342 | labelText.dataset.i18n = input.label; |
| 343 | label.appendChild(labelText); |
| 344 | |
| 345 | if (input.tooltip) { |
| 346 | const tooltip = document.createElement('div'); |
| 347 | tooltip.classList.add('fa-solid', 'fa-circle-info', 'opacity50p'); |
| 348 | setTitleFromTooltip(tooltip, input.tooltip); |
| 349 | label.appendChild(tooltip); |
| 350 | } |
| 351 | |
| 352 | this.inputControls.appendChild(label); |
| 353 | } else if (input.type === 'text') { |
| 354 | const label = document.createElement('label'); |
| 355 | label.classList.add('text_label', 'justifyCenter'); |
| 356 | label.setAttribute('for', input.id); |
| 357 | |
| 358 | const inputElement = document.createElement('input'); |
| 359 | inputElement.classList.add('text_pole', 'result-control'); |
| 360 | inputElement.type = 'text'; |
| 361 | inputElement.id = input.id; |
| 362 | inputElement.value = String(input.defaultState ?? ''); |
| 363 | inputElement.placeholder = input.tooltip ?? ''; |
| 364 | inputElement.disabled = Boolean(input.disabled ?? false); |
| 365 | setTitleFromTooltip(inputElement, input.tooltip); |
| 366 | |
| 367 | const labelText = document.createElement('span'); |
| 368 | labelText.innerText = input.label; |
| 369 | labelText.dataset.i18n = input.label; |
| 370 | |
| 371 | label.appendChild(labelText); |
| 372 | label.appendChild(inputElement); |
| 373 | |
| 374 | this.inputControls.appendChild(label); |
| 375 | } else if (input.type === 'textarea') { |
| 376 | const label = document.createElement('label'); |
| 377 | label.classList.add('text_label', 'justifyCenter'); |
| 378 | label.setAttribute('for', input.id); |
| 379 | |
| 380 | const inputElement = document.createElement('textarea'); |
| 381 | inputElement.classList.add('text_pole', 'result-control'); |
| 382 | inputElement.id = input.id; |
| 383 | inputElement.value = String(input.defaultState ?? ''); |
| 384 | inputElement.rows = input.rows ?? 1; |
| 385 | inputElement.placeholder = input.tooltip ?? ''; |
| 386 | inputElement.disabled = Boolean(input.disabled ?? false); |
| 387 | setTitleFromTooltip(inputElement, input.tooltip); |
| 388 | |
| 389 | const labelText = document.createElement('span'); |
| 390 | labelText.innerText = input.label; |
| 391 | labelText.dataset.i18n = input.label; |
| 392 | |
| 393 | label.appendChild(labelText); |
| 394 | label.appendChild(inputElement); |
| 395 | |
| 396 | this.inputControls.appendChild(label); |
| 397 | } else if (input.type === 'number') { |
| 398 | const label = document.createElement('label'); |
| 399 | label.classList.add('text_label', 'justifyCenter'); |
| 400 | label.setAttribute('for', input.id); |
| 401 | |
| 402 | const inputElement = document.createElement('input'); |
| 403 | inputElement.classList.add('text_pole', 'result-control'); |
| 404 | inputElement.type = 'number'; |
| 405 | inputElement.id = input.id; |
| 406 | inputElement.value = String(input.defaultState ?? ''); |
| 407 | inputElement.placeholder = input.tooltip ?? ''; |
| 408 | inputElement.min = String(input.min ?? ''); |
| 409 | inputElement.max = String(input.max ?? ''); |
| 410 | inputElement.step = String(input.step ?? ''); |
| 411 | inputElement.disabled = Boolean(input.disabled ?? false); |
| 412 | setTitleFromTooltip(inputElement, input.tooltip); |
| 413 | |
| 414 | inputElement.addEventListener('change', () => { |
| 415 | const value = parseFloat(inputElement.value); |
| 416 | if (isNaN(value)) return; |
| 417 | |
| 418 | const min = Number.isFinite(input.min) ? input.min : -Infinity; |
| 419 | const max = Number.isFinite(input.max) ? input.max : Infinity; |
| 420 | const clamped = clamp(value, min, max); |
| 421 | |
| 422 | if (clamped !== value) { |
| 423 | inputElement.value = String(clamped); |
| 424 | toastr.warning(t`Value must be between ${min} and ${max}. Clamped to ${clamped}.`); |
| 425 | } |
| 426 | }); |
| 427 | |
| 428 | const labelText = document.createElement('span'); |
| 429 | labelText.innerText = input.label; |
| 430 | labelText.dataset.i18n = input.label; |
| 431 | |
| 432 | label.appendChild(labelText); |
| 433 | label.appendChild(inputElement); |
| 434 | |
| 435 | this.inputControls.appendChild(label); |
| 436 | } else { |
| 437 | console.warn('Unknown custom input type. Only checkbox, text, number and textarea are supported.', input); |
| 438 | return; |
| 439 | } |
| 440 | }); |
| 441 | |
| 442 | // Set the default button class |
| 443 | const defaultButton = this.buttonControls.querySelector(`[data-result="${this.defaultResult}"]`); |
| 444 | if (defaultButton) defaultButton.classList.add('menu_button_default'); |
| 445 | |
| 446 | // Styling differences depending on the popup type |
| 447 | // General styling for all types first, that might be overridden for specific types below |
| 448 | this.mainInput.style.display = 'none'; |
| 449 | this.inputControls.style.display = customInputs ? 'block' : 'none'; |
| 450 | this.closeButton.style.display = 'none'; |
| 451 | this.cropWrap.style.display = 'none'; |
| 452 | |
| 453 | switch (type) { |
| 454 | case POPUP_TYPE.TEXT: { |
| 455 | //Text shows OK if not explicitly set to false, and CANCEL only if defined as true or with a caption |
| 456 | if (okButton === false) this.okButton.style.display = 'none'; |
| 457 | if (!cancelButton) this.cancelButton.style.display = 'none'; |
| 458 | break; |
| 459 | } |
| 460 | case POPUP_TYPE.CONFIRM: { |
| 461 | // Confirm shows OK if not explicitly set to false, and CANCEL if not explicitly set to false |
| 462 | if (okButton === false) this.okButton.style.display = 'none'; |
| 463 | if (cancelButton === false) this.cancelButton.style.display = 'none'; |
| 464 | // Override default captions for confirm on OK->Yes, CANCEL->No |
| 465 | if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-yes'); |
| 466 | if (!cancelButton) this.cancelButton.textContent = template.getAttribute('popup-button-no'); |
| 467 | break; |
| 468 | } |
| 469 | case POPUP_TYPE.INPUT: { |
| 470 | this.mainInput.style.display = 'block'; |
| 471 | // Input shows OK if not explicitly set to false, and CANCEL if not explicitly set to false |
| 472 | if (okButton === false) this.okButton.style.display = 'none'; |
| 473 | if (cancelButton === false) this.cancelButton.style.display = 'none'; |
| 474 | // Override default captions for input on OK->Save |
| 475 | if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-save'); |
| 476 | break; |
| 477 | } |
| 478 | case POPUP_TYPE.DISPLAY: { |
| 479 | // Display hides OK and CANCEL and all main button controls |
| 480 | this.buttonControls.style.display = 'none'; |
| 481 | this.closeButton.style.display = 'block'; |
| 482 | break; |
| 483 | } |
| 484 | case POPUP_TYPE.CROP: { |
| 485 | this.cropWrap.style.display = 'block'; |
| 486 | this.cropImage.src = cropImage; |
| 487 | $(this.cropImage).cropper({ |
| 488 | aspectRatio: cropAspect ?? 2 / 3, |
| 489 | autoCropArea: 1, |
| 490 | viewMode: 2, |
| 491 | rotatable: false, |
| 492 | crop: (event) => { |
| 493 | this.cropData = event.detail; |
| 494 | this.cropData.want_resize = !power_user.never_resize_avatars; |
| 495 | }, |
| 496 | }); |
| 497 | // Crop shows OK if not explicitly set to false, and CANCEL if not explicitly set to false |
| 498 | if (okButton === false) this.okButton.style.display = 'none'; |
| 499 | if (cancelButton === false) this.cancelButton.style.display = 'none'; |
| 500 | // Override default captions for crop on OK->Crop |
| 501 | if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-crop'); |
| 502 | break; |
| 503 | } |
| 504 | default: { |
| 505 | console.warn('Unknown popup type.', type); |
| 506 | break; |
| 507 | } |
| 508 | } |
| 509 | |
| 510 | this.mainInput.value = inputValue; |
| 511 | this.mainInput.rows = rows ?? 1; |
| 512 | |
| 513 | // Apply placeholder and tooltip based on popup type |
| 514 | if (type === POPUP_TYPE.INPUT) { |
| 515 | // For INPUT type, apply to the main input element |
| 516 | this.mainInput.placeholder = placeholder ?? ''; |
| 517 | setTitleFromTooltip(this.mainInput, tooltip); |
| 518 | } else { |
| 519 | // For other types, apply tooltip to the content area |
| 520 | setTitleFromTooltip(this.content, tooltip); |
| 521 | } |
| 522 | |
| 523 | this.content.innerHTML = ''; |
| 524 | if (content instanceof jQuery) { |
| 525 | $(this.content).append(content); |
| 526 | } else if (content instanceof HTMLElement) { |
| 527 | this.content.append(content); |
| 528 | } else if (typeof content == 'string') { |
| 529 | this.content.innerHTML = content; |
| 530 | } else { |
| 531 | console.warn('Unknown popup text type. Should be jQuery, HTMLElement or string.', content); |
| 532 | } |
| 533 | |
| 534 | // Already prepare the auto-focus control by adding the "autofocus" attribute, this should be respected by showModal() |
| 535 | this.setAutoFocus({ applyAutoFocus: true }); |
| 536 | |
| 537 | // Set focus event that remembers the focused element |
| 538 | this.dlg.addEventListener('focusin', (evt) => { if (evt.target instanceof HTMLElement && evt.target != this.dlg) this.lastFocus = evt.target; }); |
| 539 | |
| 540 | // Bind event listeners for all result controls to their defined event type |
| 541 | this.dlg.querySelectorAll('[data-result]').forEach(resultControl => { |
| 542 | if (!(resultControl instanceof HTMLElement)) return; |
| 543 | // If no value was set, we exit out and don't bind an action |
| 544 | if (String(resultControl.dataset.result) === String(undefined)) return; |
| 545 | |
| 546 | // Make sure that both `POPUP_RESULT` numbers and also `null` as 'cancelled' are supported |
| 547 | const result = String(resultControl.dataset.result) === String(null) ? null |
| 548 | : Number(resultControl.dataset.result); |
| 549 | |
| 550 | if (result !== null && isNaN(result)) throw new Error('Invalid result control. Result must be a number. ' + resultControl.dataset.result); |
| 551 | const type = resultControl.dataset.resultEvent || 'click'; |
| 552 | resultControl.addEventListener(type, async () => await this.complete(result)); |
| 553 | }); |
| 554 | |
| 555 | // Bind dialog listeners manually, so we can be sure context is preserved |
| 556 | const cancelListener = async (evt) => { |
| 557 | if (!this.#allowEscapeClose) { |
| 558 | evt.preventDefault(); |
| 559 | evt.stopPropagation(); |
| 560 | // Set flag so closeListener also blocks the close event (browser may fire it after multiple Escape presses) |
| 561 | this.#isClosingPrevented = true; |
| 562 | |
| 563 | // Check for double-escape within 500ms to allow force-closing |
| 564 | const now = Date.now(); |
| 565 | const timeSinceLastEscape = now - this.#lastEscapePress; |
| 566 | this.#lastEscapePress = now; |
| 567 | |
| 568 | if (timeSinceLastEscape < 500 && !this.#isShowingForceCloseConfirm) { |
| 569 | this.#isShowingForceCloseConfirm = true; |
| 570 | |
| 571 | // Defer to next frame to escape the current event context, |
| 572 | // allowing the confirmation popup to stack properly on top |
| 573 | requestAnimationFrame(async () => { |
| 574 | const confirmPopup = new Popup( |
| 575 | PopupUtils.BuildTextWithHeader( |
| 576 | t`Force-close Blocking Popup`, ` |
| 577 | <p>${t`This action is blocking and not meant to be closed manually.`}</p> |
| 578 | <p>${t`Force-closing may leave the application in an inconsistent state.`}</p> |
| 579 | <p><strong>${t`Are you sure you want to force-close?`}</strong></p>`), |
| 580 | POPUP_TYPE.CONFIRM, |
| 581 | '', |
| 582 | { okButton: t`Force Close`, cancelButton: t`Cancel` }); |
| 583 | |
| 584 | // If the the main popup closes while the force-close popup is still being displayed, we gracefully cancel that. |
| 585 | const originalOnClose = this.onClose; |
| 586 | this.onClose = async (x) => { |
| 587 | if (originalOnClose) await originalOnClose; |
| 588 | await confirmPopup.completeCancelled(); |
| 589 | }; |
| 590 | |
| 591 | |
| 592 | const result = await confirmPopup.show(); |
| 593 | this.#isShowingForceCloseConfirm = false; |
| 594 | if (result === POPUP_RESULT.AFFIRMATIVE) { |
| 595 | // Force-close by bypassing the normal close prevention |
| 596 | this.#isClosingPrevented = false; |
| 597 | await this.complete(POPUP_RESULT.CANCELLED); |
| 598 | } |
| 599 | }); |
| 600 | } |
| 601 | return; |
| 602 | } |
| 603 | |
| 604 | evt.preventDefault(); |
| 605 | evt.stopPropagation(); |
| 606 | await this.complete(POPUP_RESULT.CANCELLED); |
| 607 | }; |
| 608 | this.dlg.addEventListener('cancel', cancelListener.bind(this)); |
| 609 | |
| 610 | // Don't ask me why this is needed. I don't get it. But we have to keep it. |
| 611 | // 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, |
| 612 | // it seems to just call 'close' on the dialog even if the 'cancel' event was prevented. |
| 613 | // So here we just say that close should not happen if it was prevented. |
| 614 | const closeListener = async (evt) => { |
| 615 | if (this.#isClosingPrevented) { |
| 616 | evt.preventDefault(); |
| 617 | evt.stopPropagation(); |
| 618 | this.dlg.showModal(); |
| 619 | } |
| 620 | }; |
| 621 | this.dlg.addEventListener('close', closeListener.bind(this)); |
| 622 | |
| 623 | const keyListener = async (evt) => { |
| 624 | switch (evt.key) { |
| 625 | case 'Enter': { |
| 626 | // CTRL+Enter counts as a closing action, but all other modifiers (ALT, SHIFT) should not trigger this |
| 627 | if (evt.altKey || evt.shiftKey) |
| 628 | return; |
| 629 | |
| 630 | // Check if we are the currently active popup |
| 631 | if (this.dlg != document.activeElement?.closest('.popup')) |
| 632 | return; |
| 633 | |
| 634 | // Check if the current focus is a result control. Only should we apply the complete action |
| 635 | const resultControl = document.activeElement?.closest('.result-control'); |
| 636 | if (!resultControl) |
| 637 | return; |
| 638 | |
| 639 | // Check if we are inside an input type text or a textarea field and send on enter is disabled |
| 640 | const textarea = document.activeElement?.closest('textarea'); |
| 641 | if (textarea instanceof HTMLTextAreaElement && !shouldSendOnEnter()) |
| 642 | return; |
| 643 | const input = document.activeElement?.closest('input[type="text"]'); |
| 644 | if (input instanceof HTMLInputElement && !shouldSendOnEnter()) |
| 645 | return; |
| 646 | |
| 647 | // If this is a multiline input popup, we should still not simply send on enter, that'd be weird. |
| 648 | // Let's still make it possible if CTRL is toggled though |
| 649 | if ((textarea instanceof HTMLTextAreaElement || input instanceof HTMLInputElement) |
| 650 | && !evt.ctrlKey && this.mainInput.rows > 1) { |
| 651 | return; |
| 652 | } |
| 653 | |
| 654 | evt.preventDefault(); |
| 655 | evt.stopPropagation(); |
| 656 | const result = Number(document.activeElement.getAttribute('data-result') ?? this.defaultResult); |
| 657 | |
| 658 | // Call complete on the popup. Make sure that we handle `onClosing` cancels correctly and don't remove the listener then. |
| 659 | await this.complete(result); |
| 660 | |
| 661 | break; |
| 662 | } |
| 663 | } |
| 664 | }; |
| 665 | this.dlg.addEventListener('keydown', keyListener.bind(this)); |
| 666 | } |
| 667 | |
| 668 | /** |
| 669 | * Asynchronously shows the popup element by appending it to the document body, |
| 670 | * setting its display to 'block' and focusing on the input if the popup type is INPUT. |
| 671 | * |
| 672 | * @returns {Promise<string|number|boolean?>} A promise that resolves with the value of the popup when it is completed. |
| 673 | */ |
| 674 | async show() { |
| 675 | document.body.append(this.dlg); |
| 676 | |
| 677 | // Run opening animation |
| 678 | this.dlg.setAttribute('opening', ''); |
| 679 | |
| 680 | this.dlg.showModal(); |
| 681 | |
| 682 | // We need to fix the toastr to be present inside this dialog |
| 683 | fixToastrForDialogs(); |
| 684 | |
| 685 | runAfterAnimation(this.dlg, () => { |
| 686 | this.dlg.removeAttribute('opening'); |
| 687 | |
| 688 | // If we have an onOpen handler, we run it now |
| 689 | if (this.onOpen) { |
| 690 | this.onOpen(this); |
| 691 | } |
| 692 | }); |
| 693 | |
| 694 | this.#promise = new Promise((resolve) => { |
| 695 | this.#resolver = resolve; |
| 696 | }); |
| 697 | return this.#promise; |
| 698 | } |
| 699 | |
| 700 | setAutoFocus({ applyAutoFocus = false } = {}) { |
| 701 | /** @type {HTMLElement} */ |
| 702 | let control; |
| 703 | |
| 704 | // Try to find if we have an autofocus control already present |
| 705 | control = this.dlg.querySelector('[autofocus]'); |
| 706 | |
| 707 | // If not, find the default control for this popup type |
| 708 | if (!control) { |
| 709 | switch (this.type) { |
| 710 | case POPUP_TYPE.INPUT: { |
| 711 | control = this.mainInput; |
| 712 | break; |
| 713 | } |
| 714 | default: |
| 715 | // Select default button |
| 716 | control = this.buttonControls.querySelector(`[data-result="${this.defaultResult}"]`); |
| 717 | break; |
| 718 | } |
| 719 | } |
| 720 | |
| 721 | if (!control) { |
| 722 | return; |
| 723 | } |
| 724 | |
| 725 | if (applyAutoFocus) { |
| 726 | control.setAttribute('autofocus', ''); |
| 727 | // Manually enable tabindex too, as this might only be applied by the interactable functionality in the background, but too late for HTML autofocus |
| 728 | // interactable only gets applied when inserted into the DOM |
| 729 | control.tabIndex = 0; |
| 730 | } else { |
| 731 | control.focus(); |
| 732 | } |
| 733 | } |
| 734 | |
| 735 | /** |
| 736 | * Completes the popup and sets its result and value |
| 737 | * |
| 738 | * The completion handling will make the popup return the result to the original show promise. |
| 739 | * |
| 740 | * There will be two different types of result values: |
| 741 | * - popup with `POPUP_TYPE.INPUT` will return the input value - or `false` on negative and `null` on cancelled |
| 742 | * - All other will return the result value as provided as `POPUP_RESULT` or a custom number value |
| 743 | * |
| 744 | * <b>IMPORTANT:</b> If the popup closing was cancelled via the `onClosing` handler, the return value will be `Promise<undefined>`. |
| 745 | * |
| 746 | * @param {POPUP_RESULT|number} result - The result of the popup (either an existing `POPUP_RESULT` or a custom result value) |
| 747 | * |
| 748 | * @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> |
| 749 | */ |
| 750 | async complete(result) { |
| 751 | // In all cases besides INPUT the popup value should be the result |
| 752 | /** @type {POPUP_RESULT|number|boolean|string?} */ |
| 753 | let value = result; |
| 754 | // 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 |
| 755 | if (this.type === POPUP_TYPE.INPUT) { |
| 756 | if (result >= POPUP_RESULT.AFFIRMATIVE) value = this.mainInput.value; |
| 757 | else if (result === POPUP_RESULT.NEGATIVE) value = false; |
| 758 | else if (result === POPUP_RESULT.CANCELLED) value = null; |
| 759 | else value = false; // Might a custom negative value? |
| 760 | } |
| 761 | |
| 762 | // Cropped image should be returned as a data URL |
| 763 | if (this.type === POPUP_TYPE.CROP) { |
| 764 | value = result >= POPUP_RESULT.AFFIRMATIVE |
| 765 | ? $(this.cropImage).data('cropper').getCroppedCanvas().toDataURL('image/jpeg') |
| 766 | : null; |
| 767 | } |
| 768 | |
| 769 | if (this.customInputs?.length) { |
| 770 | this.inputResults = new Map(this.customInputs.map(input => { |
| 771 | /** @type {HTMLInputElement} */ |
| 772 | const inputControl = this.dlg.querySelector(`#${input.id}`); |
| 773 | const value = ['text', 'textarea', 'number'].includes(input.type) ? inputControl.value : inputControl.checked; |
| 774 | return [inputControl.id, value]; |
| 775 | })); |
| 776 | } |
| 777 | |
| 778 | this.value = value; |
| 779 | this.result = result; |
| 780 | |
| 781 | if (this.onClosing) { |
| 782 | const shouldClose = await this.onClosing(this); |
| 783 | if (!shouldClose) { |
| 784 | this.#isClosingPrevented = true; |
| 785 | // Set values back if we cancel out of closing the popup |
| 786 | this.value = undefined; |
| 787 | this.result = undefined; |
| 788 | this.inputResults = undefined; |
| 789 | return undefined; |
| 790 | } |
| 791 | } |
| 792 | this.#isClosingPrevented = false; |
| 793 | |
| 794 | Popup.util.lastResult = { value, result, inputResults: this.inputResults }; |
| 795 | this.#hide(); |
| 796 | |
| 797 | return this.#promise; |
| 798 | } |
| 799 | async completeAffirmative() { |
| 800 | return await this.complete(POPUP_RESULT.AFFIRMATIVE); |
| 801 | } |
| 802 | async completeNegative() { |
| 803 | return await this.complete(POPUP_RESULT.NEGATIVE); |
| 804 | } |
| 805 | async completeCancelled() { |
| 806 | return await this.complete(POPUP_RESULT.CANCELLED); |
| 807 | } |
| 808 | |
| 809 | /** |
| 810 | * Hides the popup, using the internal resolver to return the value to the original show promise |
| 811 | */ |
| 812 | #hide() { |
| 813 | // We close the dialog, first running the animation |
| 814 | this.dlg.setAttribute('closing', ''); |
| 815 | |
| 816 | // Once the hiding starts, we need to fix the toastr to the layer below |
| 817 | fixToastrForDialogs(); |
| 818 | |
| 819 | // After the dialog is actually completely closed, remove it from the DOM |
| 820 | runAfterAnimation(this.dlg, async () => { |
| 821 | // Call the close on the dialog |
| 822 | this.dlg.close(); |
| 823 | |
| 824 | // Run a possible custom handler right before DOM removal |
| 825 | if (this.onClose) { |
| 826 | await this.onClose(this); |
| 827 | } |
| 828 | |
| 829 | // Remove it from the dom |
| 830 | this.dlg.remove(); |
| 831 | |
| 832 | // Remove it from the popup references |
| 833 | removeFromArray(Popup.util.popups, this); |
| 834 | |
| 835 | // If there is any popup below this one, see if we can set the focus |
| 836 | if (Popup.util.popups.length > 0) { |
| 837 | const activeDialog = document.activeElement?.closest('.popup'); |
| 838 | const id = activeDialog?.getAttribute('data-id'); |
| 839 | const popup = Popup.util.popups.find(x => x.id == id); |
| 840 | if (popup) { |
| 841 | if (popup.lastFocus) popup.lastFocus.focus(); |
| 842 | else popup.setAutoFocus(); |
| 843 | } |
| 844 | } |
| 845 | |
| 846 | this.#resolver(this.value); |
| 847 | }); |
| 848 | } |
| 849 | |
| 850 | /** |
| 851 | * Show a popup with any of the given helper methods. Use `await` to make them blocking. |
| 852 | */ |
| 853 | static show = showPopupHelper; |
| 854 | |
| 855 | /** |
| 856 | * Utility for popup and popup management. |
| 857 | * |
| 858 | * Contains the list of all currently open popups, and it'll remember the result of the last closed popup. |
| 859 | */ |
| 860 | static util = { |
| 861 | /** @readonly @type {Popup[]} Remember all popups */ |
| 862 | popups: [], |
| 863 | |
| 864 | /** @type {{value: any, result: POPUP_RESULT|number?, inputResults: Map<string, string|boolean>?}?} Last popup result */ |
| 865 | lastResult: null, |
| 866 | |
| 867 | /** @returns {boolean} Checks if any modal popup dialog is open */ |
| 868 | isPopupOpen() { |
| 869 | return Popup.util.popups.filter(x => x.dlg.hasAttribute('open')).length > 0; |
| 870 | }, |
| 871 | |
| 872 | /** |
| 873 | * Returns the topmost modal layer in the document. If there is an open dialog popup, |
| 874 | * it returns the dialog element. Otherwise, it returns the document body. |
| 875 | * |
| 876 | * @return {HTMLElement} The topmost modal layer element |
| 877 | */ |
| 878 | getTopmostModalLayer() { |
| 879 | return getTopmostModalLayer(); |
| 880 | }, |
| 881 | }; |
| 882 | } |
| 883 | |
| 884 | export class PopupUtils { |
| 885 | /** |
| 886 | * Builds popup content with header and text below |
| 887 | * |
| 888 | * @param {string?} header - The header to be added to the text |
| 889 | * @param {string?} text - The main text content |
| 890 | */ |
| 891 | static BuildTextWithHeader(header, text) { |
| 892 | if (!header) { |
| 893 | return text; |
| 894 | } |
| 895 | return `<h3>${header}</h3> |
| 896 | ${text ?? ''}`; // Convert no text to empty string |
| 897 | } |
| 898 | } |
| 899 | |
| 900 | /** |
| 901 | * Displays a blocking popup with a given content and type |
| 902 | * |
| 903 | * @param {JQuery<HTMLElement>|string|Element} content - Content or text to display in the popup |
| 904 | * @param {POPUP_TYPE} type |
| 905 | * @param {string} inputValue - Value to set the input to |
| 906 | * @param {PopupOptions} [popupOptions={}] - Options for the popup |
| 907 | * @returns {Promise<POPUP_RESULT|string|boolean?>} The value for this popup, which can either be the popup retult or the input value if chosen |
| 908 | */ |
| 909 | export function callGenericPopup(content, type, inputValue = '', popupOptions = {}) { |
| 910 | const popup = new Popup( |
| 911 | content, |
| 912 | type, |
| 913 | inputValue, |
| 914 | popupOptions, |
| 915 | ); |
| 916 | return popup.show(); |
| 917 | } |
| 918 | |
| 919 | /** |
| 920 | * Returns the topmost modal layer in the document. If there is an open dialog, |
| 921 | * it returns the dialog element. Otherwise, it returns the document body. |
| 922 | * |
| 923 | * @return {HTMLElement} The topmost modal layer element |
| 924 | */ |
| 925 | export function getTopmostModalLayer() { |
| 926 | const dlg = Array.from(document.querySelectorAll('dialog[open]:not([closing])')).pop(); |
| 927 | if (dlg instanceof HTMLElement) return dlg; |
| 928 | return document.body; |
| 929 | } |
| 930 | |
| 931 | /** |
| 932 | * 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 |
| 933 | */ |
| 934 | export function fixToastrForDialogs() { |
| 935 | // Hacky way of getting toastr to actually display on top of the popup... |
| 936 | const dlg = Array.from(document.querySelectorAll('dialog[open]:not([closing])')).pop(); |
| 937 | |
| 938 | let toastContainer = document.getElementById('toast-container'); |
| 939 | const isAlreadyPresent = !!toastContainer; |
| 940 | if (!toastContainer) { |
| 941 | toastContainer = document.createElement('div'); |
| 942 | toastContainer.setAttribute('id', 'toast-container'); |
| 943 | if (toastr.options.positionClass) toastContainer.classList.add(toastr.options.positionClass); |
| 944 | } |
| 945 | |
| 946 | // Check if toastr is already a child. If not, we need to move it inside this dialog. |
| 947 | // This is either the existing toastr container or the newly created one. |
| 948 | if (dlg && !dlg.contains(toastContainer)) { |
| 949 | dlg?.appendChild(toastContainer); |
| 950 | return; |
| 951 | } |
| 952 | |
| 953 | // Now another case is if we only have one popup and that is currently closing. In that case the toastr container exists, |
| 954 | // 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. |
| 955 | // To prevent new toasts from being showing up in there and then vanish in an instant, |
| 956 | // we move the toastr back to the main body, or delete if its empty |
| 957 | if (!dlg && isAlreadyPresent) { |
| 958 | if (!toastContainer.childNodes.length) { |
| 959 | toastContainer.remove(); |
| 960 | } else { |
| 961 | document.body.appendChild(toastContainer); |
| 962 | toastContainer.classList.remove(...toastPositionClasses); |
| 963 | toastContainer.classList.add(toastr.options.positionClass); |
| 964 | } |
| 965 | } |
| 966 | } |