WI switch key controls to multi input

- Switch key/secondarykey controls to select2 input
- Custom tokenizer for regex parsing, allowing comma in regex
- Keep mobile-compatibility by switching to textarea
- select2 utility method to pre-fill options
- New inline display mode of select2 multi to save space
This commit is contained in:
Wolfsblvt 2024-05-07 02:01:54 +02:00
parent 7063fce2af
commit 70a2f71e33
5 changed files with 260 additions and 55 deletions

1
public/global.d.ts vendored
View File

@ -24,6 +24,7 @@ interface JQuery {
pagination(options?: any): JQuery;
transition(options?: any): JQuery;
select2(options?: any): JQuery;
select2(field: 'data'): any[];
sortable(options?: any): JQuery;
autocomplete(options?: any): JQuery;
autocomplete(method: string, options?: any): JQuery;

View File

@ -3310,7 +3310,7 @@
<span data-i18n="Active World(s) for all chats"><small>Active World(s) for all chats</small></span>
</div>
<div class="range-block-range">
<select id="world_info" multiple>
<select id="world_info" class="select2_multi_sameline" multiple>
<option value="" data-i18n="-- World Info not found --">-- World Info not found -- </option>
</select>
</div>
@ -5109,7 +5109,8 @@
</span>
</small>
<small class="textAlignCenter" data-i18n="Primary Keywords">Primary Keywords</small>
<textarea class="text_pole keyprimarytextpole" name="key" rows="1" data-i18n="[placeholder]Comma separated (required)" placeholder="Comma separated (required)" maxlength="2000"></textarea>
<select class="keyprimaryselect select2_multi_sameline" name="key" data-i18n="[placeholder]Keywords or Regexes" placeholder="Keywords or Regexes" multiple="multiple"></select>
<textarea class="text_pole keyprimarytextpole mobile" name="key" rows="1" data-i18n="[placeholder]Comma separated list" placeholder="Comma separated list" maxlength="2000" style="display: none;"></textarea>
</div>
<div class="world_entry_form_control">
<small class="textAlignCenter" data-i18n="Logic">Logic</small>
@ -5127,9 +5128,8 @@
</span>
</small>
<small class="textAlignCenter" data-i18n="Optional Filter">Optional Filter</small>
<div class="flex-container flexFlowRow alignitemscenter">
<textarea class="text_pole keysecondarytextpole" name="keysecondary" rows="1" data-i18n="[placeholder]Comma separated (ignored if empty)" placeholder="Comma separated list" maxlength="2000"></textarea>
</div>
<select class="keypsecondaryselect select2_multi_sameline" name="keysecondary" data-i18n="[placeholder]Keywords or Regexes (ignored if empty)" placeholder="Keywords or Regexes (ignored if empty)" multiple="multiple"></select>
<textarea class="text_pole keysecondarytextpole mobile" name="keysecondary" rows="1" data-i18n="[placeholder]Comma separated list (ignored if empty)" placeholder="Comma separated list (ignored if empty)" maxlength="2000" style="display: none;"></textarea>
</div>
</div>
<div name="perEntryOverridesBlock" class="flex-container wide100p alignitemscenter">
@ -5220,7 +5220,7 @@
</label>
</div>
<div class="range-block-range">
<select name="characterFilter" multiple>
<select name="characterFilter" class="select2_multi_sameline" multiple>
<option value="">
<span data-i18n="-- Characters not found --">-- Characters not found --</span>
</option>

View File

@ -1449,3 +1449,31 @@ export function includesIgnoreCaseAndAccents(text, searchTerm) {
// Check if the normalized text includes the normalized search term
return normalizedText.includes(normalizedSearchTerm);
}
/**
* Modifies the select2 options by adding not existing one and optionally selecting them
*
* @param {JQuery<HTMLElement>} element - The "select" element to add the options to
* @param {string[]|{id: string, text: string}[]} items - The option items to build, add or select
* @param {object} [options] - Optional arguments
* @param {boolean} [options.select=false] - Whether the options should be selected right away
* @param {object} [options.changeEventArgs=null] - Optional event args being passed into the "change" event when its triggered because a new options is selected
*/
export function select2ModifyOptions(element, items, { select = false, changeEventArgs = null } = {}) {
if (!items.length) return;
/** @type {{id: string, text: string}[]} */
const dataItems = items.map(x => typeof x === 'string' ? { id: x, text: x } : x);
dataItems.forEach(item => {
// Set the value, creating a new option if necessary
if (element.find("option[value='" + item.id + "']").length) {
if (select) element.val(item.id).trigger('change', changeEventArgs);
} else {
// Create a DOM Option and optionally pre-select by default
var newOption = new Option(item.text, item.id, select, select);
// Append it to the select
element.append(newOption);
if (select) element.trigger('change', changeEventArgs);
}
});
}

View File

@ -1,5 +1,5 @@
import { saveSettings, callPopup, substituteParams, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type, eventSource, event_types, getExtensionPromptByName, saveMetadata, getCurrentChatId, extension_prompt_roles } from '../script.js';
import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight } from './utils.js';
import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions } from './utils.js';
import { extension_settings, getContext } from './extensions.js';
import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js';
import { registerSlashCommand } from './slash-commands.js';
@ -1044,7 +1044,7 @@ function displayWorldEntries(name, data, navigation = navigation_option.none) {
worldEntriesList.sortable({
delay: getSortableDelay(),
handle: '.drag-handle',
stop: async function (event, ui) {
stop: async function (_event, _ui) {
const firstEntryUid = $('#world_popup_entries_list .world_entry').first().data('uid');
const minDisplayIndex = data?.entries[firstEntryUid]?.displayIndex ?? 0;
$('#world_popup_entries_list .world_entry').each(function (index) {
@ -1139,6 +1139,131 @@ function deleteOriginalDataValue(data, uid) {
}
}
/**
* Splits a given input string that contains one or more keywords or regexes, separated by commas.
*
* Each part can be a valid regex following the pattern `/myregex/flags` with optional flags. Commmas inside the regex are allowed, slashes have to be escaped like this: `\/`
* If a regex doesn't stand alone, it is not treated as a regex.
*
* @param {string} input - One or multiple keywords or regexes, separated by commas
* @returns {string[]} An array of keywords and regexes
*/
function splitKeywordsAndRegexes(input) {
/** @type {string[]} */
let keywordsAndRegexes = [];
// We can make this easy. Instead of writing another function to find and parse regexes,
// we gonna utilize the custom tokenizer that also handles the input.
// No need for validation here
const addFindCallback = (/** @type {{id: string, text: string}} */ item) => {
keywordsAndRegexes.push(item.id);
}
const { term } = customTokenizer({ _type: 'custom_call', term: input }, undefined, addFindCallback);
addFindCallback({ id: term.trim(), text: term.trim() });
return keywordsAndRegexes;
}
/**
* Tokenizer parsing input and splitting it into keywords and regexes
*
* @param {{_type: string, term: string}} input - The typed input
* @param {{options: object}} _selection - The selection even object (?)
* @param {function} callback - The original callback function to call if an item should be inserted
* @returns {{term: string}} - The remaining part that is untokenized in the textbox
*/
function customTokenizer(input, _selection, callback) {
let current = input.term;
// Go over the input and check the current state, if we can get a token
for (let i = 0; i < current.length; i++) {
let char = current[i];
// If a comma is typed, we tokenize the input.
// unless we are inside a possible regex, which would allow commas inside
if (char === ',') {
// We take everything up till now and consider this a token
const token = current.slice(0, i).trim();
// Now how we test if this is a valid regex? And not a finished one, but a half-finished one?
// Easy, if someone typed a comma it can't be a delimiter escape.
// So we just check if this opening with a slash, and if so, we "close" the regex and try to parse it.
// So if we are inside a valid regex, we can't take the token now, we continue processing until the regex is closed,
// or this is not a valid regex anymore
if (token.startsWith('/') && isValidRegex(token + '/')) {
continue;
}
// So now the comma really means the token is done.
// We take the token up till now, and insert it. Empty will be skipped.
if (token) {
// Last chance to check for valid regex again. Because it might have been valid while typing, but now is not valid anymore and contains commas we need to split.
if (token.startsWith('/') && !isValidRegex(token)) {
const tokens = token.split(',').map(x => x.trim());
tokens.forEach(x => callback({ id: x, text: x }));
} else {
callback({ id: token, text: token });
}
}
// Now remove the token from the current input, and the comma too
current = current.slice(i + 1);
}
}
// At the end, just return the left-over input
return { term: current };
}
/**
* Validates if a string is a valid slash-delimited regex, that can be parsed and executed
*
* This is a wrapper around `parseRegexFromString`
*
* @param {string} input - A delimited regex string
* @returns {boolean} Whether this would be a valid regex that can be parsed and executed
*/
function isValidRegex(input) {
return parseRegexFromString(input) !== null;
}
/**
* Gets a real regex object from a slash-delimited regex string
*
* This function works with `/` as delimiter, and each occurance of it inside the regex has to be escaped.
* Flags are optional, but can only be valid flags supported by JavaScript's `RegExp` (`g`, `i`, `m`, `s`, `u`, `y`).
*
* @param {string} input - A delimited regex string
* @returns {RegExp|null} The regex object, or null if not a valid regex
*/
function parseRegexFromString(input) {
// Extracting the regex pattern and flags
let match = input.match(/^\/([\w\W]+?)\/([gimsuy]*)$/);
if (!match) {
return null; // Not a valid regex format
}
let [, pattern, flags] = match;
// If we find any unescaped slash delimiter, we also exit out.
// JS doesn't care about delimiters inside regex patterns, but for this to be a valid regex outside of our implementation,
// we have to make sure that our delimiter is correctly escaped. Or every other engine would fail.
if (pattern.match(/(^|[^\\])\//)) {
return null;
}
// Now we need to actually unescape the slash delimiters, because JS doesn't care about delimiters
pattern = pattern.replace('\\/', '/');
// Then we return the regex. If it fails, it was invalid syntax.
try {
return new RegExp(pattern, flags);
} catch (e) {
return null;
}
}
function getWorldEntry(name, data, entry) {
if (!data.entries[entry.uid]) {
return;
@ -1148,28 +1273,60 @@ function getWorldEntry(name, data, entry) {
template.data('uid', entry.uid);
template.attr('uid', entry.uid);
/** Function to build the keys input controls @param {string} entryPropName @param {string} originalDataValueName */
function enableKeysInput(entryPropName, originalDataValueName) {
const input = !isMobile() ? template.find(`select[name="${entryPropName}"]`) : template.find(`textarea[name="${entryPropName}"]`);
input.data('uid', entry.uid);
input.on('click', function (event) {
// Prevent closing the drawer on clicking the input
event.stopPropagation();
});
if (!isMobile()) {
input.select2({
tags: true,
tokenSeparators: [','],
tokenizer: customTokenizer,
placeholder: input.attr('placeholder'),
});
input.on('change', function (_, { skipReset, noSave } = {}) {
const uid = $(this).data('uid');
/** @type {string[]} */
const keys = ($(this).select2('data')).map(x => x.id);
!skipReset && resetScrollHeight(this);
if (!noSave) {
data.entries[uid][entryPropName] = keys;
setOriginalDataValue(data, uid, originalDataValueName, data.entries[uid][entryPropName]);
saveWorldInfo(name, data);
}
});
select2ModifyOptions(input, entry[entryPropName], { select: true, changeEventArgs: { skipReset: true, noSave: true } });
}
else {
// Compatibility with mobile devices. On mobile we need a text input field, not a select option control, so we need its own event handlers
template.find(`select[name="${entryPropName}"]`).hide();
input.show();
input.on('input', function (_, { skipReset } = {}) {
const uid = $(this).data('uid');
const value = String($(this).val());
!skipReset && resetScrollHeight(this);
data.entries[uid][entryPropName] = splitKeywordsAndRegexes(value);
setOriginalDataValue(data, uid, originalDataValueName, data.entries[uid][entryPropName]);
saveWorldInfo(name, data);
});
input.val(entry[entryPropName].join(', ')).trigger('input', { skipReset: true });
}
}
// key
const keyInput = template.find('textarea[name="key"]');
keyInput.data('uid', entry.uid);
keyInput.on('click', function (event) {
// Prevent closing the drawer on clicking the input
event.stopPropagation();
});
enableKeysInput("key", "keys");
keyInput.on('input', function (_, { skipReset } = {}) {
const uid = $(this).data('uid');
const value = String($(this).val());
!skipReset && resetScrollHeight(this);
data.entries[uid].key = value
.split(',')
.map((x) => x.trim())
.filter((x) => x);
setOriginalDataValue(data, uid, 'keys', data.entries[uid].key);
saveWorldInfo(name, data);
});
keyInput.val(entry.key.join(', ')).trigger('input', { skipReset: true });
//initScrollHeight(keyInput);
// keysecondary
enableKeysInput("keysecondary", "secondary_keys");
// logic AND/NOT
const selectiveLogicDropdown = template.find('select[name="entryLogicType"]');
@ -1298,25 +1455,6 @@ function getWorldEntry(name, data, entry) {
saveWorldInfo(name, data);
});
// keysecondary
const keySecondaryInput = template.find('textarea[name="keysecondary"]');
keySecondaryInput.data('uid', entry.uid);
keySecondaryInput.on('input', function (_, { skipReset } = {}) {
const uid = $(this).data('uid');
const value = String($(this).val());
!skipReset && resetScrollHeight(this);
data.entries[uid].keysecondary = value
.split(',')
.map((x) => x.trim())
.filter((x) => x);
setOriginalDataValue(data, uid, 'secondary_keys', data.entries[uid].keysecondary);
saveWorldInfo(name, data);
});
keySecondaryInput.val(entry.keysecondary.join(', ')).trigger('input', { skipReset: true });
//initScrollHeight(keySecondaryInput);
// comment
const commentInput = template.find('textarea[name="comment"]');
const commentToggle = template.find('input[name="addMemo"]');
@ -1379,8 +1517,8 @@ function getWorldEntry(name, data, entry) {
if (counter.data('first-run')) {
counter.data('first-run', false);
countTokensDebounced(counter, contentInput.val());
initScrollHeight(keyInput);
initScrollHeight(keySecondaryInput);
// initScrollHeight(keyInput);
// initScrollHeight(keySecondaryInput);
}
});
@ -1403,11 +1541,11 @@ function getWorldEntry(name, data, entry) {
.closest('.world_entry')
.find('.keysecondarytextpole');
const keyprimarytextpole = $(this)
const keyprimaryselect = $(this)
.closest('.world_entry')
.find('.keyprimarytextpole');
.find('.keyprimaryselect');
const keyprimaryHeight = keyprimarytextpole.outerHeight();
const keyprimaryHeight = keyprimaryselect.outerHeight();
keysecondarytextpole.css('height', keyprimaryHeight + 'px');
value ? keysecondary.show() : keysecondary.hide();
@ -1919,7 +2057,7 @@ function createEntryInputAutocomplete(input, callback) {
$(input).autocomplete({
minLength: 0,
source: callback,
select: function (event, ui) {
select: function (_event, ui) {
$(input).val(ui.item.value).trigger('input').trigger('blur');
},
});
@ -1995,7 +2133,7 @@ const newEntryTemplate = {
role: 0,
};
function createWorldInfoEntry(name, data) {
function createWorldInfoEntry(_name, data) {
const newUid = getFreeWorldEntryUid(data);
if (!Number.isInteger(newUid)) {
@ -3069,7 +3207,7 @@ export async function importWorldInfo(file) {
toastr.info(`World Info "${data.name}" imported successfully!`);
}
},
error: (jqXHR, exception) => { },
error: (_jqXHR, _exception) => { },
});
}

View File

@ -4036,3 +4036,41 @@ body:not(.movingUI) .drawer-content.maximized {
z-index: 9999;
}
}
/* Custom class to support same line multi inputs of select2 controls */
.select2_multi_sameline+span.select2-container .select2-selection--multiple {
display: flex;
flex-wrap: wrap;
}
.select2_multi_sameline+span.select2-container .select2-selection--multiple .select2-search--inline {
flex-grow: 1; /* Allow search placeholder to take up all space if needed */
}
.select2_multi_sameline+span.select2-container .select2-selection--multiple .select2-selection__rendered {
/* Fix weird styling choice or huge margin around selected options */
margin-block-start: 2px;
margin-block-end: 2px;
}
.select2_multi_sameline+span.select2-container .select2-selection--multiple .select2-search__field {
min-height: calc(var(--mainFontSize) + 13px);
min-width: 4em;
align-content: center;
/* Fix search textarea alignment issue with UL elements */
margin-top: 0px;
height: unset;
}
.select2_multi_sameline+span.select2-container .select2-selection--multiple .select2-selection__rendered {
min-height: calc(var(--mainFontSize) + 13px);
}
/* Make search bar invisible unless the select2 is active, to save space */
.select2_multi_sameline+span.select2-container .select2-selection--multiple .select2-search--inline {
height: 1px;
}
.select2_multi_sameline+span.select2-container.select2-container--open .select2-selection--multiple .select2-search--inline {
height: unset;
}