Merge pull request #3730 from SillyTavern/feat/command-buttons-multiple

Add support for toggleable buttons/multiselect in `/buttons` command
This commit is contained in:
Cohee
2025-03-20 10:42:57 +02:00
committed by GitHub
2 changed files with 96 additions and 16 deletions

View File

@ -69,7 +69,7 @@ import { SlashCommand } from './slash-commands/SlashCommand.js';
import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortController.js'; import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortController.js';
import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js'; import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js';
import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js'; import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js'; import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js'; import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';
import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js'; import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js';
@ -77,6 +77,7 @@ import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHel
import { accountStorage } from './util/AccountStorage.js'; import { accountStorage } from './util/AccountStorage.js';
import { SlashCommandDebugController } from './slash-commands/SlashCommandDebugController.js'; import { SlashCommandDebugController } from './slash-commands/SlashCommandDebugController.js';
import { SlashCommandScope } from './slash-commands/SlashCommandScope.js'; import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';
import { t } from './i18n.js';
export { export {
executeSlashCommands, executeSlashCommandsWithOptions, getSlashCommandsHelp, registerSlashCommand, executeSlashCommands, executeSlashCommandsWithOptions, getSlashCommandsHelp, registerSlashCommand,
}; };
@ -1556,16 +1557,28 @@ export function initDefaultSlashCommands() {
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'buttons', name: 'buttons',
callback: buttonsCallback, callback: buttonsCallback,
returns: 'clicked button label', returns: 'clicked button label (or array of labels if multiple is enabled)',
namedArgumentList: [ namedArgumentList: [
new SlashCommandNamedArgument( SlashCommandNamedArgument.fromProps({
'labels', 'button labels', [ARGUMENT_TYPE.LIST], true, name: 'labels',
), description: 'button labels',
typeList: [ARGUMENT_TYPE.LIST],
isRequired: true,
}),
SlashCommandNamedArgument.fromProps({
name: 'multiple',
description: 'if enabled multiple buttons can be clicked/toggled, and all clicked buttons are returned as an array',
typeList: [ARGUMENT_TYPE.BOOLEAN],
enumList: commonEnumProviders.boolean('trueFalse')(),
defaultValue: 'false',
}),
], ],
unnamedArgumentList: [ unnamedArgumentList: [
new SlashCommandArgument( SlashCommandArgument.fromProps({
'text', [ARGUMENT_TYPE.STRING], true, description: 'text',
), typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
}),
], ],
helpString: ` helpString: `
<div> <div>
@ -2377,6 +2390,18 @@ async function trimTokensCallback(arg, value) {
} }
} }
/**
* Displays a popup with buttons based on provided labels and handles button interactions.
*
* @param {object} args - Named arguments for the command
* @param {string} args.labels - JSON string of an array of button labels
* @param {string} [args.multiple=false] - Flag indicating if multiple buttons can be toggled
* @param {string} text - The text content to be displayed within the popup
*
* @returns {Promise<string>} - A promise that resolves to a string of the button labels selected
* If 'multiple' is true, returns a JSON string array of labels.
* If 'multiple' is false, returns a single label string.
*/
async function buttonsCallback(args, text) { async function buttonsCallback(args, text) {
try { try {
/** @type {string[]} */ /** @type {string[]} */
@ -2387,6 +2412,10 @@ async function buttonsCallback(args, text) {
return ''; return '';
} }
/** @type {Set<number>} */
const multipleToggledState = new Set();
const multiple = isTrueBoolean(args?.multiple);
// Map custom buttons to results. Start at 2 because 1 and 0 are reserved for ok and cancel // Map custom buttons to results. Start at 2 because 1 and 0 are reserved for ok and cancel
const resultToButtonMap = new Map(buttons.map((button, index) => [index + 2, button])); const resultToButtonMap = new Map(buttons.map((button, index) => [index + 2, button]));
@ -2404,11 +2433,24 @@ async function buttonsCallback(args, text) {
for (const [result, button] of resultToButtonMap) { for (const [result, button] of resultToButtonMap) {
const buttonElement = document.createElement('div'); const buttonElement = document.createElement('div');
buttonElement.classList.add('menu_button', 'result-control', 'wide100p'); buttonElement.classList.add('menu_button', 'wide100p');
buttonElement.dataset.result = String(result);
buttonElement.addEventListener('click', async () => { if (multiple) {
await popup.complete(result); buttonElement.classList.add('toggleable');
}); buttonElement.dataset.toggleValue = String(result);
buttonElement.addEventListener('click', async () => {
buttonElement.classList.toggle('toggled');
if (buttonElement.classList.contains('toggled')) {
multipleToggledState.add(result);
} else {
multipleToggledState.delete(result);
}
});
} else {
buttonElement.classList.add('result-control');
buttonElement.dataset.result = String(result);
}
buttonElement.innerText = button; buttonElement.innerText = button;
buttonContainer.appendChild(buttonElement); buttonContainer.appendChild(buttonElement);
} }
@ -2424,10 +2466,19 @@ async function buttonsCallback(args, text) {
popupContainer.style.flexDirection = 'column'; popupContainer.style.flexDirection = 'column';
popupContainer.style.maxHeight = '80vh'; // Limit the overall height of the popup popupContainer.style.maxHeight = '80vh'; // Limit the overall height of the popup
popup = new Popup(popupContainer, POPUP_TYPE.TEXT, '', { okButton: 'Cancel', allowVerticalScrolling: true }); popup = new Popup(popupContainer, POPUP_TYPE.TEXT, '', { okButton: multiple ? t`Ok` : t`Cancel`, allowVerticalScrolling: true });
popup.show() popup.show()
.then((result => resolve(typeof result === 'number' ? resultToButtonMap.get(result) ?? '' : ''))) .then((result => resolve(getResult(result))))
.catch(() => resolve('')); .catch(() => resolve(''));
/** @returns {string} @param {string|number|boolean} result */
function getResult(result) {
if (multiple) {
const array = result === POPUP_RESULT.AFFIRMATIVE ? Array.from(multipleToggledState).map(r => resultToButtonMap.get(r) ?? '') : [];
return JSON.stringify(array);
}
return typeof result === 'number' ? resultToButtonMap.get(result) ?? '' : '';
}
}); });
} catch { } catch {
return ''; return '';

View File

@ -2911,6 +2911,35 @@ select option:not(:checked) {
pointer-events: none; pointer-events: none;
} }
.menu_button.toggleable {
padding-left: 20px;
}
.menu_button.toggleable.toggled {
border-color: var(--active);
}
.menu_button.toggleable:not(.toggled) {
filter: brightness(80%);
}
.menu_button.toggleable::before {
font-family: "Font Awesome 6 Free";
margin-left: 10px;
position: absolute;
left: 0;
}
.menu_button.toggleable.toggled::before {
content: "\f00c";
color: var(--active);
}
.menu_button.toggleable:not(.toggled)::before {
content: "\f00d";
color: var(--fullred);
}
.fav_on { .fav_on {
color: var(--golden) !important; color: var(--golden) !important;
} }
@ -2921,7 +2950,7 @@ select option:not(:checked) {
} }
.menu_button.togglable:not(.toggleEnabled) { .menu_button.togglable:not(.toggleEnabled) {
color: red; color: var(--fullred);
} }
.displayBlock { .displayBlock {