| 1 | import { ActionLoaderToastMode, getActiveLoaderHandles, getLoaderHandleById, loader } 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 | import { DOMPurify } from '../lib.js'; |
| 11 | |
| 12 | /** |
| 13 | * Registers slash commands for the action loader module. |
| 14 | */ |
| 15 | export function registerActionLoaderSlashCommands() { |
| 16 | /** |
| 17 | * Helper to create a closure-based handler from a SlashCommandClosure argument. |
| 18 | * Allows all possible slash command arg types to be passed in, but only closure is accepted. |
| 19 | * @param {string | SlashCommandClosure | (string | SlashCommandClosure)[]} closure - The closure argument |
| 20 | * @param {Object} options - Configuration options |
| 21 | * @param {string} [options.argName='onStop'] - Name of the argument for error messages |
| 22 | * @param {boolean} [options.throwInvalid=true] - Whether to throw an error for invalid input |
| 23 | * @returns {(() => Promise<void>)|null} The handler function, or null if no closure |
| 24 | */ |
| 25 | function createClosureHandler(closure, { argName = 'onStop', throwInvalid = true } = {}) { |
| 26 | if (!(closure instanceof SlashCommandClosure)) { |
| 27 | if (closure && throwInvalid) { |
| 28 | // Throw error on purpose. This is defined as a syntax error. |
| 29 | throw new Error(t`Invalid argument for ${argName} provided. This is not a closure.`); |
| 30 | } |
| 31 | return null; |
| 32 | } |
| 33 | return async () => { |
| 34 | try { |
| 35 | const localClosure = closure.getCopy(); |
| 36 | localClosure.onProgress = () => { }; |
| 37 | await localClosure.execute(); |
| 38 | } catch (e) { |
| 39 | console.error('Error executing closure handler', e); |
| 40 | } |
| 41 | }; |
| 42 | } |
| 43 | |
| 44 | // Shared loader enum providers |
| 45 | const loaderEnumProviders = { |
| 46 | toastModeEnumProvider: () => [ |
| 47 | new SlashCommandEnumValue(ActionLoaderToastMode.NONE, 'No toast displayed', enumTypes.enum, enumIcons.disabled), |
| 48 | new SlashCommandEnumValue(ActionLoaderToastMode.STATIC, 'Static toast without stop button', enumTypes.enum, enumIcons.spinner), |
| 49 | new SlashCommandEnumValue(ActionLoaderToastMode.STOPPABLE, 'Toast with stop button (default)', enumTypes.enum, enumIcons.stop), |
| 50 | ], |
| 51 | loaderHandleProvider: () => getActiveLoaderHandles().map( |
| 52 | handle => new SlashCommandEnumValue(handle.id, `Active loader: ${handle.id}`, enumTypes.enum, enumIcons.spinner), |
| 53 | ).concat( |
| 54 | new SlashCommandEnumValue('Temporary loader handle', 'Any loader handle saved in variables or similar', 'enum', '📄', () => true, () => ''), |
| 55 | ), |
| 56 | }; |
| 57 | |
| 58 | // /loader-wrap command - wraps a closure with loader display |
| 59 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 60 | name: 'loader-wrap', |
| 61 | returns: 'result of the closure execution', |
| 62 | helpString: ` |
| 63 | <div> |
| 64 | Wraps a closure execution with an action loader overlay and optional toast notification. |
| 65 | By default, the loader blocks UI interaction until the closure completes. |
| 66 | Multiple loaders can be stacked - each gets its own toast, but the overlay stays single. |
| 67 | </div> |
| 68 | <div> |
| 69 | <strong>Toast modes:</strong> |
| 70 | <ul> |
| 71 | <li><code>stoppable</code> - Shows toast with a stop button (default)</li> |
| 72 | <li><code>static</code> - Shows toast without stop button</li> |
| 73 | <li><code>none</code> - No toast, only loader overlay</li> |
| 74 | </ul> |
| 75 | </div> |
| 76 | <div> |
| 77 | Set <code>blocking=false</code> to show only a toast without blocking the UI. |
| 78 | Useful for background operations like image captioning or generation. |
| 79 | </div> |
| 80 | <div> |
| 81 | The default stop behavior is calling <code>stopGeneration()</code>. |
| 82 | If the wrapped action is doing something different than generating, a custom stop closure can be provided. |
| 83 | </div> |
| 84 | <div> |
| 85 | <strong>Examples:</strong> |
| 86 | <ul> |
| 87 | <li><pre><code class="language-stscript">/loader-wrap message="Generating summary..." {: /gen Summary of the last message | /echo Done :}</code></pre></li> |
| 88 | <li><pre><code class="language-stscript">/loader-wrap blocking=false message="Captioning..." {: /caption :}</code></pre></li> |
| 89 | <li><pre><code class="language-stscript">/loader-wrap toast=stoppable onStop={: /echo "Stopped by user" :} {: /delay 10000 :}</code></pre></li> |
| 90 | </ul> |
| 91 | </div> |
| 92 | `, |
| 93 | namedArgumentList: [ |
| 94 | SlashCommandNamedArgument.fromProps({ |
| 95 | name: 'blocking', |
| 96 | description: 'Whether to show blocking overlay. Set to false for non-blocking toast-only loaders.', |
| 97 | typeList: [ARGUMENT_TYPE.BOOLEAN], |
| 98 | defaultValue: 'true', |
| 99 | enumList: commonEnumProviders.boolean()(), |
| 100 | }), |
| 101 | SlashCommandNamedArgument.fromProps({ |
| 102 | name: 'toast', |
| 103 | description: 'Toast display mode: stoppable (with stop button), static (no stop button), or none', |
| 104 | typeList: [ARGUMENT_TYPE.STRING], |
| 105 | defaultValue: ActionLoaderToastMode.STOPPABLE, |
| 106 | enumList: loaderEnumProviders.toastModeEnumProvider(), |
| 107 | }), |
| 108 | SlashCommandNamedArgument.fromProps({ |
| 109 | name: 'message', |
| 110 | description: 'Message to display in the toast notification', |
| 111 | typeList: [ARGUMENT_TYPE.STRING], |
| 112 | defaultValue: 'Generating...', |
| 113 | }), |
| 114 | SlashCommandNamedArgument.fromProps({ |
| 115 | name: 'title', |
| 116 | description: 'Optional title for the toast notification', |
| 117 | typeList: [ARGUMENT_TYPE.STRING], |
| 118 | }), |
| 119 | SlashCommandNamedArgument.fromProps({ |
| 120 | name: 'slug', |
| 121 | description: 'Unique slug for the loader (to identify it easily via code or CSS)', |
| 122 | typeList: [ARGUMENT_TYPE.STRING], |
| 123 | defaultValue: 'slash-wrap', |
| 124 | }), |
| 125 | SlashCommandNamedArgument.fromProps({ |
| 126 | name: 'stopTooltip', |
| 127 | description: 'Tooltip text for the stop button (only used when toast=stoppable)', |
| 128 | typeList: [ARGUMENT_TYPE.STRING], |
| 129 | defaultValue: 'Stop', |
| 130 | }), |
| 131 | SlashCommandNamedArgument.fromProps({ |
| 132 | name: 'onStop', |
| 133 | description: 'Closure to execute when the stop button is clicked. If not provided, uses default stop behavior.', |
| 134 | typeList: [ARGUMENT_TYPE.CLOSURE], |
| 135 | }), |
| 136 | ], |
| 137 | unnamedArgumentList: [ |
| 138 | SlashCommandArgument.fromProps({ |
| 139 | description: 'Closure to execute while the loader is displayed', |
| 140 | typeList: [ARGUMENT_TYPE.CLOSURE], |
| 141 | isRequired: true, |
| 142 | }), |
| 143 | ], |
| 144 | callback: async (args, value) => { |
| 145 | if (!(value instanceof SlashCommandClosure)) { |
| 146 | // Throw error on purpose. This is defined as a syntax error. |
| 147 | throw new Error(t`Invalid argument for unnamed argument provided. This is not a closure.`); |
| 148 | } |
| 149 | |
| 150 | const blocking = !isFalseBoolean(String(args.blocking)); |
| 151 | const toastMode = Object.values(ActionLoaderToastMode).includes(String(args.toast)) |
| 152 | ? String(args.toast) |
| 153 | : ActionLoaderToastMode.STOPPABLE; |
| 154 | const message = String(args.message ?? t`Generating...`); |
| 155 | const title = args.title ? String(args.title) : ''; |
| 156 | const stopTooltip = String(args.stopTooltip ?? t`Stop`); |
| 157 | |
| 158 | const actionLoader = loader.show({ |
| 159 | slug: typeof args.slug === 'string' ? String(args.slug) : 'slash-wrap', |
| 160 | blocking, |
| 161 | toastMode, |
| 162 | message, |
| 163 | title, |
| 164 | stopTooltip, |
| 165 | onStop: createClosureHandler(args.onStop), |
| 166 | }); |
| 167 | |
| 168 | try { |
| 169 | const closureCopy = value.getCopy(); |
| 170 | const result = await closureCopy.execute(); |
| 171 | return result.pipe; |
| 172 | } finally { |
| 173 | await actionLoader.hide(); |
| 174 | } |
| 175 | }, |
| 176 | })); |
| 177 | |
| 178 | // /loader-show command - manually show a loader, returns handle ID |
| 179 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 180 | name: 'loader-show', |
| 181 | returns: 'loader handle ID (use with /loader-hide)', |
| 182 | helpString: ` |
| 183 | <div> |
| 184 | Manually shows an action loader. Returns a handle ID that can be used with <code>/loader-hide</code> to hide it. |
| 185 | Use this for fine-grained control when you need to show/hide the loader at specific points. |
| 186 | Multiple loaders can be stacked - each gets its own toast, but the overlay stays single. |
| 187 | </div> |
| 188 | <div> |
| 189 | <strong>Toast modes:</strong> |
| 190 | <ul> |
| 191 | <li><code>stoppable</code> - Shows toast with a stop button (default)</li> |
| 192 | <li><code>static</code> - Shows toast without stop button</li> |
| 193 | <li><code>none</code> - No toast, only loader overlay</li> |
| 194 | </ul> |
| 195 | </div> |
| 196 | <div> |
| 197 | Set <code>blocking=false</code> to show only a toast without blocking the UI. |
| 198 | Useful for background operations like image captioning or generation. |
| 199 | </div> |
| 200 | <div> |
| 201 | The default stop behavior is calling <code>stopGeneration()</code>. |
| 202 | If the wrapped action is doing something different than generating, a custom stop closure can be provided. |
| 203 | </div> |
| 204 | <div> |
| 205 | <strong>Example:</strong> |
| 206 | <pre> |
| 207 | <code class="language-stscript"> |
| 208 | /loader-show message="Loading..." | |
| 209 | /setvar key=myLoader | |
| 210 | /some-operation | |
| 211 | /loader-hide handle={{getvar::myLoader}} |
| 212 | </code> |
| 213 | </pre> |
| 214 | </div> |
| 215 | `, |
| 216 | namedArgumentList: [ |
| 217 | SlashCommandNamedArgument.fromProps({ |
| 218 | name: 'blocking', |
| 219 | description: 'Whether to show blocking overlay. Set to false for non-blocking toast-only loaders.', |
| 220 | typeList: [ARGUMENT_TYPE.BOOLEAN], |
| 221 | defaultValue: 'true', |
| 222 | enumList: commonEnumProviders.boolean()(), |
| 223 | }), |
| 224 | SlashCommandNamedArgument.fromProps({ |
| 225 | name: 'toast', |
| 226 | description: 'Toast display mode: stoppable (with stop button), static (no stop button), or none', |
| 227 | typeList: [ARGUMENT_TYPE.STRING], |
| 228 | defaultValue: ActionLoaderToastMode.STOPPABLE, |
| 229 | enumList: loaderEnumProviders.toastModeEnumProvider(), |
| 230 | }), |
| 231 | SlashCommandNamedArgument.fromProps({ |
| 232 | name: 'message', |
| 233 | description: 'Message to display in the toast notification', |
| 234 | typeList: [ARGUMENT_TYPE.STRING], |
| 235 | defaultValue: 'Generating...', |
| 236 | }), |
| 237 | SlashCommandNamedArgument.fromProps({ |
| 238 | name: 'title', |
| 239 | description: 'Optional title for the toast notification', |
| 240 | typeList: [ARGUMENT_TYPE.STRING], |
| 241 | }), |
| 242 | SlashCommandNamedArgument.fromProps({ |
| 243 | name: 'slug', |
| 244 | description: 'Unique slug for the loader (to identify it easily via code or CSS)', |
| 245 | typeList: [ARGUMENT_TYPE.STRING], |
| 246 | defaultValue: 'slash-show', |
| 247 | }), |
| 248 | SlashCommandNamedArgument.fromProps({ |
| 249 | name: 'stopTooltip', |
| 250 | description: 'Tooltip text for the stop button (only used when toast=stoppable)', |
| 251 | typeList: [ARGUMENT_TYPE.STRING], |
| 252 | defaultValue: 'Stop', |
| 253 | }), |
| 254 | SlashCommandNamedArgument.fromProps({ |
| 255 | name: 'onStop', |
| 256 | description: 'Closure to execute when the stop button is clicked', |
| 257 | typeList: [ARGUMENT_TYPE.CLOSURE], |
| 258 | }), |
| 259 | SlashCommandNamedArgument.fromProps({ |
| 260 | name: 'onHide', |
| 261 | description: 'Closure to execute when the loader is hidden (not stopped)', |
| 262 | typeList: [ARGUMENT_TYPE.CLOSURE], |
| 263 | }), |
| 264 | ], |
| 265 | unnamedArgumentList: [], |
| 266 | callback: async (args) => { |
| 267 | const blocking = !isFalseBoolean(String(args.blocking)); |
| 268 | const toastMode = Object.values(ActionLoaderToastMode).includes(String(args.toast)) |
| 269 | ? String(args.toast) |
| 270 | : ActionLoaderToastMode.STOPPABLE; |
| 271 | const message = String(args.message ?? t`Generating...`); |
| 272 | const title = args.title ? String(args.title) : ''; |
| 273 | const stopTooltip = String(args.stopTooltip ?? t`Stop`); |
| 274 | |
| 275 | const handle = loader.show({ |
| 276 | slug: typeof args.slug === 'string' ? String(args.slug) : 'slash-show', |
| 277 | blocking, |
| 278 | toastMode, |
| 279 | message: DOMPurify.sanitize(message), |
| 280 | title: DOMPurify.sanitize(title), |
| 281 | stopTooltip, |
| 282 | onStop: createClosureHandler(args.onStop), |
| 283 | onHide: createClosureHandler(args.onHide, { argName: 'onHide' }), |
| 284 | }); |
| 285 | |
| 286 | return handle.id; |
| 287 | }, |
| 288 | })); |
| 289 | |
| 290 | // /loader-hide command - manually hide a loader by handle ID |
| 291 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 292 | name: 'loader-hide', |
| 293 | returns: 'true if an active loader was hidden, otherwise false', |
| 294 | helpString: ` |
| 295 | <div> |
| 296 | Hides an action loader that was shown with <code>/loader-show</code>. |
| 297 | If no handle is provided, hides <strong>all</strong> active loaders. |
| 298 | </div> |
| 299 | <div> |
| 300 | <strong>Example:</strong> |
| 301 | <pre><code class="language-stscript">/loader-hide handle={{getvar::myLoader}}</code></pre> |
| 302 | </div> |
| 303 | `, |
| 304 | namedArgumentList: [ |
| 305 | SlashCommandNamedArgument.fromProps({ |
| 306 | name: 'handle', |
| 307 | description: 'Loader handle ID returned by /loader-show. If not provided, hides all active loaders.', |
| 308 | typeList: [ARGUMENT_TYPE.STRING], |
| 309 | enumProvider: loaderEnumProviders.loaderHandleProvider, |
| 310 | }), |
| 311 | ], |
| 312 | callback: async (args) => { |
| 313 | const handleId = args.handle ? String(args.handle) : null; |
| 314 | |
| 315 | if (handleId) { |
| 316 | const handle = getLoaderHandleById(handleId); |
| 317 | if (handle && handle.isActive) { |
| 318 | await handle.hide(); |
| 319 | return 'true'; |
| 320 | } |
| 321 | return 'false'; |
| 322 | } |
| 323 | |
| 324 | // No handle provided - hide all active loaders |
| 325 | const result = await loader.hide(); |
| 326 | return result ? 'true' : 'false'; |
| 327 | }, |
| 328 | })); |
| 329 | |
| 330 | // /loader-stop command - trigger the stop action on a loader |
| 331 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 332 | name: 'loader-stop', |
| 333 | returns: 'true if an active loader was stopped, otherwise false', |
| 334 | helpString: ` |
| 335 | <div> |
| 336 | Triggers the stop action on a specific action loader, as if the user clicked the stop button. |
| 337 | Unlike <code>/loader-hide</code>, this command requires a handle - you must specify which loader to stop. |
| 338 | </div> |
| 339 | <div> |
| 340 | <strong>Example:</strong> |
| 341 | <pre><code class="language-stscript">/loader-stop handle={{getvar::myLoader}}</code></pre> |
| 342 | </div> |
| 343 | `, |
| 344 | namedArgumentList: [ |
| 345 | SlashCommandNamedArgument.fromProps({ |
| 346 | name: 'handle', |
| 347 | description: 'Loader handle ID returned by /loader-show.', |
| 348 | typeList: [ARGUMENT_TYPE.STRING], |
| 349 | isRequired: true, |
| 350 | enumProvider: loaderEnumProviders.loaderHandleProvider, |
| 351 | }), |
| 352 | ], |
| 353 | callback: async (args) => { |
| 354 | const handleId = args.handle ? String(args.handle) : null; |
| 355 | |
| 356 | if (!handleId) { |
| 357 | toastr.warning(t`No handle provided. You must specify which loader to stop.`); |
| 358 | return 'false'; |
| 359 | } |
| 360 | |
| 361 | const handle = getLoaderHandleById(handleId); |
| 362 | if (handle && handle.isActive) { |
| 363 | await handle.stop(); |
| 364 | return 'true'; |
| 365 | } |
| 366 | |
| 367 | return 'false'; |
| 368 | }, |
| 369 | })); |
| 370 | } |