From 33cec69df92e80b1b39e12b8b4d5ceff7a673a9f Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Thu, 23 May 2024 01:55:43 +0200 Subject: [PATCH 001/298] Add option to merge into other tag on delete --- public/scripts/tags.js | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/public/scripts/tags.js b/public/scripts/tags.js index f39e1e926..7d37c54b0 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -20,6 +20,7 @@ import { power_user } from './power-user.js'; import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; import { SlashCommand } from './slash-commands/SlashCommand.js'; import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js'; +import { isMobile } from './RossAscends-mods.js'; export { TAG_FOLDER_TYPES, @@ -1421,15 +1422,47 @@ function updateDrawTagFolder(element, tag) { indicator.css('font-size', `calc(var(--mainFontSize) * ${tagFolder.size})`); } -function onTagDeleteClick() { - if (!confirm('Are you sure?')) { +async function onTagDeleteClick() { + const id = $(this).closest('.tag_view_item').attr('id'); + const tag = tags.find(x => x.id === id); + const otherTags = sortTags(tags.filter(x => x.id !== id).map(x => ({ id: x.id, name: x.name }))); + const popupText = ` +

Delete Tag

+

${`Are you sure you want to delete the tag '${tag.name}'?`}

+

If you want to merge all references to this tag into another tag, select it below:

+ `; + const result = callPopup(popupText, 'confirm'); + + // Make the select control more fancy on not mobile + if (!isMobile()) { + // Delete the empty option in the dropdown, and make the select2 be empty by default + $('#merge_tag_select option[value=""]').remove(); + $('#merge_tag_select').select2({ + width: '50%', + placeholder: 'Select tag to merge into', + allowClear: true, + }).val(null).trigger('change'); + } + + const confirm = await result; + if (!confirm) { return; } - const id = $(this).closest('.tag_view_item').attr('id'); + const mergeTagId = $('#merge_tag_select').val() ? String($('#merge_tag_select').val()) : null; + + // Remove the tag from all entities that use it + // If we have a replacement tag, add that one instead for (const key of Object.keys(tag_map)) { - tag_map[key] = tag_map[key].filter(x => x !== id); + if (tag_map[key].includes(id)) { + tag_map[key] = tag_map[key].filter(x => x !== id); + if (mergeTagId) tag_map[key].push(mergeTagId); + } } + const index = tags.findIndex(x => x.id === id); tags.splice(index, 1); $(`.tag[id="${id}"]`).remove(); From 3a5dfadac589ecb12b24936f0f25b39f13946d15 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Thu, 23 May 2024 02:45:23 +0200 Subject: [PATCH 002/298] Fix group tag list not updating --- public/script.js | 6 ++++++ public/scripts/group-chats.js | 4 ++-- public/scripts/tags.js | 26 +++++--------------------- 3 files changed, 13 insertions(+), 23 deletions(-) diff --git a/public/script.js b/public/script.js index 4e6a6ada6..40457f6cf 100644 --- a/public/script.js +++ b/public/script.js @@ -178,6 +178,8 @@ import { tag_filter_types, compareTagsForSort, initTags, + applyTagsOnCharacterSelect, + applyTagsOnGroupSelect, } from './scripts/tags.js'; import { SECRET_KEYS, @@ -1306,6 +1308,10 @@ export async function printCharacters(fullRefresh = false) { printTagFilters(tag_filter_types.character); printTagFilters(tag_filter_types.group_member); + // We are also always reprinting the lists on character/group edit window, as these ones doesn't get updated otherwise + applyTagsOnCharacterSelect(); + applyTagsOnGroupSelect(); + const entities = getEntitiesList({ doFilter: true }); $('#rm_print_characters_pagination').pagination({ diff --git a/public/scripts/group-chats.js b/public/scripts/group-chats.js index ae0487f59..3160ce0e9 100644 --- a/public/scripts/group-chats.js +++ b/public/scripts/group-chats.js @@ -73,7 +73,7 @@ import { depth_prompt_role_default, shouldAutoContinue, } from '../script.js'; -import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map } from './tags.js'; +import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js'; import { FILTER_TYPES, FilterHelper } from './filters.js'; import { isExternalMediaAllowed } from './chats.js'; @@ -1356,7 +1356,7 @@ function select_group_chats(groupId, skipAnimation) { } // render tags - printTagList($('#groupTagList'), { forEntityOrKey: groupId, tagOptions: { removable: true } }); + applyTagsOnGroupSelect(groupId); // render characters list printGroupCandidates(); diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 7d37c54b0..0d907796b 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -1046,15 +1046,16 @@ function onGroupCreateClick() { // Nothing to do here at the moment. Tags in group interface get automatically redrawn. } -export function applyTagsOnCharacterSelect() { +export function applyTagsOnCharacterSelect(chid = null) { //clearTagsFilter(); - const chid = Number(this_chid); + chid = chid ?? Number(this_chid); printTagList($('#tagList'), { forEntityOrKey: chid, tagOptions: { removable: true } }); } -function applyTagsOnGroupSelect() { +export function applyTagsOnGroupSelect(groupId = null) { //clearTagsFilter(); - // Nothing to do here at the moment. Tags in group interface get automatically redrawn. + groupId = groupId ?? Number(selected_group); + printTagList($('#groupTagList'), { forEntityOrKey: groupId, tagOptions: { removable: true } }); } /** @@ -1596,21 +1597,6 @@ function registerTagsSlashCommands() { return tag; } - function updateTagsList() { - switch (menu_type) { - case 'characters': - printTagFilters(tag_filter_types.character); - printTagFilters(tag_filter_types.group_member); - break; - case 'character_edit': - applyTagsOnCharacterSelect(); - break; - case 'group_edit': - select_group_chats(selected_group, true); - break; - } - } - SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'tag-add', returns: 'true/false - Whether the tag was added or was assigned already', @@ -1621,7 +1607,6 @@ function registerTagsSlashCommands() { const tag = paraGetTag(tagName, { allowCreate: true }); if (!tag) return 'false'; const result = addTagToEntity(tag, key); - updateTagsList(); return String(result); }, namedArgumentList: [ @@ -1656,7 +1641,6 @@ function registerTagsSlashCommands() { const tag = paraGetTag(tagName); if (!tag) return 'false'; const result = removeTagFromEntity(tag, key); - updateTagsList(); return String(result); }, namedArgumentList: [ From 26572458b6a3120164990106903c243bcec71c31 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Thu, 23 May 2024 03:34:35 +0200 Subject: [PATCH 003/298] Do not allow same-ish tag names / allow same-ish tag search --- public/scripts/tags.js | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 0d907796b..7635d4591 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -15,7 +15,7 @@ import { import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js'; import { groupCandidatesFilter, groups, select_group_chats, selected_group } from './group-chats.js'; -import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight } from './utils.js'; +import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents } from './utils.js'; import { power_user } from './power-user.js'; import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; import { SlashCommand } from './slash-commands/SlashCommand.js'; @@ -587,10 +587,10 @@ function removeTagFromMap(tagId, characterId = null) { function findTag(request, resolve, listSelector) { const skipIds = [...($(listSelector).find('.tag').map((_, el) => $(el).attr('id')))]; - const haystack = tags.filter(t => !skipIds.includes(t.id)).map(t => t.name).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); - const needle = request.term.toLowerCase(); - const hasExactMatch = haystack.findIndex(x => x.toLowerCase() == needle) !== -1; - const result = haystack.filter(x => x.toLowerCase().includes(needle)); + const haystack = tags.filter(t => !skipIds.includes(t.id)).sort(compareTagsForSort).map(t => t.name); + const needle = request.term; + const hasExactMatch = haystack.findIndex(x => equalsIgnoreCaseAndAccents(x, needle)) !== -1; + const result = haystack.filter(x => includesIgnoreCaseAndAccents(x, needle)); if (request.term && !hasExactMatch) { result.unshift(request.term); @@ -611,7 +611,7 @@ function findTag(request, resolve, listSelector) { */ function selectTag(event, ui, listSelector, { tagListOptions = {} } = {}) { let tagName = ui.item.value; - let tag = tags.find(t => t.name === tagName); + let tag = getTag(tagName); // create new tag if it doesn't exist if (!tag) { @@ -639,8 +639,8 @@ function selectTag(event, ui, listSelector, { tagListOptions = {} } = {}) { */ function getExistingTags(new_tags) { let existing_tags = []; - for (let tag of new_tags) { - let foundTag = tags.find(t => t.name.toLowerCase() === tag.toLowerCase()); + for (let tagName of new_tags) { + let foundTag = getTag(tagName); if (foundTag) { existing_tags.push(foundTag.name); } @@ -669,7 +669,7 @@ async function importTags(imported_char) { selected_tags = selected_tags.slice(0, 15); } for (let tagName of selected_tags) { - let tag = tags.find(t => t.name === tagName); + let tag = getTag(tagName); if (!tag) { tag = createNewTag(tagName); @@ -690,13 +690,29 @@ async function importTags(imported_char) { return false; } +/** + * Gets a tag from the tags array based on the provided tag name (insensitive soft matching) + * + * @param {string} tagName - The name of the tag to search for + * @return {Tag?} The tag object that matches the provided tag name, or undefined if no match is found. + */ +function getTag(tagName) { + return tags.find(t => equalsIgnoreCaseAndAccents(t.name, tagName)); +} + /** * Creates a new tag with default properties and a randomly generated id * * @param {string} tagName - name of the tag - * @returns {Tag} + * @returns {Tag} the newly created tag, or the existing tag if it already exists (with a logged warning) */ function createNewTag(tagName) { + const existing = getTag(tagName); + if (existing) { + toastr.warning(`Cannot create new tag. A tag with the name already exists:
${existing}`, 'Creating Tag', { escapeHtml: false }); + return existing; + } + const tag = { id: uuidv4(), name: tagName, @@ -1028,7 +1044,7 @@ function onTagRemoveClick(event) { // @ts-ignore function onTagInput(event) { let val = $(this).val(); - if (tags.find(t => t.name === val)) return; + if (getTag(String(val))) return; // @ts-ignore $(this).autocomplete('search', val); } @@ -1586,7 +1602,7 @@ function registerTagsSlashCommands() { toastr.warning('Tag name must be provided.'); return null; } - let tag = tags.find(t => t.name === tagName); + let tag = getTag(tagName); if (allowCreate && !tag) { tag = createNewTag(tagName); } From d9582062d2e661aa820c087d8395948155b8f70d Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Sat, 25 May 2024 00:44:09 +0200 Subject: [PATCH 004/298] Expand popup functionality - Add "custom buttons" functionality, each with their own popup result - Handle 'Enter' by defining a default action - Using default action to style the default button to make the default action visible - Allow override of ok/cancel button on any popup type to display those - Allow multiple popups to overlay each other - Small styling changes for bottom spacing on non-input popups --- public/index.html | 8 +- public/scripts/popup.js | 172 +++++++++++++++++++++++++++++++--------- public/style.css | 21 ++++- 3 files changed, 155 insertions(+), 46 deletions(-) diff --git a/public/index.html b/public/index.html index 49dd405f0..59dfafb8c 100644 --- a/public/index.html +++ b/public/index.html @@ -4856,8 +4856,8 @@
- - + +
@@ -4871,8 +4871,8 @@
- - + +
diff --git a/public/scripts/popup.js b/public/scripts/popup.js index 17d380367..7d85ef048 100644 --- a/public/scripts/popup.js +++ b/public/scripts/popup.js @@ -1,25 +1,46 @@ import { animation_duration, animation_easing } from '../script.js'; import { delay } from './utils.js'; - - -/**@readonly*/ -/**@enum {Number}*/ +/** @readonly */ +/** @enum {Number} */ export const POPUP_TYPE = { 'TEXT': 1, 'CONFIRM': 2, 'INPUT': 3, }; -/**@readonly*/ -/**@enum {Boolean}*/ +/** @readonly */ +/** @enum {number} */ export const POPUP_RESULT = { - 'AFFIRMATIVE': true, - 'NEGATIVE': false, + 'AFFIRMATIVE': 1, + 'NEGATIVE': 0, 'CANCELLED': undefined, }; +const POPUP_START_Z_INDEX = 9998; +let currentPopupZIndex = POPUP_START_Z_INDEX; +/** + * @typedef {object} PopupOptions + * @property {string|boolean?} [okButton] - Custom text for the OK button, or `true` to use the default (If set, the button will always be displayed, no matter the type of popup) + * @property {string|boolean?} [cancelButton] - Custom text for the Cancel button, or `true` to use the default (If set, the button will always be displayed, no matter the type of popup) + * @property {number?} [rows] - The number of rows for the input field + * @property {boolean?} [wide] - Whether to display the popup in wide mode + * @property {boolean?} [large] - Whether to display the popup in large mode + * @property {boolean?} [allowHorizontalScrolling] - Whether to allow horizontal scrolling in the popup + * @property {boolean?} [allowVerticalScrolling] - Whether to allow vertical scrolling in the popup + * @property {POPUP_RESULT|number?} [defaultResult] - The default result of this popup when Enter is pressed. Can be changed from `POPUP_RESULT.AFFIRMATIVE`. + * @property {CustomPopupButton[]|string[]?} [customButtons] - Custom buttons to add to the popup. If only strings are provided, the buttons will be added with default options, and their result will be in order from `2` onward. + */ + +/** + * @typedef {object} CustomPopupButton + * @property {string} text - The text of the button + * @property {POPUP_RESULT|number?} result - The result of the button - can also be a custom result value to make be able to find out that this button was clicked. If no result is specified, this button will **not** close the popup. + * @property {string[]|string?} [classes] - Optional custom CSS classes applied to the button + * @property {()=>void?} [action] - Optional action to perform when the button is clicked + * @property {boolean?} [appendAtEnd] - Whether to append the button to the end of the popup - by default it will be prepended + */ export class Popup { /**@type {POPUP_TYPE}*/ type; @@ -39,16 +60,15 @@ export class Popup { /**@type {Function}*/ keyListenerBound; - - /** - * @typedef {{okButton?: string, cancelButton?: string, rows?: number, wide?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean }} PopupOptions - Options for the popup. - * @param {JQuery|string|Element} text - Text to display in the popup. - * @param {POPUP_TYPE} type - One of Popup.TYPE - * @param {string} inputValue - Value to set the input to. - * @param {PopupOptions} options - Options for the popup. + * Constructs a new Popup object with the given text, type, inputValue, and options + * + * @param {JQuery|string|Element} text - Text to display in the popup + * @param {POPUP_TYPE} type - The type of the popup + * @param {string} [inputValue=''] - The initial value of the input field + * @param {PopupOptions} [options={}] - Additional options for the popup */ - constructor(text, type, inputValue = '', { okButton, cancelButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling } = {}) { + constructor(text, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, large = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null } = {}) { this.type = type; /**@type {HTMLTemplateElement}*/ @@ -60,6 +80,7 @@ export class Popup { this.dlg = dlg; this.text = this.dom.querySelector('.dialogue_popup_text'); this.input = this.dom.querySelector('.dialogue_popup_input'); + this.controls = this.dom.querySelector('.dialogue_popup_controls'); this.ok = this.dom.querySelector('.dialogue_popup_ok'); this.cancel = this.dom.querySelector('.dialogue_popup_cancel'); @@ -68,24 +89,53 @@ export class Popup { if (allowHorizontalScrolling) dlg.classList.add('horizontal_scrolling_dialogue_popup'); if (allowVerticalScrolling) dlg.classList.add('vertical_scrolling_dialogue_popup'); - this.ok.textContent = okButton ?? 'OK'; - this.cancel.textContent = cancelButton ?? template.getAttribute('popup_text_cancel'); + // If custom button captions are provided, we set them beforehand + this.ok.textContent = typeof okButton === 'string' ? okButton : 'OK'; + this.cancel.textContent = typeof cancelButton === 'string' ? cancelButton : template.getAttribute('popup_text_cancel'); + + this.defaultResult = defaultResult; + this.customButtons = customButtons; + this.customButtonElements = this.customButtons?.map((x, index) => { + /** @type {CustomPopupButton} */ + const button = typeof x === 'string' ? { text: x, result: index + 2 } : x; + const buttonElement = document.createElement('button'); + + buttonElement.classList.add('menu_button', 'menu_button_custom'); + buttonElement.classList.add(...(button.classes ?? [])); + + buttonElement.textContent = button.text; + if (button.action) buttonElement.addEventListener('click', button.action); + if (button.result) buttonElement.addEventListener('click', () => this.completeCustom(button.result)); + + buttonElement.setAttribute('data-result', String(button.result ?? undefined)); + + if (button.appendAtEnd) { + this.controls.appendChild(buttonElement); + } else { + this.controls.insertBefore(buttonElement, this.ok); + } + return buttonElement; + }); + + // Set the default button class + const defaultButton = this.controls.querySelector(`[data-result="${this.defaultResult}"]`); + if (defaultButton) defaultButton.classList.add('menu_button_default'); switch (type) { case POPUP_TYPE.TEXT: { this.input.style.display = 'none'; - this.cancel.style.display = 'none'; + if (!cancelButton) this.cancel.style.display = 'none'; break; } case POPUP_TYPE.CONFIRM: { this.input.style.display = 'none'; - this.ok.textContent = okButton ?? template.getAttribute('popup_text_yes'); - this.cancel.textContent = cancelButton ?? template.getAttribute('popup_text_no'); + if (!okButton) this.ok.textContent = template.getAttribute('popup_text_yes'); + if (!cancelButton) this.cancel.textContent = template.getAttribute('popup_text_no'); break; } case POPUP_TYPE.INPUT: { this.input.style.display = 'block'; - this.ok.textContent = okButton ?? template.getAttribute('popup_text_save'); + if (!okButton) this.ok.textContent = template.getAttribute('popup_text_save'); break; } default: { @@ -107,13 +157,6 @@ export class Popup { // illegal argument } - this.input.addEventListener('keydown', (evt) => { - if (evt.key != 'Enter' || evt.altKey || evt.ctrlKey || evt.shiftKey) return; - evt.preventDefault(); - evt.stopPropagation(); - this.completeAffirmative(); - }); - this.ok.addEventListener('click', () => this.completeAffirmative()); this.cancel.addEventListener('click', () => this.completeNegative()); const keyListener = (evt) => { @@ -129,13 +172,32 @@ export class Popup { break; } } + case 'Enter': { + // Only count enter if no modifier key is pressed + if (!evt.altKey && !evt.ctrlKey && !evt.shiftKey) { + evt.preventDefault(); + evt.stopPropagation(); + this.completeCustom(this.defaultResult); + window.removeEventListener('keydown', keyListenerBound); + } + break; + } } }; const keyListenerBound = keyListener.bind(this); window.addEventListener('keydown', keyListenerBound); } + /** + * Asynchronously shows the popup element by appending it to the document body, + * setting its display to 'block' and focusing on the input if the popup type is INPUT. + * + * @returns {Promise} A promise that resolves with the value of the popup when it is completed. + */ async show() { + // Set z-index, so popups can stack "on top" of each other + this.dom.style.zIndex = String(++currentPopupZIndex); + document.body.append(this.dom); this.dom.style.display = 'block'; switch (this.type) { @@ -143,6 +205,9 @@ export class Popup { this.input.focus(); break; } + default: + this.ok.focus(); + break; } $(this.dom).transition({ @@ -201,7 +266,40 @@ export class Popup { + /** + * Completes the popup with a custom result. + * Calls into the default three delete states, if a valid `POPUP_RESULT` is provided. + * + * @param {POPUP_RESULT|number} result - The result of the custom action + */ + completeCustom(result) { + switch (result) { + case POPUP_RESULT.AFFIRMATIVE: { + this.completeAffirmative(); + break; + } + case POPUP_RESULT.NEGATIVE: { + this.completeNegative(); + break; + } + case POPUP_RESULT.CANCELLED: { + this.completeCancelled(); + break; + } + default: { + this.value = this.type === POPUP_TYPE.INPUT ? this.input.value : result; + this.result = result ? POPUP_RESULT.AFFIRMATIVE : POPUP_RESULT.NEGATIVE; + this.hide(); + break; + } + } + } + + /** + * Hides the popup, using the internal resolver to return the value to the original show promise + */ hide() { + --currentPopupZIndex; $(this.dom).transition({ opacity: 0, duration: animation_duration, @@ -215,22 +313,20 @@ export class Popup { } } - - /** - * Displays a blocking popup with a given text and type. - * @param {JQuery|string|Element} text - Text to display in the popup. + * Displays a blocking popup with a given text and type + * @param {JQuery|string|Element} text - Text to display in the popup * @param {POPUP_TYPE} type - * @param {string} inputValue - Value to set the input to. - * @param {PopupOptions} options - Options for the popup. - * @returns + * @param {string} inputValue - Value to set the input to + * @param {PopupOptions} [popupOptions={}] - Options for the popup + * @returns {Promise} The value for this popup, which can either be the popup retult or the input value if chosen */ -export function callGenericPopup(text, type, inputValue = '', { okButton, cancelButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling } = {}) { +export function callGenericPopup(text, type, inputValue = '', popupOptions = {}) { const popup = new Popup( text, type, inputValue, - { okButton, cancelButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling }, + popupOptions, ); return popup.show(); } diff --git a/public/style.css b/public/style.css index ce84975e0..9cbe98738 100644 --- a/public/style.css +++ b/public/style.css @@ -3092,6 +3092,7 @@ grammarly-extension { #dialogue_popup_controls, .dialogue_popup_controls { + margin-top: 10px; display: flex; align-self: center; gap: 20px; @@ -3100,7 +3101,8 @@ grammarly-extension { #bulk_tag_popup_reset, #bulk_tag_popup_remove_mutual, #dialogue_popup_ok, -.dialogue_popup_ok { +.dialogue_popup_ok, +.menu_button.dialogue_popup_ok { background-color: var(--crimson70a); cursor: pointer; } @@ -3108,7 +3110,8 @@ grammarly-extension { #bulk_tag_popup_reset:hover, #bulk_tag_popup_remove_mutual:hover, #dialogue_popup_ok:hover, -.dialogue_popup_ok:hover { +.dialogue_popup_ok:hover, +.menu_button.dialogue_popup_ok:hover { background-color: var(--crimson-hover); } @@ -3118,7 +3121,7 @@ grammarly-extension { #dialogue_popup_input, .dialogue_popup_input { - margin: 10px 0; + margin: 10px 0 0 0; width: 100%; } @@ -3166,6 +3169,16 @@ grammarly-extension { text-align: center; } +.menu_button.menu_button_default { + border: 1px ridge var(--white30a); + box-shadow: 0 0 5px var(--SmartThemeBorderColor); +} + +.menu_button.menu_button_custom { + /** Custom buttons should not scale to smallest size, otherwise they will always break to multiline */ + width: unset; +} + .avatar_div .menu_button, .form_create_bottom_buttons_block .menu_button { font-weight: bold; @@ -5134,4 +5147,4 @@ body:not(.movingUI) .drawer-content.maximized { color: #FAF8F6; } -/* Pastel White */ \ No newline at end of file +/* Pastel White */ From 4f2543f7ae8fbdcdbdd39231ac8dd84e13bdc6db Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Sat, 25 May 2024 01:02:13 +0200 Subject: [PATCH 005/298] Fix popup custom buttons --- public/script.js | 5 +++-- public/scripts/popup.js | 10 ++++++---- public/style.css | 4 ++++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/public/script.js b/public/script.js index 40457f6cf..beba6c862 100644 --- a/public/script.js +++ b/public/script.js @@ -7003,10 +7003,10 @@ function onScenarioOverrideRemoveClick() { * @param {string} type * @param {string} inputValue - Value to set the input to. * @param {PopupOptions} options - Options for the popup. - * @typedef {{okButton?: string, rows?: number, wide?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean, cropAspect?: number }} PopupOptions - Options for the popup. + * @typedef {{okButton?: string, rows?: number, wide?: boolean, wider?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean, cropAspect?: number }} PopupOptions - Options for the popup. * @returns */ -export function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling, cropAspect } = {}) { +export function callPopup(text, type, inputValue = '', { okButton, rows, wide, wider, large, allowHorizontalScrolling, allowVerticalScrolling, cropAspect } = {}) { function getOkButtonText() { if (['avatarToCrop'].includes(popup_type)) { return okButton ?? 'Accept'; @@ -7036,6 +7036,7 @@ export function callPopup(text, type, inputValue = '', { okButton, rows, wide, l const $shadowPopup = $('#shadow_popup'); $dialoguePopup.toggleClass('wide_dialogue_popup', !!wide) + .toggleClass('wider_dialogue_popup', !!wider) .toggleClass('large_dialogue_popup', !!large) .toggleClass('horizontal_scrolling_dialogue_popup', !!allowHorizontalScrolling) .toggleClass('vertical_scrolling_dialogue_popup', !!allowVerticalScrolling); diff --git a/public/scripts/popup.js b/public/scripts/popup.js index 7d85ef048..463435bb1 100644 --- a/public/scripts/popup.js +++ b/public/scripts/popup.js @@ -25,8 +25,9 @@ let currentPopupZIndex = POPUP_START_Z_INDEX; * @property {string|boolean?} [okButton] - Custom text for the OK button, or `true` to use the default (If set, the button will always be displayed, no matter the type of popup) * @property {string|boolean?} [cancelButton] - Custom text for the Cancel button, or `true` to use the default (If set, the button will always be displayed, no matter the type of popup) * @property {number?} [rows] - The number of rows for the input field - * @property {boolean?} [wide] - Whether to display the popup in wide mode - * @property {boolean?} [large] - Whether to display the popup in large mode + * @property {boolean?} [wide] - Whether to display the popup in wide mode (wide screen, 1/1 aspect ratio) + * @property {boolean?} [wider] - Whether to display the popup in wider mode (just wider, no height scaling) + * @property {boolean?} [large] - Whether to display the popup in large mode (90% of screen) * @property {boolean?} [allowHorizontalScrolling] - Whether to allow horizontal scrolling in the popup * @property {boolean?} [allowVerticalScrolling] - Whether to allow vertical scrolling in the popup * @property {POPUP_RESULT|number?} [defaultResult] - The default result of this popup when Enter is pressed. Can be changed from `POPUP_RESULT.AFFIRMATIVE`. @@ -68,7 +69,7 @@ export class Popup { * @param {string} [inputValue=''] - The initial value of the input field * @param {PopupOptions} [options={}] - Additional options for the popup */ - constructor(text, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, large = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null } = {}) { + constructor(text, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, wider = false, large = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null } = {}) { this.type = type; /**@type {HTMLTemplateElement}*/ @@ -85,6 +86,7 @@ export class Popup { this.cancel = this.dom.querySelector('.dialogue_popup_cancel'); if (wide) dlg.classList.add('wide_dialogue_popup'); + if (wider) dlg.classList.add('wider_dialogue_popup'); if (large) dlg.classList.add('large_dialogue_popup'); if (allowHorizontalScrolling) dlg.classList.add('horizontal_scrolling_dialogue_popup'); if (allowVerticalScrolling) dlg.classList.add('vertical_scrolling_dialogue_popup'); @@ -98,7 +100,7 @@ export class Popup { this.customButtonElements = this.customButtons?.map((x, index) => { /** @type {CustomPopupButton} */ const button = typeof x === 'string' ? { text: x, result: index + 2 } : x; - const buttonElement = document.createElement('button'); + const buttonElement = document.createElement('div'); buttonElement.classList.add('menu_button', 'menu_button_custom'); buttonElement.classList.add(...(button.classes ?? [])); diff --git a/public/style.css b/public/style.css index 9cbe98738..cd4b7488b 100644 --- a/public/style.css +++ b/public/style.css @@ -3065,6 +3065,10 @@ grammarly-extension { min-width: var(--sheldWidth); } +.wider_dialogue_popup { + min-width: 750px; +} + .horizontal_scrolling_dialogue_popup { overflow-x: unset !important; } From 35e21c3568c7b00840aa12b621460e043944b9d6 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Sun, 26 May 2024 20:29:50 +0200 Subject: [PATCH 006/298] WIP: Rework import tags popup for more options - Rework "import tags" dialog, providing options which tags to import, and rendering the tags there, for manual management - Refactor tag list function to allow custom remove actions - Refactor functions to allow adding of multiple tags at once --- public/css/st-tailwind.css | 12 ++ public/scripts/tags.js | 291 +++++++++++++++++++++++++++---------- 2 files changed, 227 insertions(+), 76 deletions(-) diff --git a/public/css/st-tailwind.css b/public/css/st-tailwind.css index 5edd3059d..8532a90df 100644 --- a/public/css/st-tailwind.css +++ b/public/css/st-tailwind.css @@ -292,6 +292,14 @@ flex-wrap: nowrap; } +.inline-flex { + display: inline-flex; +} + +.inline-block { + display: inline-block; +} + .alignitemscenter, .alignItemsCenter { align-items: center; @@ -348,6 +356,10 @@ margin-right: 5px; } +.margin-r2 { + margin-right: 2px; +} + .flex0 { flex: 0; } diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 7635d4591..34e55e9e4 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -4,7 +4,6 @@ import { this_chid, callPopup, menu_type, - getCharacters, entitiesFilter, printCharactersDebounced, buildAvatarList, @@ -15,12 +14,13 @@ import { import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js'; import { groupCandidatesFilter, groups, select_group_chats, selected_group } from './group-chats.js'; -import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents } from './utils.js'; +import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents, removeFromArray } from './utils.js'; import { power_user } from './power-user.js'; import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; import { SlashCommand } from './slash-commands/SlashCommand.js'; import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js'; import { isMobile } from './RossAscends-mods.js'; +import { POPUP_TYPE, callGenericPopup } from './popup.js'; export { TAG_FOLDER_TYPES, @@ -45,6 +45,8 @@ export { removeTagFromMap, }; +/** @typedef {import('../script.js').Character} Character */ + const CHARACTER_FILTER_SELECTOR = '#rm_characters_block .rm_tag_filter'; const GROUP_FILTER_SELECTOR = '#rm_group_chats_block .rm_tag_filter'; const TAG_TEMPLATE = $('#tag_template .tag'); @@ -467,29 +469,34 @@ export function getTagKeyForEntityElement(element) { } /** - * Adds a tag to a given entity - * @param {Tag} tag - The tag to add - * @param {string|string[]} entityId - The entity to add this tag to. Has to be the entity key (e.g. `addTagToEntity`). (Also allows multiple entities to be passed in) + * Adds one or more tags to a given entity + * + * @param {Tag|Tag[]} tag - The tag or tags to add + * @param {string|string[]} entityId - The entity or entities to add this tag to. Has to be the entity key (e.g. `addTagToEntity`). * @param {object} [options={}] - Optional arguments * @param {JQuery|string?} [options.tagListSelector=null] - An optional selector if a specific list should be updated with the new tag too (for example because the add was triggered for that function) * @param {PrintTagListOptions} [options.tagListOptions] - Optional parameters for printing the tag list. Can be set to be consistent with the expected behavior of tags in the list that was defined before. * @returns {boolean} Whether at least one tag was added */ -export function addTagToEntity(tag, entityId, { tagListSelector = null, tagListOptions = {} } = {}) { +export function addTagsToEntity(tag, entityId, { tagListSelector = null, tagListOptions = {} } = {}) { + const tags = Array.isArray(tag) ? tag : [tag]; + const entityIds = Array.isArray(entityId) ? entityId : [entityId]; + let result = false; + // Add tags to the map - if (Array.isArray(entityId)) { - entityId.forEach((id) => result = addTagToMap(tag.id, id) || result); - } else { - result = addTagToMap(tag.id, entityId); - } + entityIds.forEach((id) => { + tags.forEach((tag) => { + result = addTagToMap(tag.id, id) || result; + }); + }); // Save and redraw printCharactersDebounced(); saveSettingsDebounced(); // We should manually add the selected tag to the print tag function, so we cover places where the tag list did not automatically include it - tagListOptions.addTag = tag; + tagListOptions.addTag = tags; // add tag to the UI and internal map - we reprint so sorting and new markup is done correctly if (tagListSelector) printTagList(tagListSelector, tagListOptions); @@ -625,7 +632,7 @@ function selectTag(event, ui, listSelector, { tagListOptions = {} } = {}) { const characterData = event.target.closest('#bulk_tags_div')?.dataset.characters; const characterIds = characterData ? JSON.parse(characterData).characterIds : null; - addTagToEntity(tag, characterIds, { tagListSelector: listSelector, tagListOptions: tagListOptions }); + addTagsToEntity(tag, characterIds, { tagListSelector: listSelector, tagListOptions: tagListOptions }); // need to return false to keep the input clear return false; @@ -634,75 +641,173 @@ function selectTag(event, ui, listSelector, { tagListOptions = {} } = {}) { /** * Get a list of existing tags matching a list of provided new tag names * - * @param {string[]} new_tags - A list of strings representing tag names - * @returns List of existing tags + * @param {string[]} newTags - A list of strings representing tag names + * @returns {Tag[]} List of existing tags */ -function getExistingTags(new_tags) { - let existing_tags = []; - for (let tagName of new_tags) { +function getExistingTags(newTags) { + let existingTags = []; + for (let tagName of newTags) { let foundTag = getTag(tagName); if (foundTag) { - existing_tags.push(foundTag.name); + existingTags.push(foundTag); } } - return existing_tags; + return existingTags; } -async function importTags(imported_char) { - let imported_tags = imported_char.tags.filter(t => t !== 'ROOT' && t !== 'TAVERN'); - let existingTags = await getExistingTags(imported_tags); - //make this case insensitive - let newTags = imported_tags.filter(t => !existingTags.some(existingTag => existingTag.toLowerCase() === t.toLowerCase())); - let selected_tags = ''; - const existingTagsString = existingTags.length ? (': ' + existingTags.join(', ')) : ''; - if (newTags.length === 0) { - await callPopup(`

Importing Tags For ${imported_char.name}

${existingTags.length} existing tags have been found${existingTagsString}.

`, 'text'); - } else { - selected_tags = await callPopup(`

Importing Tags For ${imported_char.name}

${existingTags.length} existing tags have been found${existingTagsString}.

The following ${newTags.length} new tags will be imported.

`, 'input', newTags.join(', ')); - } - // @ts-ignore - selected_tags = existingTags.concat(selected_tags.split(',')); - // @ts-ignore - selected_tags = selected_tags.map(t => t.trim()).filter(t => t !== ''); - //Anti-troll measure - if (selected_tags.length > 15) { - selected_tags = selected_tags.slice(0, 15); - } - for (let tagName of selected_tags) { - let tag = getTag(tagName); +const tagImportSettings = { + ALWAYS_IMPORT_ALL: 1, + ONLY_IMPORT_EXISTING: 2, + IMPORT_NONE: 3, + ASK: 4 +}; - if (!tag) { - tag = createNewTag(tagName); - } +let globalTagImportSetting = tagImportSettings.ASK; // Default setting - if (!tag_map[imported_char.avatar].includes(tag.id)) { - tag_map[imported_char.avatar].push(tag.id); - console.debug('added tag to map', tag, imported_char.name); - } +const IMPORT_EXLCUDED_TAGS = ['ROOT', 'TAVERN']; +const ANTI_TROLL_MAX_TAGS = 15; + +/** + * Imports tags for a given character + * + * @param {Character} character - The character + * @returns {Promise} Boolean indicating whether any tag was imported + */ +async function importTags(character) { + // Gather the tags to import based on the selected setting + const tagNamesToImport = await handleTagImport(character); + if (!tagNamesToImport?.length) { + toastr.info('No tags imported', 'Importing Tags'); + return; } - saveSettingsDebounced(); + const tagsToImport = tagNamesToImport.map(tag => getTag(tag, { createNew: true })); + const added = addTagsToEntity(tagsToImport, character.avatar); - // Await the character list, which will automatically reprint it and all tag filters - await getCharacters(); + toastr.success(`Imported tags:
${tagsToImport.map(x => x.name).join(', ')}`, 'Importing Tags', { escapeHtml: false }); - // need to return false to keep the input clear - return false; + return added; +} + +/** + * Handles the import of tags for a given character and returns the resulting list of tags to add + * + * @param {Character} character - The character + * @returns {Promise} Array of strings representing the tags to import + */ +async function handleTagImport(character) { + /** @type {string[]} */ + const importTags = character.tags.map(t => t.trim()).filter(t => t) + .filter(t => !IMPORT_EXLCUDED_TAGS.includes(t)) + .slice(0, ANTI_TROLL_MAX_TAGS); + const existingTags = getExistingTags(importTags); + const newTags = importTags.filter(t => !existingTags.some(existingTag => existingTag.name.toLowerCase() === t.toLowerCase())) + .map(newTag); + + switch (globalTagImportSetting) { + case tagImportSettings.ALWAYS_IMPORT_ALL: + return existingTags.concat(newTags).map(t => t.name); + case tagImportSettings.ONLY_IMPORT_EXISTING: + return existingTags.map(t => t.name); + case tagImportSettings.ASK: + return await showTagImportPopup(character, existingTags, newTags); + case tagImportSettings.IMPORT_NONE: + default: + return []; + } +} + +/** + * Shows a popup to import tags for a given character and returns the resulting list of tags to add + * + * @param {Character} character - The character + * @param {Tag[]} existingTags - List of existing tags + * @param {Tag[]} newTags - List of new tags + * @returns {Promise} Array of strings representing the tags to import + */ +async function showTagImportPopup(character, existingTags, newTags) { + /** @type {{[key: string]: import('./popup.js').CustomPopupButton}} */ + const importButtons = { + EXISTING: { result: 2, text: 'Import Existing' }, + ALL: { result: 3, text: 'Import All' }, + NONE: { result: 4, text: 'Import None' }, + } + + const customButtonsCaptions = Object.values(importButtons).map(button => `"${button.text}"`); + const customButtonsString = customButtonsCaptions.slice(0, -1).join(', ') + ' or ' + customButtonsCaptions.slice(-1); + + const popupContent = $(` +

Import Tags For ${character.name}

+
+
+ + Click remove on any tag to remove it from this import.
+ Select one of the import options to finish importing the tags. +
+ +

Existing Tags

+
+ +

New Tags

+
+ + + + +
`); + + // Print tags after popup is shown, so that events can be added + printTagList(popupContent.find('#import_existing_tags_list'), { tags: existingTags, tagOptions: { removable: true, removeAction: tag => removeFromArray(existingTags, tag) } }); + printTagList(popupContent.find('#import_new_tags_list'), { tags: newTags, tagOptions: { removable: true, removeAction: tag => removeFromArray(newTags, tag) } }); + + const result = await callGenericPopup(popupContent, POPUP_TYPE.TEXT, null, { wider: true, okButton: 'Import', cancelButton: true, customButtons: Object.values(importButtons) }); + if (!result) { + return []; + } + + switch (result) { + case 1: + case true: + case importButtons.ALL.result: // Default 'Import' option where it imports all selected + return existingTags.concat(newTags).map(t => t.name); + case importButtons.EXISTING.result: + return existingTags.map(t => t.name); + case importButtons.NONE.result: + default: + return []; + } } /** * Gets a tag from the tags array based on the provided tag name (insensitive soft matching) + * Optionally creates the tag if it doesn't exist * * @param {string} tagName - The name of the tag to search for - * @return {Tag?} The tag object that matches the provided tag name, or undefined if no match is found. + * @param {object} [options={}] - Optional parameters + * @param {boolean} [options.createNew=false] - Whether to create the tag if it doesn't exist + * @returns {Tag?} The tag object that matches the provided tag name, or undefined if no match is found */ -function getTag(tagName) { - return tags.find(t => equalsIgnoreCaseAndAccents(t.name, tagName)); +function getTag(tagName, { createNew = false } = {}) { + let tag = tags.find(t => equalsIgnoreCaseAndAccents(t.name, tagName)); + if (!tag && createNew) { + tag = createNewTag(tagName); + } + return tag; } /** * Creates a new tag with default properties and a randomly generated id * + * Does **not** trigger a save, so it's up to the caller to do that + * * @param {string} tagName - name of the tag * @returns {Tag} the newly created tag, or the existing tag if it already exists (with a logged warning) */ @@ -713,7 +818,23 @@ function createNewTag(tagName) { return existing; } - const tag = { + const tag = newTag(tagName); + tags.push(tag); + console.debug('Created new tag', tag.name, 'with id', tag.id); + return tag; +} + +/** + * Creates a new tag object with the given tag name and default properties + * + * Not to be confused with `createNewTag`, which actually creates the tag and adds it to the existing list of tags. + * Use this one to create temporary tag objects, for example for drawing. + * + * @param {string} tagName - The name of the tag + * @return {Tag} The newly created tag object + */ +function newTag(tagName) { + return { id: uuidv4(), name: tagName, folder_type: TAG_FOLDER_DEFAULT_TYPE, @@ -723,9 +844,6 @@ function createNewTag(tagName) { color2: '', create_date: Date.now(), }; - tags.push(tag); - console.debug('Created new tag', tag.name, 'with id', tag.id); - return tag; } /** @@ -733,6 +851,7 @@ function createNewTag(tagName) { * @property {boolean} [removable=false] - Whether tags can be removed. * @property {boolean} [selectable=false] - Whether tags can be selected. * @property {function} [action=undefined] - Action to perform on tag interaction. + * @property {(tag: Tag)=>boolean} [removeAction=undefined] - Action to perform on tag removal instead of the default remove action. If the action returns false, the tag will not be removed. * @property {boolean} [isGeneralList=false] - If true, indicates that this is the general list of tags. * @property {boolean} [skipExistsCheck=false] - If true, the tag gets added even if a tag with the same id already exists. */ @@ -740,7 +859,7 @@ function createNewTag(tagName) { /** * @typedef {object} PrintTagListOptions - Optional parameters for printing the tag list. * @property {Tag[]|function(): Tag[]} [tags=undefined] - Optional override of tags that should be printed. Those will not be sorted. If no supplied, tags for the relevant character are printed. Can also be a function that returns the tags. - * @property {Tag} [addTag=undefined] - Optionally provide a tag that should be manually added to this print. Either to the overriden tag list or the found tags based on the entity/key. Will respect the tag exists check. + * @property {Tag|Tag[]} [addTag=undefined] - Optionally provide one or multiple tags that should be manually added to this print. Either to the overriden tag list or the found tags based on the entity/key. Will respect the tag exists check. * @property {object|number|string} [forEntityOrKey=undefined] - Optional override for the chosen entity, otherwise the currently selected is chosen. Can be an entity with id property (character, group, tag), or directly an id or tag key. * @property {boolean|string} [empty=true] - Whether the list should be initially empty. If a string string is provided, 'always' will always empty the list, otherwise it'll evaluate to a boolean. * @property {boolean} [sort=true] - Whether the tags should be sorted via the sort function, or kept as is. @@ -764,8 +883,9 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity $element.empty(); } - if (addTag && (tagOptions.skipExistsCheck || !printableTags.some(x => x.id === addTag.id))) { - printableTags = [...printableTags, addTag]; + if (addTag) { + const addTags = Array.isArray(addTag) ? addTag : [addTag]; + printableTags = printableTags.concat(addTags.filter(tag => tagOptions.skipExistsCheck || !printableTags.some(t => t.id === tag.id))); } // one last sort, because we might have modified the tag list or manually retrieved it from a function @@ -849,7 +969,7 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity * @param {TagOptions} [options={}] - Options for tag behavior * @returns {void} */ -function appendTagToList(listElement, tag, { removable = false, selectable = false, action = undefined, isGeneralList = false, skipExistsCheck = false } = {}) { +function appendTagToList(listElement, tag, { removable = false, selectable = false, action = undefined, removeAction = undefined, isGeneralList = false, skipExistsCheck = false } = {}) { if (!listElement) { return; } @@ -867,6 +987,13 @@ function appendTagToList(listElement, tag, { removable = false, selectable = fal tagElement.find('.tag_name').text(tag.name); const removeButton = tagElement.find('.tag_remove'); removable ? removeButton.show() : removeButton.hide(); + if (removable && removeAction) { + tagElement.attr('custom-remove-action', String(true)); + removeButton.on('click', () => { + const result = removeAction(tag); + if (result !== false) tagElement.remove(); + }); + } if (tag.class) { tagElement.addClass(tag.class); @@ -1025,6 +1152,12 @@ function onTagRemoveClick(event) { const tagElement = $(this).closest('.tag'); const tagId = tagElement.attr('id'); + // If we have a custom remove action, we are not executing anything here in the default handler + if (tagElement.attr('custom-remove-action')) { + console.debug('Custom remove action', tagId); + return; + } + // Check if we are inside the drilldown. If so, we call remove on the bogus folder if ($(this).closest('.rm_tag_bogus_drilldown').length > 0) { console.debug('Bogus drilldown remove', tagId); @@ -1135,9 +1268,9 @@ function onViewTagsListClick() { const tagContainer = $('
'); html.append(tagContainer); - callPopup(html, 'text', null, { allowVerticalScrolling: true }); + const result = callGenericPopup(html, POPUP_TYPE.TEXT, null, { allowVerticalScrolling: true }); - printViewTagList(); + printViewTagList(html); makeTagListDraggable(tagContainer); $('#dialogue_popup .tag-color').on('change', (evt) => onTagColorize(evt)); @@ -1443,15 +1576,19 @@ async function onTagDeleteClick() { const id = $(this).closest('.tag_view_item').attr('id'); const tag = tags.find(x => x.id === id); const otherTags = sortTags(tags.filter(x => x.id !== id).map(x => ({ id: x.id, name: x.name }))); - const popupText = ` + + const popupContent = $(`

Delete Tag

-

${`Are you sure you want to delete the tag '${tag.name}'?`}

-

If you want to merge all references to this tag into another tag, select it below:

+
Do you want to delete the tag
?
+
If you want to merge all references to this tag into another tag, select it below:
`; - const result = callPopup(popupText, 'confirm'); + `); + + appendTagToList(popupContent.find('#tag_to_delete'), tag); + + const result = callGenericPopup(popupContent, POPUP_TYPE.CONFIRM); // Make the select control more fancy on not mobile if (!isMobile()) { @@ -1485,6 +1622,8 @@ async function onTagDeleteClick() { $(`.tag[id="${id}"]`).remove(); $(`.tag_view_item[id="${id}"]`).remove(); + toastr.success(`'${tag.name}' deleted${mergeTagId ? ` and merged into '${tags.find(x => x.id === mergeTagId).name}'` : ''}`, 'Delete Tag'); + printCharactersDebounced(); saveSettingsDebounced(); } @@ -1562,8 +1701,8 @@ function copyTags(data) { tag_map[data.newAvatar] = Array.from(new Set([...prevTagMap, ...newTagMap])); } -function printViewTagList(empty = true) { - const tagContainer = $('#dialogue_popup .tag_view_list_tags'); +function printViewTagList(html, empty = true) { + const tagContainer = html.find('.tag_view_list_tags'); if (empty) tagContainer.empty(); const everything = Object.values(tag_map).flat(); @@ -1622,7 +1761,7 @@ function registerTagsSlashCommands() { if (!key) return 'false'; const tag = paraGetTag(tagName, { allowCreate: true }); if (!tag) return 'false'; - const result = addTagToEntity(tag, key); + const result = addTagsToEntity(tag, key); return String(result); }, namedArgumentList: [ From 24224dc0b1acd5841ef117ed2ffe3a48dcdb29a7 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 27 May 2024 03:35:03 +0200 Subject: [PATCH 007/298] Fix and improve more tag popups - Rework tag color pickers to... actually work without hacks - Color picker default to main text color and tag default background. If default color is chosen, sets "empty" in tag, for possible style changes - Fix tabbing on tag name in tag view list being broken - Unique names on new tag click - Several fixes on tags popups - Animation utility functions (for popup, heh) - Utility function to get free (unique) name --- public/scripts/tags.js | 105 ++++++++++++++++++++++++---------------- public/scripts/utils.js | 62 ++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 43 deletions(-) diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 34e55e9e4..249dd892b 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -14,13 +14,13 @@ import { import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js'; import { groupCandidatesFilter, groups, select_group_chats, selected_group } from './group-chats.js'; -import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents, removeFromArray } from './utils.js'; +import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents, removeFromArray, getFreeName } from './utils.js'; import { power_user } from './power-user.js'; import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; import { SlashCommand } from './slash-commands/SlashCommand.js'; import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js'; import { isMobile } from './RossAscends-mods.js'; -import { POPUP_TYPE, callGenericPopup } from './popup.js'; +import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from './popup.js'; export { TAG_FOLDER_TYPES, @@ -330,7 +330,7 @@ function filterByFolder(filterHelper) { if (!power_user.bogus_folders) { $('#bogus_folders').prop('checked', true).trigger('input'); onViewTagsListClick(); - flashHighlight($('#dialogue_popup .tag_as_folder, #dialogue_popup .tag_folder_indicator')); + flashHighlight($('#tag_view_list .tag_as_folder, #tag_view_list .tag_folder_indicator')); return; } @@ -814,7 +814,7 @@ function getTag(tagName, { createNew = false } = {}) { function createNewTag(tagName) { const existing = getTag(tagName); if (existing) { - toastr.warning(`Cannot create new tag. A tag with the name already exists:
${existing}`, 'Creating Tag', { escapeHtml: false }); + toastr.warning(`Cannot create new tag. A tag with the name already exists:
${existing.name}`, 'Creating Tag', { escapeHtml: false }); return existing; } @@ -1225,9 +1225,7 @@ export function createTagInput(inputSelector, listSelector, tagListOptions = {}) .focus(onTagInputFocus); // <== show tag list on click } -function onViewTagsListClick() { - const popup = $('#dialogue_popup'); - popup.addClass('large_dialogue_popup'); +async function onViewTagsListClick() { const html = $(document.createElement('div')); html.attr('id', 'tag_view_list'); html.append(` @@ -1268,13 +1266,10 @@ function onViewTagsListClick() { const tagContainer = $('
'); html.append(tagContainer); - const result = callGenericPopup(html, POPUP_TYPE.TEXT, null, { allowVerticalScrolling: true }); - - printViewTagList(html); + printViewTagList(tagContainer); makeTagListDraggable(tagContainer); - $('#dialogue_popup .tag-color').on('change', (evt) => onTagColorize(evt)); - $('#dialogue_popup .tag-color2').on('change', (evt) => onTagColorize2(evt)); + await callGenericPopup(html, POPUP_TYPE.TEXT, null, { allowVerticalScrolling: true }); } /** @@ -1336,7 +1331,7 @@ function makeTagListDraggable(tagContainer) { // If tags were dragged manually, we have to disable auto sorting if (power_user.auto_sort_tags) { power_user.auto_sort_tags = false; - $('#dialogue_popup input[name="auto_sort_tags"]').prop('checked', false); + $('#tag_view_list input[name="auto_sort_tags"]').prop('checked', false); toastr.info('Automatic sorting of tags deactivated.'); } @@ -1463,7 +1458,7 @@ async function onTagRestoreFileSelect(e) { printCharactersDebounced(); saveSettingsDebounced(); - onViewTagsListClick(); + await onViewTagsListClick(); } function onBackupRestoreClick() { @@ -1485,14 +1480,18 @@ function onTagsBackupClick() { } function onTagCreateClick() { - const tag = createNewTag('New Tag'); - printViewTagList(); + const tagName = getFreeName('New Tag', tags.map(x => x.name)); + const tag = createNewTag(tagName); + printViewTagList($('#tag_view_list .tag_view_list_tags')); - const tagElement = ($('#dialogue_popup .tag_view_list_tags')).find(`.tag_view_item[id="${tag.id}"]`); + const tagElement = ($('#tag_view_list .tag_view_list_tags')).find(`.tag_view_item[id="${tag.id}"]`); + tagElement[0]?.scrollIntoView(); flashHighlight(tagElement); printCharactersDebounced(); saveSettingsDebounced(); + + toastr.success('Tag created', 'Create Tag', { showDuration: 60000 }); } function appendViewTagToList(list, tag, everything) { @@ -1516,25 +1515,23 @@ function appendViewTagToList(list, tag, everything) { const primaryColorPicker = $('') .addClass('tag-color') - .attr({ id: colorPickerId, color: tag.color }); + .attr({ id: colorPickerId, color: tag.color || 'rgba(0, 0, 0, 0.3)', 'data-default-color': 'rgba(0, 0, 0, 0.3)' }); const secondaryColorPicker = $('') .addClass('tag-color2') - .attr({ id: colorPicker2Id, color: tag.color2 }); + .attr({ id: colorPicker2Id, color: tag.color2 || power_user.main_text_color, 'data-default-color': power_user.main_text_color }); template.find('.tagColorPickerHolder').append(primaryColorPicker); template.find('.tagColorPicker2Holder').append(secondaryColorPicker); template.find('.tag_as_folder').attr('id', tagAsFolderId); + primaryColorPicker.on('change', (evt) => onTagColorize(evt)); + secondaryColorPicker.on('change', (evt) => onTagColorize2(evt)); + list.append(template); updateDrawTagFolder(template, tag); - - // @ts-ignore - $(colorPickerId).color = tag.color; - // @ts-ignore - $(colorPicker2Id).color = tag.color2; } function onTagAsFolderClick() { @@ -1588,21 +1585,19 @@ async function onTagDeleteClick() { appendTagToList(popupContent.find('#tag_to_delete'), tag); - const result = callGenericPopup(popupContent, POPUP_TYPE.CONFIRM); - // Make the select control more fancy on not mobile if (!isMobile()) { // Delete the empty option in the dropdown, and make the select2 be empty by default - $('#merge_tag_select option[value=""]').remove(); - $('#merge_tag_select').select2({ + popupContent.find('#merge_tag_select option[value=""]').remove(); + popupContent.find('#merge_tag_select').select2({ width: '50%', placeholder: 'Select tag to merge into', allowClear: true, }).val(null).trigger('change'); } - const confirm = await result; - if (!confirm) { + const result = await callGenericPopup(popupContent, POPUP_TYPE.CONFIRM); + if (result !== POPUP_RESULT.AFFIRMATIVE) { return; } @@ -1633,14 +1628,22 @@ function onTagRenameInput() { const newName = $(this).text(); const tag = tags.find(x => x.id === id); tag.name = newName; + $(this).attr('dirty', ''); $(`.tag[id="${id}"] .tag_name`).text(newName); saveSettingsDebounced(); } function onTagColorize(evt) { console.debug(evt); + const isDefaultColor = $(evt.target).data('default-color') === evt.detail.rgba; + + if (evt.detail.rgba === evt.detail.color.originalInput) + return; + const id = $(evt.target).closest('.tag_view_item').attr('id'); - const newColor = evt.detail.rgba; + let newColor = evt.detail.rgba; + if (isDefaultColor) newColor = ''; + $(evt.target).parent().parent().find('.tag_view_name').css('background-color', newColor); $(`.tag[id="${id}"]`).css('background-color', newColor); $(`.bogus_folder_select[tagid="${id}"] .avatar`).css('background-color', newColor); @@ -1652,8 +1655,17 @@ function onTagColorize(evt) { function onTagColorize2(evt) { console.debug(evt); + if (evt.detail.rgba === evt.detail.color.originalInput) + return; + const id = $(evt.target).closest('.tag_view_item').attr('id'); - const newColor = evt.detail.rgba; + let newColor = evt.detail.rgba; + + // If new color is same as "data-default-color", we set it to empty string + const defaultColor = $(evt.target).data('default-color'); + if (newColor === defaultColor) newColor = ''; + + $(evt.target).parent().parent().find('.tag_view_name').css('color', newColor); $(`.tag[id="${id}"]`).css('color', newColor); $(`.bogus_folder_select[tagid="${id}"] .avatar`).css('color', newColor); @@ -1701,9 +1713,7 @@ function copyTags(data) { tag_map[data.newAvatar] = Array.from(new Set([...prevTagMap, ...newTagMap])); } -function printViewTagList(html, empty = true) { - const tagContainer = html.find('.tag_view_list_tags'); - +function printViewTagList(tagContainer, empty = true) { if (empty) tagContainer.empty(); const everything = Object.values(tag_map).flat(); const sortedTags = sortTags(tags); @@ -1901,22 +1911,31 @@ export function initTags() { eventSource.on(event_types.CHARACTER_DUPLICATED, copyTags); eventSource.makeFirst(event_types.CHAT_CHANGED, () => selected_group ? applyTagsOnGroupSelect() : applyTagsOnCharacterSelect()); - $(document).on('input', '#dialogue_popup input[name="auto_sort_tags"]', (evt) => { + $(document).on('input', '#tag_view_list input[name="auto_sort_tags"]', (evt) => { const toggle = $(evt.target).is(':checked'); toggleAutoSortTags(evt.originalEvent, toggle); - printViewTagList(); + printViewTagList($('#tag_view_list .tag_view_list_tags')); }); - $(document).on('focusout', '#dialogue_popup .tag_view_name', (evt) => { + $(document).on('focusout', '#tag_view_list .tag_view_name', (evt) => { + // Reorder/reprint tags, but only if the name actually has changed, and only if we auto sort tags + if (!power_user.auto_sort_tags || !$(evt.target).is('[dirty]')) return; + // Remember the order, so we can flash highlight if it changed after reprinting - const tagId = $(evt.target).parent('.tag_view_item').attr('id'); - const oldOrder = $('#dialogue_popup .tag_view_item').map((_, el) => el.id).get(); + const tagId = ($(evt.target).closest('.tag_view_item')).attr('id'); + const oldOrder = $('#tag_view_list .tag_view_item').map((_, el) => el.id).get(); - printViewTagList(); + printViewTagList($('#tag_view_list .tag_view_list_tags')); - const newOrder = $('#dialogue_popup .tag_view_item').map((_, el) => el.id).get(); + // If the new focus would've been inside the now redrawn tag list, we should at least move back the focus to the current name + // Otherwise tab-navigation gets a bit weird + if (evt.relatedTarget instanceof HTMLElement && $(evt.relatedTarget).closest('#tag_view_list')) { + $(`#tag_view_list .tag_view_item[id="${tagId}"] .tag_view_name`)[0]?.focus(); + } + + const newOrder = $('#tag_view_list .tag_view_item').map((_, el) => el.id).get(); const orderChanged = !oldOrder.every((id, index) => id === newOrder[index]); if (orderChanged) { - flashHighlight($(`#dialogue_popup .tag_view_item[id="${tagId}"]`)); + flashHighlight($(`#tag_view_list .tag_view_item[id="${tagId}"]`)); } }); diff --git a/public/scripts/utils.js b/public/scripts/utils.js index 516a70412..6928c793b 100644 --- a/public/scripts/utils.js +++ b/public/scripts/utils.js @@ -74,6 +74,20 @@ export function onlyUnique(value, index, array) { return array.indexOf(value) === index; } +/** + * Removes the first occurrence of a specified item from an array + * + * @param {*[]} array - The array from which to remove the item + * @param {*} item - The item to remove from the array + * @returns {boolean} - Returns true if the item was successfully removed, false otherwise. + */ +export function removeFromArray(array, item) { + const index = array.indexOf(item); + if (index === -1) return false; + array.splice(index, 1); + return true; +} + /** * Checks if a string only contains digits. * @param {string} str The string to check. @@ -1499,6 +1513,35 @@ export function flashHighlight(element, timespan = 2000) { setTimeout(() => element.removeClass('flash animated'), timespan); } +/** + * Checks if the given control has an animation applied to it + * + * @param {HTMLElement} control - The control element to check for animation + * @returns {boolean} Whether the control has an animation applied + */ +export function hasAnimation(control) { + const animatioName = getComputedStyle(control, null)["animation-name"]; + return animatioName != "none"; +} + +/** + * Run an action once an animation on a control ends. If the control has no animation, the action will be executed immediately. + * + * @param {HTMLElement} control - The control element to listen for animation end event + * @param {(control:*?) => void} callback - The callback function to be executed when the animation ends + */ +export function runAfterAnimation(control, callback) { + if (hasAnimation(control)) { + const onAnimationEnd = () => { + control.removeEventListener('animationend', onAnimationEnd); + callback(control); + }; + control.addEventListener('animationend', onAnimationEnd); + } else { + callback(control); + } +} + /** * A common base function for case-insensitive and accent-insensitive string comparisons. * @@ -1755,3 +1798,22 @@ export async function checkOverwriteExistingData(type, existingNames, name, { in return true; } + +/** + * Generates a free name by appending a counter to the given name if it already exists in the list + * + * @param {string} name - The original name to check for existence in the list + * @param {string[]} list - The list of names to check for existence + * @param {(n: number) => string} [numberFormatter=(n) => ` #${n}`] - The function used to format the counter + * @returns {string} The generated free name + */ +export function getFreeName(name, list, numberFormatter = (n) => ` #${n}`) { + if (!list.includes(name)) { + return name; + } + let counter = 1; + while (list.includes(`${name} #${counter}`)) { + counter++; + } + return `${name}${numberFormatter(counter)}`; +} From 311fb261a4f49c23998c9ce72d664b43df12332c Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 27 May 2024 05:02:00 +0200 Subject: [PATCH 008/298] Allow re-linking tag colors to theme - Add button to link tag color back to theme color, but explicitly setting it to empty again - Debounce redrawing of tag color for performance --- public/css/tags.css | 11 +++++++ public/scripts/tags.js | 66 ++++++++++++++++++++---------------------- 2 files changed, 43 insertions(+), 34 deletions(-) diff --git a/public/css/tags.css b/public/css/tags.css index f9896d992..1912a541b 100644 --- a/public/css/tags.css +++ b/public/css/tags.css @@ -27,6 +27,17 @@ flex: 1; } +.tag_view_color_picker { + position: relative; +} + +.tag_view_color_picker .link_icon { + position: absolute; + top: 50%; + right: 0px; + opacity: 0.5; +} + .tag_delete { padding-right: 0; color: var(--SmartThemeBodyColor) !important; diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 249dd892b..493d91c69 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -14,13 +14,14 @@ import { import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js'; import { groupCandidatesFilter, groups, select_group_chats, selected_group } from './group-chats.js'; -import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents, removeFromArray, getFreeName } from './utils.js'; +import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents, removeFromArray, getFreeName, debounce } from './utils.js'; import { power_user } from './power-user.js'; import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; import { SlashCommand } from './slash-commands/SlashCommand.js'; import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js'; import { isMobile } from './RossAscends-mods.js'; import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from './popup.js'; +import { debounce_timeout } from './constants.js'; export { TAG_FOLDER_TYPES, @@ -1521,13 +1522,21 @@ function appendViewTagToList(list, tag, everything) { .addClass('tag-color2') .attr({ id: colorPicker2Id, color: tag.color2 || power_user.main_text_color, 'data-default-color': power_user.main_text_color }); - template.find('.tagColorPickerHolder').append(primaryColorPicker); - template.find('.tagColorPicker2Holder').append(secondaryColorPicker); + template.find('.tag_view_color_picker[data-value="color"]').append(primaryColorPicker) + .append($('')); + template.find('.tag_view_color_picker[data-value="color2"]').append(secondaryColorPicker) + .append($('')); template.find('.tag_as_folder').attr('id', tagAsFolderId); - primaryColorPicker.on('change', (evt) => onTagColorize(evt)); - secondaryColorPicker.on('change', (evt) => onTagColorize2(evt)); + primaryColorPicker.on('change', (evt) => onTagColorize(evt, (tag, color) => tag.color = color, 'background-color')); + secondaryColorPicker.on('change', (evt) => onTagColorize(evt, (tag, color) => tag.color2 = color, 'color')); + template.find('.tag_view_color_picker .link_icon').on('click', (evt) => { + const colorPicker = $(evt.target).closest('.tag_view_color_picker').find('toolcool-color-picker'); + const defaultColor = colorPicker.attr('data-default-color'); + // @ts-ignore + colorPicker[0].color = defaultColor; + }); list.append(template); @@ -1633,47 +1642,36 @@ function onTagRenameInput() { saveSettingsDebounced(); } -function onTagColorize(evt) { +/** + * Handles the colorization of a tag when the user interacts with the color picker + * + * @param {*} evt - The custom colorize event object + * @param {(tag: Tag, val: string) => void} setColor - A function that sets the color of the tag + * @param {string} cssProperty - The CSS property to apply the color to + */ +function onTagColorize(evt, setColor, cssProperty) { console.debug(evt); const isDefaultColor = $(evt.target).data('default-color') === evt.detail.rgba; - - if (evt.detail.rgba === evt.detail.color.originalInput) - return; + $(evt.target).closest('.tag_view_color_picker').find('.link_icon').toggle(!isDefaultColor); const id = $(evt.target).closest('.tag_view_item').attr('id'); let newColor = evt.detail.rgba; if (isDefaultColor) newColor = ''; - $(evt.target).parent().parent().find('.tag_view_name').css('background-color', newColor); - $(`.tag[id="${id}"]`).css('background-color', newColor); - $(`.bogus_folder_select[tagid="${id}"] .avatar`).css('background-color', newColor); + $(evt.target).closest('.tag_view_item').find('.tag_view_name').css(cssProperty, newColor); const tag = tags.find(x => x.id === id); - tag.color = newColor; + setColor(tag, newColor); console.debug(tag); saveSettingsDebounced(); + + // Debounce redrawing color of the tag in other elements + debouncedTagColoring(tag.id, cssProperty, newColor); } -function onTagColorize2(evt) { - console.debug(evt); - if (evt.detail.rgba === evt.detail.color.originalInput) - return; - - const id = $(evt.target).closest('.tag_view_item').attr('id'); - let newColor = evt.detail.rgba; - - // If new color is same as "data-default-color", we set it to empty string - const defaultColor = $(evt.target).data('default-color'); - if (newColor === defaultColor) newColor = ''; - - - $(evt.target).parent().parent().find('.tag_view_name').css('color', newColor); - $(`.tag[id="${id}"]`).css('color', newColor); - $(`.bogus_folder_select[tagid="${id}"] .avatar`).css('color', newColor); - const tag = tags.find(x => x.id === id); - tag.color2 = newColor; - console.debug(tag); - saveSettingsDebounced(); -} +const debouncedTagColoring = debounce((tagId, cssProperty, newColor) => { + $(`.tag[id="${tagId}"]`).css(cssProperty, newColor); + $(`.bogus_folder_select[tagid="${tagId}"] .avatar`).css(cssProperty, newColor); +}, debounce_timeout.quick); function onTagListHintClick() { $(this).toggleClass('selected'); From 6c3118549f4f3f695c312fa1f7535f6c144f376b Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Thu, 30 May 2024 05:11:23 +0200 Subject: [PATCH 009/298] Make generic popups be modal dialogs - Switch generic popups to actual elements - Move toastr settings from html to JS - Add style variable for animation duration (to re-use in CSS) - Remember focus of popup on stacking pop-up close to switch back to the element you started out in - Fix keybinds of popups to only act on actual result-triggering controls - Fix toastr appearing behind popups by dynamically moving the container inside the currently open dialog - Improve autofocus on popup open - Make cleaner and prettier popup animations, and tie them to the animation speed - --- public/index.html | 37 ++--- public/script.js | 19 ++- public/scripts/popup.js | 335 +++++++++++++++++++++++----------------- public/scripts/tags.js | 2 +- public/style.css | 108 +++++++++++-- 5 files changed, 315 insertions(+), 186 deletions(-) diff --git a/public/index.html b/public/index.html index 59dfafb8c..2c0fba520 100644 --- a/public/index.html +++ b/public/index.html @@ -4848,20 +4848,18 @@
@@ -5198,8 +5196,8 @@
-
-
+
+
 entries
@@ -6437,15 +6435,6 @@ -