Merge pull request #3730 from SillyTavern/feat/command-buttons-multiple Add support for toggleable buttons/multiselect in `/buttons` command

ef56eda2c21c7ae90d4aeb67bca41f6e819e82c1

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
2 files changed, +94 -14Showing whitespace changes
public/scripts/slash-commands.js+64 -13
@@ -69,7 +69,7 @@ import { SlashCommand } from './slash-commands/SlashCommand.js';
69import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortController.js';69import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortController.js';
70import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js';70import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js';
71import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';71import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
72import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js';72import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
73import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';73import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
74import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';74import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';
75import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js';75import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js';
@@ -77,6 +77,7 @@ import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHel
77import { accountStorage } from './util/AccountStorage.js';77import { accountStorage } from './util/AccountStorage.js';
78import { SlashCommandDebugController } from './slash-commands/SlashCommandDebugController.js';78import { SlashCommandDebugController } from './slash-commands/SlashCommandDebugController.js';
79import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';79import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';
80import { t } from './i18n.js';
80export {81export {
81 executeSlashCommands, executeSlashCommandsWithOptions, getSlashCommandsHelp, registerSlashCommand,82 executeSlashCommands, executeSlashCommandsWithOptions, getSlashCommandsHelp, registerSlashCommand,
82};83};
@@ -1556,16 +1557,28 @@ export function initDefaultSlashCommands() {
1556 SlashCommandParser.addCommandObject(SlashCommand.fromProps({1557 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1557 name: 'buttons',1558 name: 'buttons',
1558 callback: buttonsCallback,1559 callback: buttonsCallback,
1559 returns: 'clicked button label',1560 returns: 'clicked button label (or array of labels if multiple is enabled)',
1560 namedArgumentList: [1561 namedArgumentList: [
1561 new SlashCommandNamedArgument(1562 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 unnamedArgumentList: [1576 unnamedArgumentList: [
1566 new SlashCommandArgument(1577 SlashCommandArgument.fromProps({
1567 'text', [ARGUMENT_TYPE.STRING], true,1578 description: 'text',
1568 ),1579 typeList: [ARGUMENT_TYPE.STRING],
1580 isRequired: true,
1581 }),
1569 ],1582 ],
1570 helpString: `1583 helpString: `
1571 <div>1584 <div>
@@ -2377,6 +2390,18 @@ async function trimTokensCallback(arg, value) {
2377 }2390 }
2378}2391}
23792392
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 */
2380async function buttonsCallback(args, text) {2405async function buttonsCallback(args, text) {
2381 try {2406 try {
2382 /** @type {string[]} */2407 /** @type {string[]} */
@@ -2387,6 +2412,10 @@ async function buttonsCallback(args, text) {
2387 return '';2412 return '';
2388 }2413 }
23892414
2415 /** @type {Set<number>} */
2416 const multipleToggledState = new Set();
2417 const multiple = isTrueBoolean(args?.multiple);
2418
2390 // Map custom buttons to results. Start at 2 because 1 and 0 are reserved for ok and cancel2419 // Map custom buttons to results. Start at 2 because 1 and 0 are reserved for ok and cancel
2391 const resultToButtonMap = new Map(buttons.map((button, index) => [index + 2, button]));2420 const resultToButtonMap = new Map(buttons.map((button, index) => [index + 2, button]));
23922421
@@ -2404,11 +2433,24 @@ async function buttonsCallback(args, text) {
24042433
2405 for (const [result, button] of resultToButtonMap) {2434 for (const [result, button] of resultToButtonMap) {
2406 const buttonElement = document.createElement('div');2435 const buttonElement = document.createElement('div');
2407 buttonElement.classList.add('menu_button', 'result-control', 'wide100p');2436 buttonElement.classList.add('menu_button', 'wide100p');
2408 buttonElement.dataset.result = String(result);2437
2438 if (multiple) {
2439 buttonElement.classList.add('toggleable');
2440 buttonElement.dataset.toggleValue = String(result);
2409 buttonElement.addEventListener('click', async () => {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 buttonElement.innerText = button;2454 buttonElement.innerText = button;
2413 buttonContainer.appendChild(buttonElement);2455 buttonContainer.appendChild(buttonElement);
2414 }2456 }
@@ -2424,10 +2466,19 @@ async function buttonsCallback(args, text) {
2424 popupContainer.style.flexDirection = 'column';2466 popupContainer.style.flexDirection = 'column';
2425 popupContainer.style.maxHeight = '80vh'; // Limit the overall height of the popup2467 popupContainer.style.maxHeight = '80vh'; // Limit the overall height of the popup
24262468
2427 popup = new Popup(popupContainer, POPUP_TYPE.TEXT, '', { okButton: 'Cancel', allowVerticalScrolling: true });2469 popup = new Popup(popupContainer, POPUP_TYPE.TEXT, '', { okButton: multiple ? t`Ok` : t`Cancel`, allowVerticalScrolling: true });
2428 popup.show()2470 popup.show()
2429 .then((result => resolve(typeof result === 'number' ? resultToButtonMap.get(result) ?? '' : '')))2471 .then((result => resolve(getResult(result))))
2430 .catch(() => resolve(''));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 } catch {2483 } catch {
2433 return '';2484 return '';
public/style.css+30 -1
@@ -2911,6 +2911,35 @@ select option:not(:checked) {
2911 pointer-events: none;2911 pointer-events: none;
2912}2912}
29132913
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.fav_on {2943.fav_on {
2915 color: var(--golden) !important;2944 color: var(--golden) !important;
2916}2945}
@@ -2921,7 +2950,7 @@ select option:not(:checked) {
2921}2950}
29222951
2923.menu_button.togglable:not(.toggleEnabled) {2952.menu_button.togglable:not(.toggleEnabled) {
2924 color: red;2953 color: var(--fullred);
2925}2954}
29262955
2927.displayBlock {2956.displayBlock {