Merge pull request #3730 from SillyTavern/feat/command-buttons-multiple Add support for toggleable buttons/multiselect in `/buttons` command
Signed| @@ -69,7 +69,7 @@ import { SlashCommand } from './slash-commands/SlashCommand.js'; | ||
| 69 | 69 | import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortController.js'; |
| 70 | 70 | import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js'; |
| 71 | 71 | import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js'; |
| 72 | 72 | import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; |
| 73 | 73 | import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 74 | 74 | import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js'; |
| 75 | 75 | import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js'; |
| @@ -77,6 +77,7 @@ import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHel | ||
| 77 | 77 | import { accountStorage } from './util/AccountStorage.js'; |
| 78 | 78 | import { SlashCommandDebugController } from './slash-commands/SlashCommandDebugController.js'; |
| 79 | 79 | import { SlashCommandScope } from './slash-commands/SlashCommandScope.js'; |
| 80 | +import { t } from './i18n.js'; | |
| 80 | 81 | export { |
| 81 | 82 | executeSlashCommands, executeSlashCommandsWithOptions, getSlashCommandsHelp, registerSlashCommand, |
| 82 | 83 | }; |
| @@ -1556,16 +1557,28 @@ export function initDefaultSlashCommands() { | ||
| 1556 | 1557 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 1557 | 1558 | name: 'buttons', |
| 1558 | 1559 | callback: buttonsCallback, |
| 1559 | 1560 | returns: 'clicked button label (or array of labels if multiple is enabled)', |
| 1560 | 1561 | namedArgumentList: [ |
| 1561 | 1562 | new SlashCommandNamedArgument.fromProps({ |
| 1562 | - 'labels', 'button labels', [ARGUMENT_TYPE.LIST], true, | |
| 1563 | + name: 'labels', | |
| 1563 | - ), | |
| 1564 | + description: 'button labels', | |
| 1565 | + typeList: [ARGUMENT_TYPE.LIST], | |
| 1566 | + isRequired: true, | |
| 1567 | + }), | |
| 1568 | + SlashCommandNamedArgument.fromProps({ | |
| 1569 | + name: 'multiple', | |
| 1570 | + description: 'if enabled multiple buttons can be clicked/toggled, and all clicked buttons are returned as an array', | |
| 1571 | + typeList: [ARGUMENT_TYPE.BOOLEAN], | |
| 1572 | + enumList: commonEnumProviders.boolean('trueFalse')(), | |
| 1573 | + defaultValue: 'false', | |
| 1574 | + }), | |
| 1564 | 1575 | ], |
| 1565 | 1576 | unnamedArgumentList: [ |
| 1566 | 1577 | new SlashCommandArgument.fromProps({ |
| 1567 | - 'text', [ARGUMENT_TYPE.STRING], true, | |
| 1578 | + description: 'text', | |
| 1568 | - ), | |
| 1579 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 1580 | + isRequired: true, | |
| 1581 | + }), | |
| 1569 | 1582 | ], |
| 1570 | 1583 | helpString: ` |
| 1571 | 1584 | <div> |
| @@ -2377,6 +2390,18 @@ async function trimTokensCallback(arg, value) { | ||
| 2377 | 2390 | } |
| 2378 | 2391 | } |
| 2379 | 2392 | |
| 2393 | +/** | |
| 2394 | + * Displays a popup with buttons based on provided labels and handles button interactions. | |
| 2395 | + * | |
| 2396 | + * @param {object} args - Named arguments for the command | |
| 2397 | + * @param {string} args.labels - JSON string of an array of button labels | |
| 2398 | + * @param {string} [args.multiple=false] - Flag indicating if multiple buttons can be toggled | |
| 2399 | + * @param {string} text - The text content to be displayed within the popup | |
| 2400 | + * | |
| 2401 | + * @returns {Promise<string>} - A promise that resolves to a string of the button labels selected | |
| 2402 | + * If 'multiple' is true, returns a JSON string array of labels. | |
| 2403 | + * If 'multiple' is false, returns a single label string. | |
| 2404 | + */ | |
| 2380 | 2405 | async function buttonsCallback(args, text) { |
| 2381 | 2406 | try { |
| 2382 | 2407 | /** @type {string[]} */ |
| @@ -2387,6 +2412,10 @@ async function buttonsCallback(args, text) { | ||
| 2387 | 2412 | return ''; |
| 2388 | 2413 | } |
| 2389 | 2414 | |
| 2415 | + /** @type {Set<number>} */ | |
| 2416 | + const multipleToggledState = new Set(); | |
| 2417 | + const multiple = isTrueBoolean(args?.multiple); | |
| 2418 | + | |
| 2390 | 2419 | // Map custom buttons to results. Start at 2 because 1 and 0 are reserved for ok and cancel |
| 2391 | 2420 | const resultToButtonMap = new Map(buttons.map((button, index) => [index + 2, button])); |
| 2392 | 2421 | |
| @@ -2404,11 +2433,24 @@ async function buttonsCallback(args, text) { | ||
| 2404 | 2433 | |
| 2405 | 2434 | for (const [result, button] of resultToButtonMap) { |
| 2406 | 2435 | const buttonElement = document.createElement('div'); |
| 2407 | 2436 | buttonElement.classList.add('menu_button', 'result-control', 'wide100p'); |
| 2408 | - buttonElement.dataset.result = String(result); | |
| 2437 | + | |
| 2438 | + if (multiple) { | |
| 2439 | + buttonElement.classList.add('toggleable'); | |
| 2440 | + buttonElement.dataset.toggleValue = String(result); | |
| 2409 | 2441 | buttonElement.addEventListener('click', async () => { |
| 2410 | - await popup.complete(result); | |
| 2442 | + buttonElement.classList.toggle('toggled'); | |
| 2443 | + if (buttonElement.classList.contains('toggled')) { | |
| 2444 | + multipleToggledState.add(result); | |
| 2445 | + } else { | |
| 2446 | + multipleToggledState.delete(result); | |
| 2447 | + } | |
| 2411 | 2448 | }); |
| 2449 | + } else { | |
| 2450 | + buttonElement.classList.add('result-control'); | |
| 2451 | + buttonElement.dataset.result = String(result); | |
| 2452 | + } | |
| 2453 | + | |
| 2412 | 2454 | buttonElement.innerText = button; |
| 2413 | 2455 | buttonContainer.appendChild(buttonElement); |
| 2414 | 2456 | } |
| @@ -2424,10 +2466,19 @@ async function buttonsCallback(args, text) { | ||
| 2424 | 2466 | popupContainer.style.flexDirection = 'column'; |
| 2425 | 2467 | popupContainer.style.maxHeight = '80vh'; // Limit the overall height of the popup |
| 2426 | 2468 | |
| 2427 | 2469 | popup = new Popup(popupContainer, POPUP_TYPE.TEXT, '', { okButton: 'multiple ? t`Ok` : t`Cancel'`, allowVerticalScrolling: true }); |
| 2428 | 2470 | popup.show() |
| 2429 | 2471 | .then((result => resolve(typeof result === 'number' ? resultToButtonMap.getgetResult(result) ?? '' : ''))) |
| 2430 | 2472 | .catch(() => resolve('')); |
| 2473 | + | |
| 2474 | + /** @returns {string} @param {string|number|boolean} result */ | |
| 2475 | + function getResult(result) { | |
| 2476 | + if (multiple) { | |
| 2477 | + const array = result === POPUP_RESULT.AFFIRMATIVE ? Array.from(multipleToggledState).map(r => resultToButtonMap.get(r) ?? '') : []; | |
| 2478 | + return JSON.stringify(array); | |
| 2479 | + } | |
| 2480 | + return typeof result === 'number' ? resultToButtonMap.get(result) ?? '' : ''; | |
| 2481 | + } | |
| 2431 | 2482 | }); |
| 2432 | 2483 | } catch { |
| 2433 | 2484 | return ''; |
| @@ -2911,6 +2911,35 @@ select option:not(:checked) { | ||
| 2911 | 2911 | pointer-events: none; |
| 2912 | 2912 | } |
| 2913 | 2913 | |
| 2914 | +.menu_button.toggleable { | |
| 2915 | + padding-left: 20px; | |
| 2916 | +} | |
| 2917 | + | |
| 2918 | +.menu_button.toggleable.toggled { | |
| 2919 | + border-color: var(--active); | |
| 2920 | +} | |
| 2921 | + | |
| 2922 | +.menu_button.toggleable:not(.toggled) { | |
| 2923 | + filter: brightness(80%); | |
| 2924 | +} | |
| 2925 | + | |
| 2926 | +.menu_button.toggleable::before { | |
| 2927 | + font-family: "Font Awesome 6 Free"; | |
| 2928 | + margin-left: 10px; | |
| 2929 | + position: absolute; | |
| 2930 | + left: 0; | |
| 2931 | +} | |
| 2932 | + | |
| 2933 | +.menu_button.toggleable.toggled::before { | |
| 2934 | + content: "\f00c"; | |
| 2935 | + color: var(--active); | |
| 2936 | +} | |
| 2937 | + | |
| 2938 | +.menu_button.toggleable:not(.toggled)::before { | |
| 2939 | + content: "\f00d"; | |
| 2940 | + color: var(--fullred); | |
| 2941 | +} | |
| 2942 | + | |
| 2914 | 2943 | .fav_on { |
| 2915 | 2944 | color: var(--golden) !important; |
| 2916 | 2945 | } |
| @@ -2921,7 +2950,7 @@ select option:not(:checked) { | ||
| 2921 | 2950 | } |
| 2922 | 2951 | |
| 2923 | 2952 | .menu_button.togglable:not(.toggleEnabled) { |
| 2924 | 2953 | color: redvar(--fullred); |
| 2925 | 2954 | } |
| 2926 | 2955 | |
| 2927 | 2956 | .displayBlock { |