Blame Raw
Cohee · 51ad27fb · · 617 lines (18.8 KB)
2 contributors
1/**
2 * Unified action loader system - shows loader overlay with optional toast notifications.
3 * Designed to be flexible and reusable for various long-running operations.
4 *
5 * Features:
6 * - Stacking multiple loaders - overlay stays single, but toasts can stack
7 * - Blocking and non-blocking modes
8 * - Stoppable or static toasts
9 * - Class-based handle system for fine-grained control
10 *
11 * @module action-loader
12 */
13
14import { t } from './i18n.js';
15import { stopGeneration } from '../script.js';
16import { Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
17
18/**
19 * Enum representing the toast display mode for the action loader.
20 * @readonly
21 * @enum {string}
22 */
23export const ActionLoaderToastMode = {
24 /** No toast is displayed */
25 NONE: 'none',
26 /** Toast is displayed without stop button (non-interactable) */
27 STATIC: 'static',
28 /** Toast is displayed with stop button (default) */
29 STOPPABLE: 'stoppable',
30};
31
32/**
33 * @typedef {object} ActionLoaderOptions
34 * @property {boolean} [blocking=true] - Whether to show the blocking overlay. Set to false for non-blocking toast-only loaders.
35 * @property {ActionLoaderToastMode} [toastMode='stoppable'] - Toast display mode
36 * @property {string} [slug=null] - Unique slug for the loader to identify it easily via code or CSS
37 * @property {string} [message='Generating...'] - The message to display in the toast
38 * @property {string} [title] - Optional title for the toast notification
39 * @property {string} [stopTooltip='Stop'] - Tooltip text for the stop button
40 * @property {HTMLElement|string|null} [overlayContent=null] - Custom content for the overlay (replaces default spinner)
41 * @property {(() => void)|null} [onStop=null] - Custom stop handler. If null, calls `stopGeneration()`
42 * @property {(() => void)|null} [onHide=null] - Custom hide handler. Called when the loader is hidden (not stopped).
43 */
44
45/** Counter for generating unique loader IDs */
46let loaderIdCounter = 0;
47
48/** @type {Set<ActionLoaderHandle>} Set of all active loader handles */
49const activeHandles = new Set();
50
51/**
52 * Generates a unique loader ID.
53 * @returns {string} Unique loader ID
54 */
55function generateLoaderId() {
56 return `loader_${++loaderIdCounter}`;
57}
58
59/**
60 * Checks if there are any active blocking loaders.
61 * @returns {boolean} True if at least one blocking loader is active
62 */
63function hasBlockingLoaders() {
64 for (const handle of activeHandles) {
65 if (handle.isBlocking && handle.isActive) {
66 return true;
67 }
68 }
69 return false;
70}
71
72/**
73 * Class representing an action loader handle.
74 * Manages its own toast, stop handler, and lifecycle.
75 */
76export class ActionLoaderHandle {
77 /**
78 * A special empty handle that is already disposed. Useful as a default value to avoid null checks.
79 * Does not generate any id, toast, or overlay, and all its methods are no-ops.
80 * @type {ActionLoaderHandle}
81 */
82 static get EMPTY() {
83 return new ActionLoaderHandle({ predisposed: true });
84 }
85
86 /** @type {string} Unique identifier for this handle */
87 #id;
88
89 /** @type {string|null} Unique slug for the loader */
90 #slug = null;
91
92 /** @type {JQuery<HTMLElement>|null} The toast element for this loader */
93 #toast = null;
94
95 /** @type {(() => void)|null} Custom stop handler */
96 #onStop = null;
97
98 /** @type {(() => void)|null} Custom hide handler */
99 #onHide = null;
100
101 /** @type {boolean} Whether this loader blocks the UI with an overlay */
102 #blocking = true;
103
104 /** @type {boolean} Whether this handle has been disposed */
105 #disposed = false;
106
107 /**
108 * Creates a new ActionLoaderHandle.
109 * @param {object} options - Configuration options
110 * @param {boolean} [options.blocking=true] - Whether to show blocking overlay
111 * @param {ActionLoaderToastMode} [options.toastMode] - Toast display mode
112 * @param {string|null} [options.slug] - Unique slug for the loader (to identify it easily via code or CSS)
113 * @param {string} [options.message='Generating...'] - Message to display in the toast
114 * @param {string} [options.title] - Title for the toast notification
115 * @param {string} [options.stopTooltip='Stop'] - Tooltip for the stop button
116 * @param {boolean} [options.predisposed=false] - Whether this handle is already disposed (for special use)
117 * @param {HTMLElement|string|null} [options.overlayContent] - Custom content for the overlay (replaces default spinner)
118 * @param {(() => void)|null} [options.onStop] - Custom stop handler
119 * @param {(() => void)|null} [options.onHide] - Custom hide handler
120 */
121 constructor({
122 blocking = true,
123 toastMode = ActionLoaderToastMode.STOPPABLE,
124 slug = null,
125 message = t`Generating...`,
126 title = '',
127 stopTooltip = t`Stop`,
128 overlayContent = null,
129 onStop = null,
130 onHide = null,
131 predisposed = false,
132 } = {}) {
133 if (predisposed) {
134 this.#disposed = true;
135 return;
136 }
137
138 this.#id = generateLoaderId();
139 this.#slug = slug;
140 this.#blocking = blocking;
141 this.#onStop = onStop;
142 this.#onHide = onHide;
143
144 // Warn if non-blocking loader has no toast - it won't be visible to the user
145 if (!blocking && toastMode === ActionLoaderToastMode.NONE && !overlayContent) {
146 console.warn('[ActionLoader] Non-blocking loader created without a toast. This loader will not be visible to the user.');
147 }
148
149 // Show the blocking loader overlay if this is the first blocking handle
150 if (blocking && !hasBlockingLoaders() && !isOverlayDisplayed()) {
151 showOverlay(overlayContent);
152 }
153
154 // Register this handle
155 activeHandles.add(this);
156
157 // Create toast if needed
158 if (toastMode !== ActionLoaderToastMode.NONE) {
159 this.#createToast(message, title, toastMode, stopTooltip);
160 }
161 }
162
163 /**
164 * Creates the toast element for this loader.
165 * @param {string} message - Message to display
166 * @param {string} title - Title for the toast
167 * @param {ActionLoaderToastMode} toastMode - Toast mode
168 * @param {string} stopTooltip - Tooltip for stop button
169 */
170 #createToast(message, title, toastMode, stopTooltip) {
171 const toastContent = document.createElement('div');
172 toastContent.className = 'action-loader-toast';
173
174 if (this.#slug) {
175 toastContent.dataset.slug = this.#slug;
176 }
177 toastContent.dataset.loaderId = this.#id;
178 toastContent.dataset.blocking = this.#blocking.toString();
179
180 const messageSpan = document.createElement('span');
181 messageSpan.className = 'action-loader-message';
182 messageSpan.textContent = message;
183 toastContent.appendChild(messageSpan);
184
185 // Add stop button if mode is STOPPABLE
186 if (toastMode === ActionLoaderToastMode.STOPPABLE) {
187 const stopButton = document.createElement('i');
188 stopButton.className = 'fa-solid fa-stop-circle action-loader-stop interactable';
189 stopButton.title = stopTooltip;
190 stopButton.addEventListener('click', (e) => {
191 e.preventDefault();
192 e.stopPropagation();
193 this.stop();
194 });
195 toastContent.appendChild(stopButton);
196 }
197
198 // Show toast with no timeout (sticky)
199 this.#toast = toastr.info($(toastContent), title, {
200 timeOut: 0,
201 extendedTimeOut: 0,
202 tapToDismiss: false,
203 escapeHtml: false,
204 });
205 }
206
207 /**
208 * Clears the toast element for this loader.
209 */
210 #clearToast() {
211 if (this.#toast) {
212 toastr.clear(this.#toast, { force: true }); // Need to force as the toast might have focus/hover
213 this.#toast = null;
214 }
215 }
216
217 /**
218 * Disposes this handle, removing it from active handles and hiding overlay if last.
219 */
220 async #dispose() {
221 if (this.#disposed) return;
222 this.#disposed = true;
223
224 this.#clearToast();
225 activeHandles.delete(this);
226
227 // Hide the overlay if this was the last blocking handle
228 if (this.#blocking && !hasBlockingLoaders()) {
229 await hideOverlay();
230 }
231 }
232
233 /**
234 * The unique identifier for this loader handle.
235 * @returns {string}
236 */
237 get id() {
238 return this.#id;
239 }
240
241 /**
242 * The unique slug for this loader handle, used to identify it easily via code or CSS.
243 * @returns {string|null}
244 */
245 get slug() {
246 return this.#slug;
247 }
248
249 /**
250 * Whether this handle is still active (not disposed).
251 * @returns {boolean}
252 */
253 get isActive() {
254 return !this.#disposed;
255 }
256
257 /**
258 * Whether this loader blocks the UI with an overlay.
259 * @returns {boolean}
260 */
261 get isBlocking() {
262 return this.#blocking;
263 }
264
265 /**
266 * Triggers the stop action on this loader.
267 * Calls the custom onStop handler if provided, otherwise calls stopGeneration().
268 * Then hides this loader.
269 */
270 async stop() {
271 if (this.#disposed) return;
272
273 // Call custom stop handler or default
274 if (this.#onStop) {
275 try {
276 await this.#onStop();
277 } catch (e) {
278 console.error('Error executing onStop handler', e);
279 }
280 } else {
281 stopGeneration();
282 }
283
284 // Dispose without calling onHide (stop is different from hide)
285 await this.#dispose();
286 }
287
288 /**
289 * Hides this loader and clears its toast.
290 * Calls the custom onHide handler if provided.
291 */
292 async hide() {
293 if (this.#disposed) return;
294
295 // Call custom hide handler if provided
296 if (this.#onHide) {
297 try {
298 await this.#onHide();
299 } catch (e) {
300 console.error('Error executing onHide handler', e);
301 }
302 }
303
304 await this.#dispose();
305 }
306}
307
308/**
309 * Action loader utility API.
310 * Provides a convenient interface for showing and managing loading indicators.
311 *
312 * Read the functions documentation for more details.
313 *
314 * @example
315 * // Basic usage
316 * const handle = loader.show({ message: 'Loading...' });
317 * await someOperation();
318 * handle.hide();
319 *
320 * @example
321 * // Non-blocking background task
322 * const handle = loader.show({ blocking: false, message: 'Processing...' });
323 *
324 * @example
325 * // Hide all active loaders
326 * loader.hide();
327 */
328export const loader = {
329 /**
330 * Shows an action loader with optional toast notification.
331 * Returns a handle to control the loader.
332 * @type {typeof showActionLoader}
333 */
334 show: showActionLoader,
335
336 /**
337 * Hides a specific loader by handle, or all loaders if no handle provided.
338 * @type {typeof hideActionLoader}
339 */
340 hide: hideActionLoader,
341
342 /**
343 * Gets all currently active loader handles.
344 * @type {typeof getActiveLoaderHandles}
345 */
346 active: getActiveLoaderHandles,
347
348 /**
349 * Gets a loader handle by its ID.
350 * @type {typeof getLoaderHandleById}
351 */
352 get: getLoaderHandleById,
353
354 /**
355 * Checks if any blocking loader overlay is currently displayed.
356 * @returns {boolean} True if a blocking overlay is shown
357 */
358 isBlocking: isOverlayDisplayed,
359
360 /**
361 * Toast display mode constants.
362 * @type {typeof ActionLoaderToastMode}
363 */
364 ToastMode: ActionLoaderToastMode,
365
366 /**
367 * The ActionLoaderHandle class.
368 * @type {typeof ActionLoaderHandle}
369 */
370 Handle: ActionLoaderHandle,
371
372 /**
373 * Creates a fresh default loader overlay element.
374 * @type {typeof createDefaultLoaderOverlay}
375 */
376 createOverlay: createDefaultLoaderOverlay,
377};
378
379/**
380 * Shows an action loader with an optional stoppable toast notification.
381 * Multiple loaders can be stacked - the overlay stays single, but each gets its own toast.
382 * When the last loader is hidden, the overlay is removed.
383 *
384 * With default arguments, will function as a generation loader / wrapper.
385 *
386 * @param {ActionLoaderOptions} [options={}] - Configuration options
387 * @returns {ActionLoaderHandle} Handle to control the loader
388 *
389 * @example
390 * // Basic usage
391 * const loader = showActionLoader({ message: 'Generating title...' });
392 * try {
393 * const result = await generateRaw({ prompt });
394 * // process result
395 * } finally {
396 * await loader.hide();
397 * }
398 *
399 * @example
400 * // With custom stop and hide handlers
401 * const loader = showActionLoader({
402 * message: 'Downloading...',
403 * stopTooltip: 'Cancel download',
404 * onStop: () => myCustomCancelFunction(),
405 * onHide: () => console.log('Loader hidden'),
406 * });
407 *
408 * @example
409 * // Stacking multiple loaders
410 * const loader1 = showActionLoader({ message: 'Task 1...' });
411 * const loader2 = showActionLoader({ message: 'Task 2...' });
412 * await loader1.hide(); // Overlay stays, loader2 still active
413 * await loader2.hide(); // Now overlay hides
414 *
415 * @example
416 * // Non-blocking loader (toast only, no overlay)
417 * const loader = showActionLoader({
418 * message: 'Captioning image...',
419 * blocking: false,
420 * onStop: () => abortCaptioning(),
421 * });
422 */
423export function showActionLoader(options = {}) {
424 return new ActionLoaderHandle(options);
425}
426
427/**
428 * Hides a specific action loader by handle, or all active loaders if no handle provided.
429 * @param {ActionLoaderHandle|null} [handle=null] - Specific handle to hide, or undefined to hide all
430 * @returns {Promise<boolean>} Whether any loader was hidden
431 */
432export async function hideActionLoader(handle = null) {
433 if (handle instanceof ActionLoaderHandle) {
434 if (handle.isActive) {
435 await handle.hide();
436 return true;
437 }
438 return false;
439 }
440
441 // No handle provided - hide all active loaders
442 const handles = getActiveLoaderHandles();
443 for (const h of handles) {
444 await h.hide();
445 }
446 return handles.length > 0;
447}
448
449/**
450 * Gets all currently active loader handles.
451 * @returns {ActionLoaderHandle[]} Array of active handles
452 */
453export function getActiveLoaderHandles() {
454 return Array.from(activeHandles);
455}
456
457/**
458 * Gets a loader handle by its ID.
459 * @param {string} id - The handle ID
460 * @returns {ActionLoaderHandle|undefined} The handle, or undefined if not found
461 */
462export function getLoaderHandleById(id) {
463 for (const handle of activeHandles) {
464 if (handle.id === id) {
465 return handle;
466 }
467 }
468 return undefined;
469}
470
471// ============================================================================
472// Internal overlay management
473// ============================================================================
474
475/** @type {Popup|null} The current loader overlay popup */
476let loaderPopup = null;
477
478/** Whether the initial HTML preloader has been removed */
479let preloaderYoinked = false;
480
481/**
482 * Creates the default loader overlay element.
483 * Always returns a fresh element instance.
484 *
485 * @returns {HTMLDivElement} A new loader overlay element
486 */
487export function createDefaultLoaderOverlay() {
488 const loaderElement = document.createElement('div');
489 loaderElement.id = 'loader';
490
491 const spinnerElement = document.createElement('div');
492 spinnerElement.id = 'load-spinner';
493 spinnerElement.className = 'fa-solid fa-gear fa-spin fa-3x';
494
495 loaderElement.appendChild(spinnerElement);
496
497 return loaderElement;
498}
499
500/**
501 * Normalizes custom overlay content into a value supported by Popup.
502 * @param {string|HTMLElement|null} customContent - Custom overlay content
503 * @returns {string|HTMLElement} Content for Popup
504 */
505function getOverlayContent(customContent) {
506 if (typeof customContent === 'string') {
507 return customContent;
508 }
509
510 if (customContent instanceof HTMLElement) {
511 return customContent;
512 }
513
514 return createDefaultLoaderOverlay();
515}
516
517/**
518 * Checks if the loader overlay is currently displayed.
519 * @returns {boolean} True if overlay is shown
520 */
521function isOverlayDisplayed() {
522 return !!loaderPopup;
523}
524
525/**
526 * Shows the blocking loader overlay.
527 * Internal function - use showActionLoader() instead.
528 * @param {HTMLElement|string|null} [customContent] - Custom content for the overlay
529 */
530function showOverlay(customContent = null) {
531 // Two loaders don't make sense. Don't await, we can overlay the old loader while it closes
532 if (loaderPopup) loaderPopup.complete(POPUP_RESULT.CANCELLED);
533
534 const content = getOverlayContent(customContent);
535
536 loaderPopup = new Popup(content, POPUP_TYPE.DISPLAY, null, {
537 allowEscapeClose: false,
538 transparent: true,
539 animation: 'none',
540 wide: true,
541 large: true,
542 });
543
544 // No close button, loaders are not closable
545 loaderPopup.closeButton.style.display = 'none';
546
547 loaderPopup.show();
548}
549
550/**
551 * Hides the blocking loader overlay with animation.
552 * Internal function - use hideActionLoader() instead.
553 * @returns {Promise<void>}
554 */
555async function hideOverlay() {
556 if (!loaderPopup) {
557 return Promise.resolve();
558 }
559
560 return new Promise((resolve) => {
561 const loaderElement = $('#loader');
562 const spinner = $('#load-spinner');
563
564 if (!loaderElement.length) {
565 console.warn('Loader element not found, skipping animation');
566 cleanup();
567 return;
568 }
569
570 // Check if transitions are enabled on spinner (which has the transition property)
571 const transitionDuration = spinner.length && spinner[0] ? getComputedStyle(spinner[0]).transitionDuration : '0s';
572 const hasTransitions = parseFloat(transitionDuration) > 0;
573
574 if (hasTransitions) {
575 Promise.race([
576 new Promise((r) => setTimeout(r, 500)), // Fallback timeout
577 new Promise((r) => loaderElement.one('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', r)),
578 ]).finally(cleanup);
579 } else {
580 cleanup();
581 }
582
583 function cleanup() {
584 loaderElement.remove();
585 // Yoink preloader entirely; it only exists to cover up unstyled content while loading JS
586 // If it's present, we remove it once and then it's gone.
587 yoinkPreloader();
588
589 loaderPopup.complete(POPUP_RESULT.AFFIRMATIVE)
590 .catch((err) => console.error('Error completing loaderPopup:', err))
591 .finally(() => {
592 loaderPopup = null;
593 resolve();
594 });
595 }
596
597 // Apply the blur styles to the entire loader element
598 loaderElement.css({
599 'filter': 'blur(15px)',
600 'opacity': '0',
601 });
602 });
603}
604
605/**
606 * Removes the initial HTML preloader element.
607 * Called once after the first loader hide.
608 */
609function yoinkPreloader() {
610 if (preloaderYoinked) return;
611 document.getElementById('preloader')?.remove();
612 preloaderYoinked = true;
613}
614
615// ============================================================================
616// End internal overlay management
617// ============================================================================