Add Action Loader Module with Stacking Support and STscript Commands (#5311) * Add action loader utility with stoppable toast notifications * Add slash commands for action loader control (/loader-wrap, /loader-show, /loader-hide, /loader-stop) * Refactor action loader to support stacking multiple loaders with individual toast management - Convert action loader from singleton to class-based handle system (ActionLoaderHandle) - Support multiple concurrent loaders with single overlay and stacked toasts - Add unique ID generation and tracking for each loader instance - Implement onHide callback alongside existing onStop callback - Add getActiveLoaderHandles() and getLoaderHandleById() utility functions - Refactor hideActionLoader() to accept... * Improve action loader overlay and toast handling with better state checks and cleanup timing - Check isLoaderDisplayed() before showing overlay to prevent conflicts with existing loaders - Use toastr.options.hideDuration for toast removal timing instead of hardcoded 250ms - Simplify hideActionLoader() by using getActiveLoaderHandles() helper - Remove redundant empty check in hideActionLoader() - Add clarifying comment for intentional error throw in createClosureHandler() - Remove redundant 'onStop' default * fix stop button toast removal issue by using toastr.clear force option instead of manual removal with timeout * Fix isLoaderDisplayed() by using double negation operator instead of null comparison * Add non-blocking loader support with `blocking` parameter for toast-only action loaders - Add `blocking` option to ActionLoaderHandle (default: true) - Implement hasBlockingLoaders() helper to check for active blocking loaders - Show/hide overlay only when blocking loaders are active - Add `blocking` named argument to /loader-wrap and /loader-show commands - Update help strings with non-blocking usage examples - Import commonEnumProviders for boolean enum and isFalseBoolean utility * Add optional title parameter to action loader toast notifications and reorder constructor parameters for consistency - Add `title` parameter to ActionLoaderOptions and ActionLoaderHandle constructor - Pass title to toastr.info() for toast notifications - Reorder parameters: blocking, toastMode, message, title, stopTooltip (grouped by importance) - Add warning when creating non-blocking loader without toast (invisible to user) - Update /loader-wrap and /loader-show commands with title argument * Add loader utility API to action-loader and expose it in ST context for convenient programmatic access - Create `loader` object with show/hide/active/get methods and ToastMode/Handle exports - Add JSDoc examples for basic usage, non-blocking tasks, and hiding all loaders - Import and expose `loader` in ST context alongside existing loader functions * Split slash command and functional modules * Create abort controller on app init * Remove HTML tags from "returns" declaration --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
Signed| @@ -29,3 +29,27 @@ | ||
| 29 | 29 | align-items: center; |
| 30 | 30 | justify-content: center; |
| 31 | 31 | } |
| 32 | + | |
| 33 | +/* Action loader toast styles */ | |
| 34 | +.action-loader-toast { | |
| 35 | + display: flex; | |
| 36 | + align-items: center; | |
| 37 | + gap: 10px; | |
| 38 | + width: 100%; | |
| 39 | +} | |
| 40 | + | |
| 41 | +.action-loader-message { | |
| 42 | + flex: 1; | |
| 43 | +} | |
| 44 | + | |
| 45 | +.action-loader-stop { | |
| 46 | + cursor: pointer; | |
| 47 | + font-size: 1.2em; | |
| 48 | + opacity: 0.8; | |
| 49 | + transition: opacity 0.15s ease, color 0.15s ease; | |
| 50 | +} | |
| 51 | + | |
| 52 | +.action-loader-stop:hover { | |
| 53 | + opacity: 1; | |
| 54 | + color: color-mix(in srgb, currentColor 40%, #e74c3c 60%); | |
| 55 | +} | |
| @@ -361,12 +361,36 @@ toastr.options = { | ||
| 361 | 361 | // so the toasts still show up inside there. |
| 362 | 362 | fixToastrForDialogs(); |
| 363 | 363 | }, |
| 364 | - onShown: function () { | |
| 365 | - // Set tooltip to the notification message | |
| 366 | - $(this).attr('title', t`Tap to close`); | |
| 367 | - }, | |
| 368 | 364 | }; |
| 369 | 365 | |
| 366 | +// Run once during startup | |
| 367 | +toastr.subscribe(function (args) { | |
| 368 | + if (args.state !== 'visible') { | |
| 369 | + return; | |
| 370 | + } | |
| 371 | + | |
| 372 | + const $container = toastr.getContainer(args.options, false); | |
| 373 | + if (!$container || !$container.length) { | |
| 374 | + return; | |
| 375 | + } | |
| 376 | + | |
| 377 | + // toastr has already inserted the element at this point | |
| 378 | + const $toast = args.options.newestOnTop | |
| 379 | + ? $container.children().first() | |
| 380 | + : $container.children().last(); | |
| 381 | + | |
| 382 | + // Meaning of "clickable": | |
| 383 | + // Interactable unless tapToDismiss was explicitly false | |
| 384 | + const isInteractable = args.options.tapToDismiss !== false; | |
| 385 | + $toast.toggleClass('interactable', isInteractable); | |
| 386 | + if (isInteractable) { | |
| 387 | + $toast.attr('title', t`Tap to close`); | |
| 388 | + } else { | |
| 389 | + $toast.removeAttr('title'); | |
| 390 | + $toast.addClass('toast-non-interactable'); | |
| 391 | + } | |
| 392 | +}); | |
| 393 | + | |
| 370 | 394 | export const characterGroupOverlay = new BulkEditOverlay(); |
| 371 | 395 | |
| 372 | 396 | // Markdown converter |
| @@ -600,8 +624,7 @@ export let recentSwipes = 0; | ||
| 600 | 624 | export let extension_prompts = {}; |
| 601 | 625 | |
| 602 | 626 | export let main_api;// = "kobold"; |
| 603 | -/** @type {AbortController} */ | |
| 627 | +let abortController = new AbortController(); | |
| 604 | -let abortController; | |
| 605 | 628 | |
| 606 | 629 | //css |
| 607 | 630 | var css_send_form_display = $('<div id=send_form></div>').css('display'); |
| @@ -3862,7 +3885,10 @@ export async function generateRawData({ prompt = '', api = null, instructOverrid | ||
| 3862 | 3885 | |
| 3863 | 3886 | // Allow extensions to stop generation before it happens |
| 3864 | 3887 | const eventAbortController = new AbortController(); |
| 3865 | 3888 | const abortHook = () => eventAbortController.abort(new Error('Cancelled by extension'));{ |
| 3889 | + abortController.abort(new Error('Cancelled by stop event')); | |
| 3890 | + eventAbortController.abort(new Error('Cancelled by extension')); | |
| 3891 | + }; | |
| 3866 | 3892 | eventSource.on(event_types.GENERATION_STOPPED, abortHook); |
| 3867 | 3893 | |
| 3868 | 3894 | try { |
| @@ -0,0 +1,355 @@ | ||
| 1 | +import { ActionLoaderToastMode, getActiveLoaderHandles, getLoaderHandleById, hideActionLoader, showActionLoader } from './action-loader.js'; | |
| 2 | +import { t } from './i18n.js'; | |
| 3 | +import { SlashCommand } from './slash-commands/SlashCommand.js'; | |
| 4 | +import { SlashCommandNamedArgument, ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js'; | |
| 5 | +import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js'; | |
| 6 | +import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js'; | |
| 7 | +import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js'; | |
| 8 | +import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; | |
| 9 | +import { isFalseBoolean } from './utils.js'; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * Registers slash commands for the action loader module. | |
| 13 | + */ | |
| 14 | +export function registerActionLoaderSlashCommands() { | |
| 15 | + /** | |
| 16 | + * Helper to create a closure-based handler from a SlashCommandClosure argument. | |
| 17 | + * Allows all possible slash command arg types to be passed in, but only closure is accepted. | |
| 18 | + * @param {string | SlashCommandClosure | (string | SlashCommandClosure)[]} closure - The closure argument | |
| 19 | + * @param {Object} options - Configuration options | |
| 20 | + * @param {string} [options.argName='onStop'] - Name of the argument for error messages | |
| 21 | + * @param {boolean} [options.throwInvalid=true] - Whether to throw an error for invalid input | |
| 22 | + * @returns {(() => Promise<void>)|null} The handler function, or null if no closure | |
| 23 | + */ | |
| 24 | + function createClosureHandler(closure, { argName = 'onStop', throwInvalid = true } = {}) { | |
| 25 | + if (!(closure instanceof SlashCommandClosure)) { | |
| 26 | + if (closure && throwInvalid) { | |
| 27 | + // Throw error on purpose. This is defined as a syntax error. | |
| 28 | + throw new Error(t`Invalid argument for ${argName} provided. This is not a closure.`); | |
| 29 | + } | |
| 30 | + return null; | |
| 31 | + } | |
| 32 | + return async () => { | |
| 33 | + try { | |
| 34 | + const localClosure = closure.getCopy(); | |
| 35 | + localClosure.onProgress = () => { }; | |
| 36 | + await localClosure.execute(); | |
| 37 | + } catch (e) { | |
| 38 | + console.error('Error executing closure handler', e); | |
| 39 | + } | |
| 40 | + }; | |
| 41 | + } | |
| 42 | + | |
| 43 | + // Shared loader enum providers | |
| 44 | + const loaderEnumProviders = { | |
| 45 | + toastModeEnumProvider: () => [ | |
| 46 | + new SlashCommandEnumValue(ActionLoaderToastMode.NONE, 'No toast displayed', enumTypes.enum, enumIcons.disabled), | |
| 47 | + new SlashCommandEnumValue(ActionLoaderToastMode.STATIC, 'Static toast without stop button', enumTypes.enum, enumIcons.spinner), | |
| 48 | + new SlashCommandEnumValue(ActionLoaderToastMode.STOPPABLE, 'Toast with stop button (default)', enumTypes.enum, enumIcons.stop), | |
| 49 | + ], | |
| 50 | + loaderHandleProvider: () => getActiveLoaderHandles().map( | |
| 51 | + handle => new SlashCommandEnumValue(handle.id, `Active loader: ${handle.id}`, enumTypes.enum, enumIcons.spinner), | |
| 52 | + ).concat( | |
| 53 | + new SlashCommandEnumValue('Temporary loader handle', 'Any loader handle saved in variables or similar', 'enum', '📄', () => true, () => ''), | |
| 54 | + ), | |
| 55 | + }; | |
| 56 | + | |
| 57 | + // /loader-wrap command - wraps a closure with loader display | |
| 58 | + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | |
| 59 | + name: 'loader-wrap', | |
| 60 | + returns: 'result of the closure execution', | |
| 61 | + helpString: ` | |
| 62 | + <div> | |
| 63 | + Wraps a closure execution with an action loader overlay and optional toast notification. | |
| 64 | + By default, the loader blocks UI interaction until the closure completes. | |
| 65 | + Multiple loaders can be stacked - each gets its own toast, but the overlay stays single. | |
| 66 | + </div> | |
| 67 | + <div> | |
| 68 | + <strong>Toast modes:</strong> | |
| 69 | + <ul> | |
| 70 | + <li><code>stoppable</code> - Shows toast with a stop button (default)</li> | |
| 71 | + <li><code>static</code> - Shows toast without stop button</li> | |
| 72 | + <li><code>none</code> - No toast, only loader overlay</li> | |
| 73 | + </ul> | |
| 74 | + </div> | |
| 75 | + <div> | |
| 76 | + Set <code>blocking=false</code> to show only a toast without blocking the UI. | |
| 77 | + Useful for background operations like image captioning or generation. | |
| 78 | + </div> | |
| 79 | + <div> | |
| 80 | + The default stop behavior is calling <code>stopGeneration()</code>. | |
| 81 | + If the wrapped action is doing something different than generating, a custom stop closure can be provided. | |
| 82 | + </div> | |
| 83 | + <div> | |
| 84 | + <strong>Examples:</strong> | |
| 85 | + <ul> | |
| 86 | + <li><pre><code class="language-stscript">/loader-wrap message="Generating summary..." {: /gen Summary of the last message | /echo Done :}</code></pre></li> | |
| 87 | + <li><pre><code class="language-stscript">/loader-wrap blocking=false message="Captioning..." {: /caption :}</code></pre></li> | |
| 88 | + <li><pre><code class="language-stscript">/loader-wrap toast=stoppable onStop={: /echo "Stopped by user" :} {: /delay 10000 :}</code></pre></li> | |
| 89 | + </ul> | |
| 90 | + </div> | |
| 91 | + `, | |
| 92 | + namedArgumentList: [ | |
| 93 | + SlashCommandNamedArgument.fromProps({ | |
| 94 | + name: 'blocking', | |
| 95 | + description: 'Whether to show blocking overlay. Set to false for non-blocking toast-only loaders.', | |
| 96 | + typeList: [ARGUMENT_TYPE.BOOLEAN], | |
| 97 | + defaultValue: 'true', | |
| 98 | + enumList: commonEnumProviders.boolean()(), | |
| 99 | + }), | |
| 100 | + SlashCommandNamedArgument.fromProps({ | |
| 101 | + name: 'toast', | |
| 102 | + description: 'Toast display mode: stoppable (with stop button), static (no stop button), or none', | |
| 103 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 104 | + defaultValue: ActionLoaderToastMode.STOPPABLE, | |
| 105 | + enumList: loaderEnumProviders.toastModeEnumProvider(), | |
| 106 | + }), | |
| 107 | + SlashCommandNamedArgument.fromProps({ | |
| 108 | + name: 'message', | |
| 109 | + description: 'Message to display in the toast notification', | |
| 110 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 111 | + defaultValue: 'Generating...', | |
| 112 | + }), | |
| 113 | + SlashCommandNamedArgument.fromProps({ | |
| 114 | + name: 'title', | |
| 115 | + description: 'Optional title for the toast notification', | |
| 116 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 117 | + }), | |
| 118 | + SlashCommandNamedArgument.fromProps({ | |
| 119 | + name: 'stopTooltip', | |
| 120 | + description: 'Tooltip text for the stop button (only used when toast=stoppable)', | |
| 121 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 122 | + defaultValue: 'Stop', | |
| 123 | + }), | |
| 124 | + SlashCommandNamedArgument.fromProps({ | |
| 125 | + name: 'onStop', | |
| 126 | + description: 'Closure to execute when the stop button is clicked. If not provided, uses default stop behavior.', | |
| 127 | + typeList: [ARGUMENT_TYPE.CLOSURE], | |
| 128 | + }), | |
| 129 | + ], | |
| 130 | + unnamedArgumentList: [ | |
| 131 | + SlashCommandArgument.fromProps({ | |
| 132 | + description: 'Closure to execute while the loader is displayed', | |
| 133 | + typeList: [ARGUMENT_TYPE.CLOSURE], | |
| 134 | + isRequired: true, | |
| 135 | + }), | |
| 136 | + ], | |
| 137 | + callback: async (args, value) => { | |
| 138 | + if (!(value instanceof SlashCommandClosure)) { | |
| 139 | + // Throw error on purpose. This is defined as a syntax error. | |
| 140 | + throw new Error(t`Invalid argument for unnamed argument provided. This is not a closure.`); | |
| 141 | + } | |
| 142 | + | |
| 143 | + const blocking = !isFalseBoolean(String(args.blocking)); | |
| 144 | + const toastMode = Object.values(ActionLoaderToastMode).includes(String(args.toast)) | |
| 145 | + ? String(args.toast) | |
| 146 | + : ActionLoaderToastMode.STOPPABLE; | |
| 147 | + const message = String(args.message ?? t`Generating...`); | |
| 148 | + const title = args.title ? String(args.title) : ''; | |
| 149 | + const stopTooltip = String(args.stopTooltip ?? t`Stop`); | |
| 150 | + | |
| 151 | + const loader = showActionLoader({ | |
| 152 | + blocking, | |
| 153 | + toastMode, | |
| 154 | + message, | |
| 155 | + title, | |
| 156 | + stopTooltip, | |
| 157 | + onStop: createClosureHandler(args.onStop), | |
| 158 | + }); | |
| 159 | + | |
| 160 | + try { | |
| 161 | + const closureCopy = value.getCopy(); | |
| 162 | + const result = await closureCopy.execute(); | |
| 163 | + return result.pipe; | |
| 164 | + } finally { | |
| 165 | + await loader.hide(); | |
| 166 | + } | |
| 167 | + }, | |
| 168 | + })); | |
| 169 | + | |
| 170 | + // /loader-show command - manually show a loader, returns handle ID | |
| 171 | + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | |
| 172 | + name: 'loader-show', | |
| 173 | + returns: 'loader handle ID (use with /loader-hide)', | |
| 174 | + helpString: ` | |
| 175 | + <div> | |
| 176 | + Manually shows an action loader. Returns a handle ID that can be used with <code>/loader-hide</code> to hide it. | |
| 177 | + Use this for fine-grained control when you need to show/hide the loader at specific points. | |
| 178 | + Multiple loaders can be stacked - each gets its own toast, but the overlay stays single. | |
| 179 | + </div> | |
| 180 | + <div> | |
| 181 | + <strong>Toast modes:</strong> | |
| 182 | + <ul> | |
| 183 | + <li><code>stoppable</code> - Shows toast with a stop button (default)</li> | |
| 184 | + <li><code>static</code> - Shows toast without stop button</li> | |
| 185 | + <li><code>none</code> - No toast, only loader overlay</li> | |
| 186 | + </ul> | |
| 187 | + </div> | |
| 188 | + <div> | |
| 189 | + Set <code>blocking=false</code> to show only a toast without blocking the UI. | |
| 190 | + Useful for background operations like image captioning or generation. | |
| 191 | + </div> | |
| 192 | + <div> | |
| 193 | + The default stop behavior is calling <code>stopGeneration()</code>. | |
| 194 | + If the wrapped action is doing something different than generating, a custom stop closure can be provided. | |
| 195 | + </div> | |
| 196 | + <div> | |
| 197 | + <strong>Example:</strong> | |
| 198 | + <pre> | |
| 199 | + <code class="language-stscript"> | |
| 200 | +/loader-show message="Loading..." | | |
| 201 | +/setvar key=myLoader | | |
| 202 | +/some-operation | | |
| 203 | +/loader-hide handle={{getvar::myLoader}} | |
| 204 | + </code> | |
| 205 | + </pre> | |
| 206 | + </div> | |
| 207 | + `, | |
| 208 | + namedArgumentList: [ | |
| 209 | + SlashCommandNamedArgument.fromProps({ | |
| 210 | + name: 'blocking', | |
| 211 | + description: 'Whether to show blocking overlay. Set to false for non-blocking toast-only loaders.', | |
| 212 | + typeList: [ARGUMENT_TYPE.BOOLEAN], | |
| 213 | + defaultValue: 'true', | |
| 214 | + enumList: commonEnumProviders.boolean()(), | |
| 215 | + }), | |
| 216 | + SlashCommandNamedArgument.fromProps({ | |
| 217 | + name: 'toast', | |
| 218 | + description: 'Toast display mode: stoppable (with stop button), static (no stop button), or none', | |
| 219 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 220 | + defaultValue: ActionLoaderToastMode.STOPPABLE, | |
| 221 | + enumList: loaderEnumProviders.toastModeEnumProvider(), | |
| 222 | + }), | |
| 223 | + SlashCommandNamedArgument.fromProps({ | |
| 224 | + name: 'message', | |
| 225 | + description: 'Message to display in the toast notification', | |
| 226 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 227 | + defaultValue: 'Generating...', | |
| 228 | + }), | |
| 229 | + SlashCommandNamedArgument.fromProps({ | |
| 230 | + name: 'title', | |
| 231 | + description: 'Optional title for the toast notification', | |
| 232 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 233 | + }), | |
| 234 | + SlashCommandNamedArgument.fromProps({ | |
| 235 | + name: 'stopTooltip', | |
| 236 | + description: 'Tooltip text for the stop button (only used when toast=stoppable)', | |
| 237 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 238 | + defaultValue: 'Stop', | |
| 239 | + }), | |
| 240 | + SlashCommandNamedArgument.fromProps({ | |
| 241 | + name: 'onStop', | |
| 242 | + description: 'Closure to execute when the stop button is clicked', | |
| 243 | + typeList: [ARGUMENT_TYPE.CLOSURE], | |
| 244 | + }), | |
| 245 | + SlashCommandNamedArgument.fromProps({ | |
| 246 | + name: 'onHide', | |
| 247 | + description: 'Closure to execute when the loader is hidden (not stopped)', | |
| 248 | + typeList: [ARGUMENT_TYPE.CLOSURE], | |
| 249 | + }), | |
| 250 | + ], | |
| 251 | + unnamedArgumentList: [], | |
| 252 | + callback: async (args) => { | |
| 253 | + const blocking = !isFalseBoolean(String(args.blocking)); | |
| 254 | + const toastMode = Object.values(ActionLoaderToastMode).includes(String(args.toast)) | |
| 255 | + ? String(args.toast) | |
| 256 | + : ActionLoaderToastMode.STOPPABLE; | |
| 257 | + const message = String(args.message ?? t`Generating...`); | |
| 258 | + const title = args.title ? String(args.title) : ''; | |
| 259 | + const stopTooltip = String(args.stopTooltip ?? t`Stop`); | |
| 260 | + | |
| 261 | + const handle = showActionLoader({ | |
| 262 | + blocking, | |
| 263 | + toastMode, | |
| 264 | + message, | |
| 265 | + title, | |
| 266 | + stopTooltip, | |
| 267 | + onStop: createClosureHandler(args.onStop), | |
| 268 | + onHide: createClosureHandler(args.onHide, { argName: 'onHide' }), | |
| 269 | + }); | |
| 270 | + | |
| 271 | + return handle.id; | |
| 272 | + }, | |
| 273 | + })); | |
| 274 | + | |
| 275 | + // /loader-hide command - manually hide a loader by handle ID | |
| 276 | + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | |
| 277 | + name: 'loader-hide', | |
| 278 | + returns: 'true if an active loader was hidden, otherwise false', | |
| 279 | + helpString: ` | |
| 280 | + <div> | |
| 281 | + Hides an action loader that was shown with <code>/loader-show</code>. | |
| 282 | + If no handle is provided, hides <strong>all</strong> active loaders. | |
| 283 | + </div> | |
| 284 | + <div> | |
| 285 | + <strong>Example:</strong> | |
| 286 | + <pre><code class="language-stscript">/loader-hide handle={{getvar::myLoader}}</code></pre> | |
| 287 | + </div> | |
| 288 | + `, | |
| 289 | + namedArgumentList: [ | |
| 290 | + SlashCommandNamedArgument.fromProps({ | |
| 291 | + name: 'handle', | |
| 292 | + description: 'Loader handle ID returned by /loader-show. If not provided, hides all active loaders.', | |
| 293 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 294 | + enumProvider: loaderEnumProviders.loaderHandleProvider, | |
| 295 | + }), | |
| 296 | + ], | |
| 297 | + callback: async (args) => { | |
| 298 | + const handleId = args.handle ? String(args.handle) : null; | |
| 299 | + | |
| 300 | + if (handleId) { | |
| 301 | + const handle = getLoaderHandleById(handleId); | |
| 302 | + if (handle && handle.isActive) { | |
| 303 | + await handle.hide(); | |
| 304 | + return 'true'; | |
| 305 | + } | |
| 306 | + return 'false'; | |
| 307 | + } | |
| 308 | + | |
| 309 | + // No handle provided - hide all active loaders | |
| 310 | + const result = await hideActionLoader(); | |
| 311 | + return result ? 'true' : 'false'; | |
| 312 | + }, | |
| 313 | + })); | |
| 314 | + | |
| 315 | + // /loader-stop command - trigger the stop action on a loader | |
| 316 | + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | |
| 317 | + name: 'loader-stop', | |
| 318 | + returns: 'true if an active loader was stopped, otherwise false', | |
| 319 | + helpString: ` | |
| 320 | + <div> | |
| 321 | + Triggers the stop action on a specific action loader, as if the user clicked the stop button. | |
| 322 | + Unlike <code>/loader-hide</code>, this command requires a handle - you must specify which loader to stop. | |
| 323 | + </div> | |
| 324 | + <div> | |
| 325 | + <strong>Example:</strong> | |
| 326 | + <pre><code class="language-stscript">/loader-stop handle={{getvar::myLoader}}</code></pre> | |
| 327 | + </div> | |
| 328 | + `, | |
| 329 | + namedArgumentList: [ | |
| 330 | + SlashCommandNamedArgument.fromProps({ | |
| 331 | + name: 'handle', | |
| 332 | + description: 'Loader handle ID returned by /loader-show.', | |
| 333 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 334 | + isRequired: true, | |
| 335 | + enumProvider: loaderEnumProviders.loaderHandleProvider, | |
| 336 | + }), | |
| 337 | + ], | |
| 338 | + callback: async (args) => { | |
| 339 | + const handleId = args.handle ? String(args.handle) : null; | |
| 340 | + | |
| 341 | + if (!handleId) { | |
| 342 | + toastr.warning(t`No handle provided. You must specify which loader to stop.`); | |
| 343 | + return 'false'; | |
| 344 | + } | |
| 345 | + | |
| 346 | + const handle = getLoaderHandleById(handleId); | |
| 347 | + if (handle && handle.isActive) { | |
| 348 | + await handle.stop(); | |
| 349 | + return 'true'; | |
| 350 | + } | |
| 351 | + | |
| 352 | + return 'false'; | |
| 353 | + }, | |
| 354 | + })); | |
| 355 | +} | |
| @@ -0,0 +1,406 @@ | ||
| 1 | +/** | |
| 2 | + * Action loader utility - shows loader overlay with stoppable toast notification. | |
| 3 | + * Designed to be flexible and reusable for various long-running operations. | |
| 4 | + * Supports stacking multiple loaders - overlay stays single, but toasts can stack. | |
| 5 | + * | |
| 6 | + * With default arguments, will function as a generation loader / wrapper. | |
| 7 | + * | |
| 8 | + * @module action-loader | |
| 9 | + */ | |
| 10 | + | |
| 11 | +import { t } from './i18n.js'; | |
| 12 | +import { stopGeneration } from '../script.js'; | |
| 13 | +import { showLoader, hideLoader, isLoaderDisplayed } from './loader.js'; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * Enum representing the toast display mode for the action loader. | |
| 17 | + * @readonly | |
| 18 | + * @enum {string} | |
| 19 | + */ | |
| 20 | +export const ActionLoaderToastMode = { | |
| 21 | + /** No toast is displayed */ | |
| 22 | + NONE: 'none', | |
| 23 | + /** Toast is displayed without stop button (non-interactable) */ | |
| 24 | + STATIC: 'static', | |
| 25 | + /** Toast is displayed with stop button (default) */ | |
| 26 | + STOPPABLE: 'stoppable', | |
| 27 | +}; | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * @typedef {object} ActionLoaderOptions | |
| 31 | + * @property {boolean} [blocking=true] - Whether to show the blocking overlay. Set to false for non-blocking toast-only loaders. | |
| 32 | + * @property {ActionLoaderToastMode} [toastMode='stoppable'] - Toast display mode | |
| 33 | + * @property {string} [message='Generating...'] - The message to display in the toast | |
| 34 | + * @property {string} [title] - Optional title for the toast notification | |
| 35 | + * @property {string} [stopTooltip='Stop'] - Tooltip text for the stop button | |
| 36 | + * @property {(() => void)|null} [onStop=null] - Custom stop handler. If null, calls `stopGeneration()` | |
| 37 | + * @property {(() => void)|null} [onHide=null] - Custom hide handler. Called when the loader is hidden (not stopped). | |
| 38 | + */ | |
| 39 | + | |
| 40 | +/** Counter for generating unique loader IDs */ | |
| 41 | +let loaderIdCounter = 0; | |
| 42 | + | |
| 43 | +/** @type {Set<ActionLoaderHandle>} Set of all active loader handles */ | |
| 44 | +const activeHandles = new Set(); | |
| 45 | + | |
| 46 | +/** | |
| 47 | + * Generates a unique loader ID. | |
| 48 | + * @returns {string} Unique loader ID | |
| 49 | + */ | |
| 50 | +function generateLoaderId() { | |
| 51 | + return `loader_${++loaderIdCounter}`; | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** | |
| 55 | + * Checks if there are any active blocking loaders. | |
| 56 | + * @returns {boolean} True if at least one blocking loader is active | |
| 57 | + */ | |
| 58 | +function hasBlockingLoaders() { | |
| 59 | + for (const handle of activeHandles) { | |
| 60 | + if (handle.isBlocking && handle.isActive) { | |
| 61 | + return true; | |
| 62 | + } | |
| 63 | + } | |
| 64 | + return false; | |
| 65 | +} | |
| 66 | + | |
| 67 | +/** | |
| 68 | + * Class representing an action loader handle. | |
| 69 | + * Manages its own toast, stop handler, and lifecycle. | |
| 70 | + */ | |
| 71 | +export class ActionLoaderHandle { | |
| 72 | + /** @type {string} Unique identifier for this handle */ | |
| 73 | + id; | |
| 74 | + | |
| 75 | + /** @type {JQuery<HTMLElement>|null} The toast element for this loader */ | |
| 76 | + #toast = null; | |
| 77 | + | |
| 78 | + /** @type {(() => void)|null} Custom stop handler */ | |
| 79 | + #onStop = null; | |
| 80 | + | |
| 81 | + /** @type {(() => void)|null} Custom hide handler */ | |
| 82 | + #onHide = null; | |
| 83 | + | |
| 84 | + /** @type {boolean} Whether this loader blocks the UI with an overlay */ | |
| 85 | + #blocking = true; | |
| 86 | + | |
| 87 | + /** @type {boolean} Whether this handle has been disposed */ | |
| 88 | + #disposed = false; | |
| 89 | + | |
| 90 | + /** | |
| 91 | + * Creates a new ActionLoaderHandle. | |
| 92 | + * @param {object} options - Configuration options | |
| 93 | + * @param {boolean} [options.blocking=true] - Whether to show blocking overlay | |
| 94 | + * @param {ActionLoaderToastMode} [options.toastMode] - Toast display mode | |
| 95 | + * @param {string} [options.message='Generating...'] - Message to display in the toast | |
| 96 | + * @param {string} [options.title] - Title for the toast notification | |
| 97 | + * @param {string} [options.stopTooltip='Stop'] - Tooltip for the stop button | |
| 98 | + * @param {(() => void)|null} [options.onStop] - Custom stop handler | |
| 99 | + * @param {(() => void)|null} [options.onHide] - Custom hide handler | |
| 100 | + */ | |
| 101 | + constructor({ | |
| 102 | + blocking = true, | |
| 103 | + toastMode = ActionLoaderToastMode.STOPPABLE, | |
| 104 | + message = t`Generating...`, | |
| 105 | + title = '', | |
| 106 | + stopTooltip = t`Stop`, | |
| 107 | + onStop = null, | |
| 108 | + onHide = null, | |
| 109 | + } = {}) { | |
| 110 | + this.id = generateLoaderId(); | |
| 111 | + this.#blocking = blocking; | |
| 112 | + this.#onStop = onStop; | |
| 113 | + this.#onHide = onHide; | |
| 114 | + | |
| 115 | + // Warn if non-blocking loader has no toast - it won't be visible to the user | |
| 116 | + if (!blocking && toastMode === ActionLoaderToastMode.NONE) { | |
| 117 | + console.warn('[ActionLoader] Non-blocking loader created without a toast. This loader will not be visible to the user.'); | |
| 118 | + } | |
| 119 | + | |
| 120 | + // Show the blocking loader overlay if this is the first blocking handle | |
| 121 | + if (blocking && !hasBlockingLoaders() && !isLoaderDisplayed()) { | |
| 122 | + showLoader(); | |
| 123 | + } | |
| 124 | + | |
| 125 | + // Register this handle | |
| 126 | + activeHandles.add(this); | |
| 127 | + | |
| 128 | + // Create toast if needed | |
| 129 | + if (toastMode !== ActionLoaderToastMode.NONE) { | |
| 130 | + this.#createToast(message, title, toastMode, stopTooltip); | |
| 131 | + } | |
| 132 | + } | |
| 133 | + | |
| 134 | + /** | |
| 135 | + * Creates the toast element for this loader. | |
| 136 | + * @param {string} message - Message to display | |
| 137 | + * @param {string} title - Title for the toast | |
| 138 | + * @param {ActionLoaderToastMode} toastMode - Toast mode | |
| 139 | + * @param {string} stopTooltip - Tooltip for stop button | |
| 140 | + */ | |
| 141 | + #createToast(message, title, toastMode, stopTooltip) { | |
| 142 | + const toastContent = document.createElement('div'); | |
| 143 | + toastContent.className = 'action-loader-toast'; | |
| 144 | + | |
| 145 | + const messageSpan = document.createElement('span'); | |
| 146 | + messageSpan.className = 'action-loader-message'; | |
| 147 | + messageSpan.textContent = message; | |
| 148 | + toastContent.appendChild(messageSpan); | |
| 149 | + | |
| 150 | + // Add stop button if mode is STOPPABLE | |
| 151 | + if (toastMode === ActionLoaderToastMode.STOPPABLE) { | |
| 152 | + const stopButton = document.createElement('i'); | |
| 153 | + stopButton.className = 'fa-solid fa-stop-circle action-loader-stop interactable'; | |
| 154 | + stopButton.title = stopTooltip; | |
| 155 | + stopButton.addEventListener('click', (e) => { | |
| 156 | + e.preventDefault(); | |
| 157 | + e.stopPropagation(); | |
| 158 | + this.stop(); | |
| 159 | + }); | |
| 160 | + toastContent.appendChild(stopButton); | |
| 161 | + } | |
| 162 | + | |
| 163 | + // Show toast with no timeout (sticky) | |
| 164 | + this.#toast = toastr.info($(toastContent), title, { | |
| 165 | + timeOut: 0, | |
| 166 | + extendedTimeOut: 0, | |
| 167 | + tapToDismiss: false, | |
| 168 | + escapeHtml: false, | |
| 169 | + }); | |
| 170 | + } | |
| 171 | + | |
| 172 | + /** | |
| 173 | + * Clears the toast element for this loader. | |
| 174 | + */ | |
| 175 | + #clearToast() { | |
| 176 | + if (this.#toast) { | |
| 177 | + toastr.clear(this.#toast, { force: true }); // Need to force as the toast might have focus/hover | |
| 178 | + this.#toast = null; | |
| 179 | + } | |
| 180 | + } | |
| 181 | + | |
| 182 | + /** | |
| 183 | + * Disposes this handle, removing it from active handles and hiding overlay if last. | |
| 184 | + */ | |
| 185 | + async #dispose() { | |
| 186 | + if (this.#disposed) return; | |
| 187 | + this.#disposed = true; | |
| 188 | + | |
| 189 | + this.#clearToast(); | |
| 190 | + activeHandles.delete(this); | |
| 191 | + | |
| 192 | + // Hide the overlay if this was the last blocking handle | |
| 193 | + if (this.#blocking && !hasBlockingLoaders()) { | |
| 194 | + await hideLoader(); | |
| 195 | + } | |
| 196 | + } | |
| 197 | + | |
| 198 | + /** | |
| 199 | + * Whether this handle is still active (not disposed). | |
| 200 | + * @returns {boolean} | |
| 201 | + */ | |
| 202 | + get isActive() { | |
| 203 | + return !this.#disposed; | |
| 204 | + } | |
| 205 | + | |
| 206 | + /** | |
| 207 | + * Whether this loader blocks the UI with an overlay. | |
| 208 | + * @returns {boolean} | |
| 209 | + */ | |
| 210 | + get isBlocking() { | |
| 211 | + return this.#blocking; | |
| 212 | + } | |
| 213 | + | |
| 214 | + /** | |
| 215 | + * Triggers the stop action on this loader. | |
| 216 | + * Calls the custom onStop handler if provided, otherwise calls stopGeneration(). | |
| 217 | + * Then hides this loader. | |
| 218 | + */ | |
| 219 | + async stop() { | |
| 220 | + if (this.#disposed) return; | |
| 221 | + | |
| 222 | + // Call custom stop handler or default | |
| 223 | + if (this.#onStop) { | |
| 224 | + try { | |
| 225 | + await this.#onStop(); | |
| 226 | + } catch (e) { | |
| 227 | + console.error('Error executing onStop handler', e); | |
| 228 | + } | |
| 229 | + } else { | |
| 230 | + stopGeneration(); | |
| 231 | + } | |
| 232 | + | |
| 233 | + // Dispose without calling onHide (stop is different from hide) | |
| 234 | + await this.#dispose(); | |
| 235 | + } | |
| 236 | + | |
| 237 | + /** | |
| 238 | + * Hides this loader and clears its toast. | |
| 239 | + * Calls the custom onHide handler if provided. | |
| 240 | + */ | |
| 241 | + async hide() { | |
| 242 | + if (this.#disposed) return; | |
| 243 | + | |
| 244 | + // Call custom hide handler if provided | |
| 245 | + if (this.#onHide) { | |
| 246 | + try { | |
| 247 | + await this.#onHide(); | |
| 248 | + } catch (e) { | |
| 249 | + console.error('Error executing onHide handler', e); | |
| 250 | + } | |
| 251 | + } | |
| 252 | + | |
| 253 | + await this.#dispose(); | |
| 254 | + } | |
| 255 | +} | |
| 256 | + | |
| 257 | +/** | |
| 258 | + * Action loader utility API. | |
| 259 | + * Provides a convenient interface for showing and managing loading indicators. | |
| 260 | + * | |
| 261 | + * Read the functions documentation for more details. | |
| 262 | + * | |
| 263 | + * @example | |
| 264 | + * // Basic usage | |
| 265 | + * const handle = loader.show({ message: 'Loading...' }); | |
| 266 | + * await someOperation(); | |
| 267 | + * handle.hide(); | |
| 268 | + * | |
| 269 | + * @example | |
| 270 | + * // Non-blocking background task | |
| 271 | + * const handle = loader.show({ blocking: false, message: 'Processing...' }); | |
| 272 | + * | |
| 273 | + * @example | |
| 274 | + * // Hide all active loaders | |
| 275 | + * loader.hide(); | |
| 276 | + */ | |
| 277 | +export const loader = { | |
| 278 | + /** | |
| 279 | + * Shows an action loader with optional toast notification. | |
| 280 | + * Returns a handle to control the loader. | |
| 281 | + * @type {typeof showActionLoader} | |
| 282 | + */ | |
| 283 | + show: showActionLoader, | |
| 284 | + | |
| 285 | + /** | |
| 286 | + * Hides a specific loader by handle, or all loaders if no handle provided. | |
| 287 | + * @type {typeof hideActionLoader} | |
| 288 | + */ | |
| 289 | + hide: hideActionLoader, | |
| 290 | + | |
| 291 | + /** | |
| 292 | + * Gets all currently active loader handles. | |
| 293 | + * @type {typeof getActiveLoaderHandles} | |
| 294 | + */ | |
| 295 | + active: getActiveLoaderHandles, | |
| 296 | + | |
| 297 | + /** | |
| 298 | + * Gets a loader handle by its ID. | |
| 299 | + * @type {typeof getLoaderHandleById} | |
| 300 | + */ | |
| 301 | + get: getLoaderHandleById, | |
| 302 | + | |
| 303 | + /** | |
| 304 | + * Toast display mode constants. | |
| 305 | + * @type {typeof ActionLoaderToastMode} | |
| 306 | + */ | |
| 307 | + ToastMode: ActionLoaderToastMode, | |
| 308 | + | |
| 309 | + /** | |
| 310 | + * The ActionLoaderHandle class. | |
| 311 | + * @type {typeof ActionLoaderHandle} | |
| 312 | + */ | |
| 313 | + Handle: ActionLoaderHandle, | |
| 314 | +}; | |
| 315 | + | |
| 316 | +/** | |
| 317 | + * Shows an action loader with an optional stoppable toast notification. | |
| 318 | + * Multiple loaders can be stacked - the overlay stays single, but each gets its own toast. | |
| 319 | + * When the last loader is hidden, the overlay is removed. | |
| 320 | + * | |
| 321 | + * With default arguments, will function as a generation loader / wrapper. | |
| 322 | + * | |
| 323 | + * @param {ActionLoaderOptions} [options={}] - Configuration options | |
| 324 | + * @returns {ActionLoaderHandle} Handle to control the loader | |
| 325 | + * | |
| 326 | + * @example | |
| 327 | + * // Basic usage | |
| 328 | + * const loader = showActionLoader({ message: 'Generating title...' }); | |
| 329 | + * try { | |
| 330 | + * const result = await generateRaw({ prompt }); | |
| 331 | + * // process result | |
| 332 | + * } finally { | |
| 333 | + * await loader.hide(); | |
| 334 | + * } | |
| 335 | + * | |
| 336 | + * @example | |
| 337 | + * // With custom stop and hide handlers | |
| 338 | + * const loader = showActionLoader({ | |
| 339 | + * message: 'Downloading...', | |
| 340 | + * stopTooltip: 'Cancel download', | |
| 341 | + * onStop: () => myCustomCancelFunction(), | |
| 342 | + * onHide: () => console.log('Loader hidden'), | |
| 343 | + * }); | |
| 344 | + * | |
| 345 | + * @example | |
| 346 | + * // Stacking multiple loaders | |
| 347 | + * const loader1 = showActionLoader({ message: 'Task 1...' }); | |
| 348 | + * const loader2 = showActionLoader({ message: 'Task 2...' }); | |
| 349 | + * await loader1.hide(); // Overlay stays, loader2 still active | |
| 350 | + * await loader2.hide(); // Now overlay hides | |
| 351 | + * | |
| 352 | + * @example | |
| 353 | + * // Non-blocking loader (toast only, no overlay) | |
| 354 | + * const loader = showActionLoader({ | |
| 355 | + * message: 'Captioning image...', | |
| 356 | + * blocking: false, | |
| 357 | + * onStop: () => abortCaptioning(), | |
| 358 | + * }); | |
| 359 | + */ | |
| 360 | +export function showActionLoader(options = {}) { | |
| 361 | + return new ActionLoaderHandle(options); | |
| 362 | +} | |
| 363 | + | |
| 364 | +/** | |
| 365 | + * Hides a specific action loader by handle, or all active loaders if no handle provided. | |
| 366 | + * @param {ActionLoaderHandle|null} [handle=null] - Specific handle to hide, or undefined to hide all | |
| 367 | + * @returns {Promise<boolean>} Whether any loader was hidden | |
| 368 | + */ | |
| 369 | +export async function hideActionLoader(handle = null) { | |
| 370 | + if (handle instanceof ActionLoaderHandle) { | |
| 371 | + if (handle.isActive) { | |
| 372 | + await handle.hide(); | |
| 373 | + return true; | |
| 374 | + } | |
| 375 | + return false; | |
| 376 | + } | |
| 377 | + | |
| 378 | + // No handle provided - hide all active loaders | |
| 379 | + const handles = getActiveLoaderHandles(); | |
| 380 | + for (const h of handles) { | |
| 381 | + await h.hide(); | |
| 382 | + } | |
| 383 | + return handles.length > 0; | |
| 384 | +} | |
| 385 | + | |
| 386 | +/** | |
| 387 | + * Gets all currently active loader handles. | |
| 388 | + * @returns {ActionLoaderHandle[]} Array of active handles | |
| 389 | + */ | |
| 390 | +export function getActiveLoaderHandles() { | |
| 391 | + return Array.from(activeHandles); | |
| 392 | +} | |
| 393 | + | |
| 394 | +/** | |
| 395 | + * Gets a loader handle by its ID. | |
| 396 | + * @param {string} id - The handle ID | |
| 397 | + * @returns {ActionLoaderHandle|undefined} The handle, or undefined if not found | |
| 398 | + */ | |
| 399 | +export function getLoaderHandleById(id) { | |
| 400 | + for (const handle of activeHandles) { | |
| 401 | + if (handle.id === id) { | |
| 402 | + return handle; | |
| 403 | + } | |
| 404 | + } | |
| 405 | + return undefined; | |
| 406 | +} | |
| @@ -5,6 +5,10 @@ let loaderPopup; | ||
| 5 | 5 | |
| 6 | 6 | let preloaderYoinked = false; |
| 7 | 7 | |
| 8 | +export function isLoaderDisplayed() { | |
| 9 | + return !!loaderPopup; | |
| 10 | +} | |
| 11 | + | |
| 8 | 12 | export function showLoader() { |
| 9 | 13 | // Two loaders don't make sense. Don't await, we can overlay the old loader while it closes |
| 10 | 14 | if (loaderPopup) loaderPopup.complete(POPUP_RESULT.CANCELLED); |
| @@ -77,6 +77,7 @@ import { SERVER_INPUTS, textgen_types, textgenerationwebui_settings } from './te | ||
| 77 | 77 | import { decodeTextTokens, getAvailableTokenizers, getFriendlyTokenizerName, getTextTokens, getTokenCountAsync, selectTokenizer } from './tokenizers.js'; |
| 78 | 78 | import { debounce, delay, equalsIgnoreCaseAndAccents, findChar, getCharIndex, isFalseBoolean, isTrueBoolean, onlyUnique, regexFromString, showFontAwesomePicker, stringToRange, trimToEndSentence, trimToStartSentence, waitUntilCondition } from './utils.js'; |
| 79 | 79 | import { registerVariableCommands, resolveVariable } from './variables.js'; |
| 80 | +import { registerActionLoaderSlashCommands } from './action-loader-slashcommands.js'; | |
| 80 | 81 | import { background_settings } from './backgrounds.js'; |
| 81 | 82 | import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js'; |
| 82 | 83 | import { SlashCommandClosureResult } from './slash-commands/SlashCommandClosureResult.js'; |
| @@ -3714,6 +3715,7 @@ export function initDefaultSlashCommands() { | ||
| 3714 | 3715 | })); |
| 3715 | 3716 | |
| 3716 | 3717 | registerVariableCommands(); |
| 3718 | + registerActionLoaderSlashCommands(); | |
| 3717 | 3719 | } |
| 3718 | 3720 | |
| 3719 | 3721 | const NARRATOR_NAME_KEY = 'narrator_name'; |
| @@ -42,6 +42,8 @@ export const enumIcons = { | ||
| 42 | 42 | image: '🖼️', |
| 43 | 43 | video: '🎥', |
| 44 | 44 | key: '🔑', |
| 45 | + spinner: '♻️', | |
| 46 | + stop: '🛑', | |
| 45 | 47 | |
| 46 | 48 | true: '✔️', |
| 47 | 49 | false: '❌', |
| @@ -81,6 +81,7 @@ import { | ||
| 81 | 81 | import { groups, openGroupChat, selected_group, unshallowGroupMembers } from './group-chats.js'; |
| 82 | 82 | import { addLocaleData, getCurrentLocale, t, translate } from './i18n.js'; |
| 83 | 83 | import { hideLoader, showLoader } from './loader.js'; |
| 84 | +import { loader } from './action-loader.js'; | |
| 84 | 85 | import { MacrosParser } from './macros.js'; |
| 85 | 86 | import { getChatCompletionModel, oai_settings } from './openai.js'; |
| 86 | 87 | import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js'; |
| @@ -235,6 +236,7 @@ export function getContext() { | ||
| 235 | 236 | scrollChatToBottom, |
| 236 | 237 | scrollOnMediaLoad, |
| 237 | 238 | macros, |
| 239 | + loader, | |
| 238 | 240 | swipe: { |
| 239 | 241 | left: swipe_left, |
| 240 | 242 | right: swipe_right, |
| @@ -3973,7 +3973,7 @@ grammarly-extension { | ||
| 3973 | 3973 | |
| 3974 | 3974 | |
| 3975 | 3975 | /* Override toastr default styles */ |
| 3976 | 3976 | body> #toast-container { |
| 3977 | 3977 | margin-top: var(--topBarBlockSize); |
| 3978 | 3978 | } |
| 3979 | 3979 | |
| @@ -3986,6 +3986,10 @@ body #toast-container>div { | ||
| 3986 | 3986 | width: 300px; |
| 3987 | 3987 | } |
| 3988 | 3988 | |
| 3989 | +body #toast-container .toast.toast-non-interactable { | |
| 3990 | + cursor: default; | |
| 3991 | +} | |
| 3992 | + | |
| 3989 | 3993 | body #toast-container .toast-success { |
| 3990 | 3994 | background-color: #5d9e5d; |
| 3991 | 3995 | } |