Blame Raw
Cohee · 51ad27fb · · 966 lines (45.4 KB)
3 contributors
1import dialogPolyfill from '../lib/dialog-polyfill.esm.js';
2import { shouldSendOnEnter } from './RossAscends-mods.js';
3import { t } from './i18n.js';
4import { power_user, toastPositionClasses } from './power-user.js';
5import { clamp, removeFromArray, runAfterAnimation, uuidv4 } from './utils.js';
6
7/** @readonly */
8/** @enum {Number} */
9export 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?} */
24export 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 */
96const 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
148export 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
884export 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 */
909export 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 */
925export 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 */
934export 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}