Merge pull request #2330 from Wolfsblvt/smol-tag-improvements

Smol tag improvements & Huuuuuge popup/dialog rework (heh)
This commit is contained in:
Cohee
2024-06-18 01:58:52 +03:00
committed by GitHub
39 changed files with 2007 additions and 764 deletions

View File

@@ -303,7 +303,7 @@ export async function favsToHotswap() {
return;
}
buildAvatarList(container, favs, { selectable: true, highlightFavs: false });
buildAvatarList(container, favs, { interactable: true, highlightFavs: false });
}
//changes input bar and send button display depending on connection status

View File

@@ -6,6 +6,7 @@ import { BlankAutoCompleteOption } from './BlankAutoCompleteOption.js';
// eslint-disable-next-line no-unused-vars
import { AutoCompleteNameResult } from './AutoCompleteNameResult.js';
import { AutoCompleteSecondaryNameResult } from './AutoCompleteSecondaryNameResult.js';
import { Popup, getTopmostModalLayer } from '../popup.js';
/**@readonly*/
/**@enum {Number}*/
@@ -442,7 +443,7 @@ export class AutoComplete {
}
this.dom.append(frag);
this.updatePosition();
document.body.append(this.domWrap);
getTopmostModalLayer().append(this.domWrap);
} else {
this.domWrap.remove();
}
@@ -457,7 +458,7 @@ export class AutoComplete {
if (!this.isShowingDetails && this.isReplaceable) return this.detailsWrap.remove();
this.detailsDom.innerHTML = '';
this.detailsDom.append(this.selectedItem?.renderDetails() ?? 'NO ITEM');
document.body.append(this.detailsWrap);
getTopmostModalLayer().append(this.detailsWrap);
this.updateDetailsPositionDebounced();
}
@@ -473,7 +474,7 @@ export class AutoComplete {
const rect = {};
rect[AUTOCOMPLETE_WIDTH.INPUT] = this.textarea.getBoundingClientRect();
rect[AUTOCOMPLETE_WIDTH.CHAT] = document.querySelector('#sheld').getBoundingClientRect();
rect[AUTOCOMPLETE_WIDTH.FULL] = document.body.getBoundingClientRect();
rect[AUTOCOMPLETE_WIDTH.FULL] = getTopmostModalLayer().getBoundingClientRect();
this.domWrap.style.setProperty('--bottom', `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`);
this.dom.style.setProperty('--bottom', `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`);
this.domWrap.style.bottom = `${window.innerHeight - rect[AUTOCOMPLETE_WIDTH.INPUT].top}px`;
@@ -500,7 +501,7 @@ export class AutoComplete {
const rect = {};
rect[AUTOCOMPLETE_WIDTH.INPUT] = this.textarea.getBoundingClientRect();
rect[AUTOCOMPLETE_WIDTH.CHAT] = document.querySelector('#sheld').getBoundingClientRect();
rect[AUTOCOMPLETE_WIDTH.FULL] = document.body.getBoundingClientRect();
rect[AUTOCOMPLETE_WIDTH.FULL] = getTopmostModalLayer().getBoundingClientRect();
if (this.isReplaceable) {
this.detailsWrap.classList.remove('full');
const selRect = this.selectedItem.dom.children[0].getBoundingClientRect();
@@ -596,7 +597,7 @@ export class AutoComplete {
}
this.clone.style.position = 'fixed';
this.clone.style.visibility = 'hidden';
document.body.append(this.clone);
getTopmostModalLayer().append(this.clone);
const mo = new MutationObserver(muts=>{
if (muts.find(it=>Array.from(it.removedNodes).includes(this.textarea))) {
this.clone.remove();

View File

@@ -94,6 +94,9 @@ function enableBulkSelect() {
});
$(el).prepend(checkbox);
});
$('#rm_print_characters_block.group_overlay_mode_select .bogus_folder_select, #rm_print_characters_block.group_overlay_mode_select .group_select')
.addClass('disabled');
$('#rm_print_characters_block').addClass('bulk_select');
// We also need to disable the default click event for the character_select divs
$(document).on('click', '.bulk_select_checkbox', function (event) {
@@ -106,6 +109,8 @@ function enableBulkSelect() {
*/
function disableBulkSelect() {
$('.bulk_select_checkbox').remove();
$('#rm_print_characters_block.group_overlay_mode_select .bogus_folder_select, #rm_print_characters_block.group_overlay_mode_select .group_select')
.removeClass('disabled');
$('#rm_print_characters_block').removeClass('bulk_select');
}

View File

@@ -37,6 +37,7 @@ import {
import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js';
import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from './popup.js';
import { ScraperManager } from './scrapers.js';
import { DragAndDropHandler } from './dragdrop.js';
/**
* @typedef {Object} FileAttachment
@@ -991,49 +992,24 @@ async function openAttachmentManager() {
template.find('.chatAttachmentsName').text(chatName);
}
function addDragAndDrop() {
$(document.body).on('dragover', '.dialogue_popup', (event) => {
event.preventDefault();
event.stopPropagation();
$(event.target).closest('.dialogue_popup').addClass('dragover');
const dragDropHandler = new DragAndDropHandler('.popup', async (files, event) => {
let selectedTarget = ATTACHMENT_SOURCE.GLOBAL;
const targets = getAvailableTargets();
const targetSelectTemplate = $(await renderExtensionTemplateAsync('attachments', 'files-dropped', { count: files.length, targets: targets }));
targetSelectTemplate.find('.droppedFilesTarget').on('input', function () {
selectedTarget = String($(this).val());
});
$(document.body).on('dragleave', '.dialogue_popup', (event) => {
event.preventDefault();
event.stopPropagation();
$(event.target).closest('.dialogue_popup').removeClass('dragover');
});
$(document.body).on('drop', '.dialogue_popup', async (event) => {
event.preventDefault();
event.stopPropagation();
$(event.target).closest('.dialogue_popup').removeClass('dragover');
const files = Array.from(event.originalEvent.dataTransfer.files);
let selectedTarget = ATTACHMENT_SOURCE.GLOBAL;
const targets = getAvailableTargets();
const targetSelectTemplate = $(await renderExtensionTemplateAsync('attachments', 'files-dropped', { count: files.length, targets: targets }));
targetSelectTemplate.find('.droppedFilesTarget').on('input', function () {
selectedTarget = String($(this).val());
});
const result = await callGenericPopup(targetSelectTemplate, POPUP_TYPE.CONFIRM, '', { wide: false, large: false, okButton: 'Upload', cancelButton: 'Cancel' });
if (result !== POPUP_RESULT.AFFIRMATIVE) {
console.log('File upload cancelled');
return;
}
for (const file of files) {
await uploadFileAttachmentToServer(file, selectedTarget);
}
renderAttachments();
});
}
function removeDragAndDrop() {
$(document.body).off('dragover', '.shadow_popup');
$(document.body).off('dragleave', '.shadow_popup');
$(document.body).off('drop', '.shadow_popup');
}
const result = await callGenericPopup(targetSelectTemplate, POPUP_TYPE.CONFIRM, '', { wide: false, large: false, okButton: 'Upload', cancelButton: 'Cancel' });
if (result !== POPUP_RESULT.AFFIRMATIVE) {
console.log('File upload cancelled');
return;
}
for (const file of files) {
await uploadFileAttachmentToServer(file, selectedTarget);
}
renderAttachments();
});
let sortField = localStorage.getItem('DataBank_sortField') || 'created';
let sortOrder = localStorage.getItem('DataBank_sortOrder') || 'desc';
@@ -1129,11 +1105,10 @@ async function openAttachmentManager() {
const cleanupFn = await renderButtons();
await verifyAttachments();
await renderAttachments();
addDragAndDrop();
await callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: true, large: true, okButton: 'Close' });
cleanupFn();
removeDragAndDrop();
dragDropHandler.destroy();
}
/**

107
public/scripts/dragdrop.js vendored Normal file
View File

@@ -0,0 +1,107 @@
import { debounce_timeout } from './constants.js';
/**
* Drag and drop handler
*
* Can be used on any element, enabling drag&drop styling and callback on drop.
*/
export class DragAndDropHandler {
/** @private @type {JQuery.Selector} */ selector;
/** @private @type {(files: File[], event:JQuery.DropEvent<HTMLElement, undefined, any, any>) => void} */ onDropCallback;
/** @private @type {NodeJS.Timeout} Remark: Not actually NodeJS timeout, but it's close */ dragLeaveTimeout;
/** @private @type {boolean} */ noAnimation;
/**
* Create a DragAndDropHandler
* @param {JQuery.Selector} selector - The CSS selector for the elements to enable drag and drop
* @param {(files: File[], event:JQuery.DropEvent<HTMLElement, undefined, any, any>) => void} onDropCallback - The callback function to handle the drop event
*/
constructor(selector, onDropCallback, { noAnimation = false } = {}) {
this.selector = selector;
this.onDropCallback = onDropCallback;
this.dragLeaveTimeout = null;
this.noAnimation = noAnimation;
this.init();
}
/**
* Destroy the drag and drop functionality
*/
destroy() {
if (this.selector === 'body') {
$(document.body).off('dragover', this.handleDragOver.bind(this));
$(document.body).off('dragleave', this.handleDragLeave.bind(this));
$(document.body).off('drop', this.handleDrop.bind(this));
} else {
$(document.body).off('dragover', this.selector, this.handleDragOver.bind(this));
$(document.body).off('dragleave', this.selector, this.handleDragLeave.bind(this));
$(document.body).off('drop', this.selector, this.handleDrop.bind(this));
}
$(this.selector).remove('drop_target no_animation');
}
/**
* Initialize the drag and drop functionality
* Automatically called on construction
* @private
*/
init() {
if (this.selector === 'body') {
$(document.body).on('dragover', this.handleDragOver.bind(this));
$(document.body).on('dragleave', this.handleDragLeave.bind(this));
$(document.body).on('drop', this.handleDrop.bind(this));
} else {
$(document.body).on('dragover', this.selector, this.handleDragOver.bind(this));
$(document.body).on('dragleave', this.selector, this.handleDragLeave.bind(this));
$(document.body).on('drop', this.selector, this.handleDrop.bind(this));
}
$(this.selector).addClass('drop_target');
if (this.noAnimation) $(this.selector).addClass('no_animation');
}
/**
* @param {JQuery.DragOverEvent<HTMLElement, undefined, any, any>} event - The dragover event
* @private
*/
handleDragOver(event) {
event.preventDefault();
event.stopPropagation();
clearTimeout(this.dragLeaveTimeout);
$(this.selector).addClass('drop_target dragover');
if (this.noAnimation) $(this.selector).addClass('no_animation');
}
/**
* @param {JQuery.DragLeaveEvent<HTMLElement, undefined, any, any>} event - The dragleave event
* @private
*/
handleDragLeave(event) {
event.preventDefault();
event.stopPropagation();
// Debounce the removal of the class, so it doesn't "flicker" on dragging over
clearTimeout(this.dragLeaveTimeout);
this.dragLeaveTimeout = setTimeout(() => {
$(this.selector).removeClass('dragover');
}, debounce_timeout.quick);
}
/**
* @param {JQuery.DropEvent<HTMLElement, undefined, any, any>} event - The drop event
* @private
*/
handleDrop(event) {
event.preventDefault();
event.stopPropagation();
clearTimeout(this.dragLeaveTimeout);
$(this.selector).removeClass('dragover');
const files = Array.from(event.originalEvent.dataTransfer.files);
this.onDropCallback(files, event);
}
}

View File

@@ -0,0 +1,162 @@
/** @type {CSSStyleSheet} */
let dynamicStyleSheet = null;
/** @type {CSSStyleSheet} */
let dynamicExtensionStyleSheet = null;
/**
* An observer that will check if any new stylesheets are added to the head
* @type {MutationObserver}
*/
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type !== 'childList') return;
mutation.addedNodes.forEach(node => {
if (node instanceof HTMLLinkElement && node.tagName === 'LINK' && node.rel === 'stylesheet') {
node.addEventListener('load', () => {
try {
applyDynamicFocusStyles(node.sheet);
} catch (e) {
console.warn('Failed to process new stylesheet:', e);
}
});
}
});
});
});
/**
* Generates dynamic focus styles based on the given stylesheet, taking its hover styles as reference
*
* @param {CSSStyleSheet} styleSheet - The stylesheet to process
* @param {object} [options] - Optional configuration options
* @param {boolean} [options.fromExtension=false] - Indicates if the styles are from an extension
*/
function applyDynamicFocusStyles(styleSheet, { fromExtension = false } = {}) {
/** @type {{baseSelector: string, rule: CSSStyleRule}[]} */
const hoverRules = [];
/** @type {Set<string>} */
const focusRules = new Set();
const PLACEHOLDER = ':__PLACEHOLDER__';
/**
* Processes the CSS rules and separates selectors for hover and focus
* @param {CSSRuleList} rules - The CSS rules to process
*/
function processRules(rules) {
Array.from(rules).forEach(rule => {
if (rule instanceof CSSImportRule) {
// Make sure that @import rules are processed recursively
processImportedStylesheet(rule.styleSheet);
} else if (rule instanceof CSSStyleRule) {
// Separate multiple selectors on a rule
const selectors = rule.selectorText.split(',').map(s => s.trim());
// We collect all hover and focus rules to be able to later decide which hover rules don't have a matching focus rule
selectors.forEach(selector => {
const isHover = selector.includes(':hover'), isFocus = selector.includes(':focus');
if (isHover && isFocus) {
// We currently do nothing here. Rules containing both hover and focus are very specific and should never be automatically touched
}
else if (isHover) {
const baseSelector = selector.replace(':hover', PLACEHOLDER).trim();
hoverRules.push({ baseSelector, rule });
} else if (isFocus) {
// We need to make sure that we remember all existing :focus, :focus-within and :focus-visible rules
const baseSelector = selector.replace(':focus-within', PLACEHOLDER).replace(':focus-visible', PLACEHOLDER).replace(':focus', PLACEHOLDER).trim();
focusRules.add(baseSelector);
}
});
} else if (rule instanceof CSSMediaRule || rule instanceof CSSSupportsRule) {
// Recursively process nested rules
processRules(rule.cssRules);
}
});
}
/**
* Processes the CSS rules of an imported stylesheet recursively
* @param {CSSStyleSheet} sheet - The imported stylesheet to process
*/
function processImportedStylesheet(sheet) {
if (sheet && sheet.cssRules) {
processRules(sheet.cssRules);
}
}
processRules(styleSheet.cssRules);
/** @type {CSSStyleSheet} */
let targetStyleSheet = null;
// Now finally create the dynamic focus rules
hoverRules.forEach(({ baseSelector, rule }) => {
if (!focusRules.has(baseSelector)) {
// Only initialize the dynamic stylesheet if needed
targetStyleSheet ??= getDynamicStyleSheet({ fromExtension });
// The closest keyboard-equivalent to :hover styling is utilizing the :focus-visible rule from modern browsers.
// It let's the browser decide whether a focus highlighting is expected and makes sense.
// So we take all :hover rules that don't have a manually defined focus rule yet, and create their
// :focus-visible counterpart, which will make the styling work the same for keyboard and mouse.
// If something like :focus-within or a more specific selector like `.blah:has(:focus-visible)` for elements inside,
// it should be manually defined in CSS.
const focusSelector = rule.selectorText.replace(/:hover/g, ':focus-visible');
const focusRule = `${focusSelector} { ${rule.style.cssText} }`;
try {
targetStyleSheet.insertRule(focusRule, targetStyleSheet.cssRules.length);
} catch (e) {
console.warn('Failed to insert focus rule:', e);
}
}
});
}
/**
* Retrieves the stylesheet that should be used for dynamic rules
*
* @param {object} options - The options object
* @param {boolean} [options.fromExtension=false] - Indicates whether the rules are coming from extensions
* @return {CSSStyleSheet} The dynamic stylesheet
*/
function getDynamicStyleSheet({ fromExtension = false } = {}) {
if (fromExtension) {
if (!dynamicExtensionStyleSheet) {
const styleSheetElement = document.createElement('style');
styleSheetElement.setAttribute('id', 'dynamic-extension-styles');
document.head.appendChild(styleSheetElement);
dynamicExtensionStyleSheet = styleSheetElement.sheet;
}
return dynamicExtensionStyleSheet;
} else {
if (!dynamicStyleSheet) {
const styleSheetElement = document.createElement('style');
styleSheetElement.setAttribute('id', 'dynamic-styles');
document.head.appendChild(styleSheetElement);
dynamicStyleSheet = styleSheetElement.sheet;
}
return dynamicStyleSheet;
}
}
/**
* Initializes dynamic styles for ST
*/
export function initDynamicStyles() {
// Start observing the head for any new added stylesheets
observer.observe(document.head, {
childList: true,
subtree: true
});
// Process all stylesheets on initial load
Array.from(document.styleSheets).forEach(sheet => {
try {
applyDynamicFocusStyles(sheet, { fromExtension: sheet.href.toLowerCase().includes('scripts/extensions') });
} catch (e) {
console.warn('Failed to process stylesheet on initial load:', e);
}
});
}

View File

@@ -349,12 +349,12 @@ function autoConnectInputHandler() {
function addExtensionsButtonAndMenu() {
const buttonHTML =
'<div id="extensionsMenuButton" style="display: none;" class="fa-solid fa-magic-wand-sparkles" title="Extras Extensions" /></div>';
'<div id="extensionsMenuButton" style="display: none;" class="fa-solid fa-magic-wand-sparkles interactable" title="Extras Extensions" /></div>';
const extensionsMenuHTML = '<div id="extensionsMenu" class="options-content" style="display: none;"></div>';
$(document.body).append(extensionsMenuHTML);
$('#leftSendForm').prepend(buttonHTML);
$('#leftSendForm').append(buttonHTML);
const button = $('#extensionsMenuButton');
const dropdown = $('#extensionsMenu');

View File

@@ -11,6 +11,7 @@ import { dragElement } from '../../RossAscends-mods.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
import { DragAndDropHandler } from '../../dragdrop.js';
const extensionName = 'gallery';
const extensionFolderPath = `scripts/extensions/${extensionName}/`;
@@ -56,7 +57,8 @@ async function getGalleryItems(url) {
* @returns {Promise<void>} - Promise representing the completion of the gallery initialization.
*/
async function initGallery(items, url) {
$('#dragGallery').nanogallery2({
const gallery = $('#dragGallery');
gallery.nanogallery2({
'items': items,
thumbnailWidth: 'auto',
thumbnailHeight: thumbnailHeight,
@@ -80,44 +82,24 @@ async function initGallery(items, url) {
eventSource.on('resizeUI', function (elmntName) {
jQuery('#dragGallery').nanogallery2('resize');
gallery.nanogallery2('resize');
});
const dropZone = $('#dragGallery');
//remove any existing handlers
dropZone.off('dragover');
dropZone.off('dragleave');
dropZone.off('drop');
// Set dropzone height to be the same as the parent
dropZone.css('height', dropZone.parent().css('height'));
// Initialize dropzone handlers
dropZone.on('dragover', function (e) {
e.stopPropagation(); // Ensure this event doesn't propagate
e.preventDefault();
$(this).addClass('dragging'); // Add a CSS class to change appearance during drag-over
});
dropZone.on('dragleave', function (e) {
e.stopPropagation(); // Ensure this event doesn't propagate
$(this).removeClass('dragging');
});
dropZone.on('drop', function (e) {
e.stopPropagation(); // Ensure this event doesn't propagate
e.preventDefault();
$(this).removeClass('dragging');
let file = e.originalEvent.dataTransfer.files[0];
const dragDropHandler = new DragAndDropHandler('#dragGallery', async (files, event) => {
let file = files[0];
uploadFile(file, url); // Added url parameter to know where to upload
});
// Set dropzone height to be the same as the parent
gallery.css('height', gallery.parent().css('height'));
//let images populate first
await delay(100);
//unset the height (which must be getting set by the gallery library at some point)
$('#dragGallery').css('height', 'unset');
gallery.css('height', 'unset');
//force a resize to make images display correctly
jQuery('#dragGallery').nanogallery2('resize');
gallery.nanogallery2('resize');
}
/**

View File

@@ -220,68 +220,68 @@
align-items: baseline;
}
@media screen and (max-width: 750px) {
body .dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor {
body .popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor {
flex-direction: column;
overflow: auto;
}
body .dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main {
body .popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main {
flex: 0 0 auto;
}
body .dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels {
body .popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels {
flex-direction: column;
}
body .dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder {
body .popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder {
min-height: 50svh;
height: 50svh;
}
}
.dialogue_popup:has(#qr--modalEditor) {
.popup:has(#qr--modalEditor) {
aspect-ratio: unset;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text {
.popup:has(#qr--modalEditor) .popup-content {
display: flex;
flex-direction: column;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor {
flex: 1 1 auto;
display: flex;
flex-direction: row;
gap: 1em;
overflow: hidden;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main {
flex: 1 1 auto;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels {
flex: 0 0 auto;
display: flex;
flex-direction: row;
gap: 0.5em;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels > label {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label {
flex: 1 1 1px;
display: flex;
flex-direction: column;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels > label > .qr--labelText {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label > .qr--labelText {
flex: 1 1 auto;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels > label > .qr--labelHint {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label > .qr--labelHint {
flex: 1 1 auto;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels > label > input {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--labels > label > input {
flex: 0 0 auto;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer {
flex: 1 1 auto;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > .qr--modal-editorSettings {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > .qr--modal-editorSettings {
display: flex;
flex-direction: row;
gap: 1em;
@@ -289,35 +289,35 @@
font-size: smaller;
align-items: baseline;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > .qr--modal-editorSettings > .checkbox_label {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > .qr--modal-editorSettings > .checkbox_label {
white-space: nowrap;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > .qr--modal-editorSettings > .checkbox_label > input {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > .qr--modal-editorSettings > .checkbox_label > input {
font-size: inherit;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder {
flex: 1 1 auto;
display: grid;
text-align: left;
overflow: hidden;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder.qr--noSyntax > #qr--modal-messageSyntax {
.dialogue_popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder.qr--noSyntax > #qr--modal-messageSyntax {
display: none;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder.qr--noSyntax > #qr--modal-message {
.dialogue_popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder.qr--noSyntax > #qr--modal-message {
background-color: var(--ac-style-color-background);
color: var(--ac-style-color-text);
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder.qr--noSyntax > #qr--modal-message::selection {
.dialogue_popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder.qr--noSyntax > #qr--modal-message::selection {
color: unset;
background-color: rgba(108 171 251 / 0.25);
}
@supports (color: rgb(from white r g b / 0.25)) {
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder.qr--noSyntax > #qr--modal-message::selection {
.dialogue_popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder.qr--noSyntax > #qr--modal-message::selection {
background-color: rgb(from var(--ac-style-color-matchedText) r g b / 0.25);
}
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder > #qr--modal-messageSyntax {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder > #qr--modal-messageSyntax {
grid-column: 1;
grid-row: 1;
padding: 0;
@@ -327,10 +327,10 @@
min-width: 100%;
width: 0;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder > #qr--modal-messageSyntax > #qr--modal-messageSyntaxInner {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder > #qr--modal-messageSyntax > #qr--modal-messageSyntaxInner {
height: 100%;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder > #qr--modal-message {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder > #qr--modal-message {
background-color: transparent;
color: transparent;
grid-column: 1;
@@ -338,22 +338,22 @@
caret-color: var(--ac-style-color-text);
overflow: auto;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder > #qr--modal-message::-webkit-scrollbar,
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder > #qr--modal-message::-webkit-scrollbar-thumb {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder > #qr--modal-message::-webkit-scrollbar,
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder > #qr--modal-message::-webkit-scrollbar-thumb {
visibility: hidden;
cursor: default;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder > #qr--modal-message::selection {
.dialogue_popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder > #qr--modal-message::selection {
color: transparent;
background-color: rgba(108 171 251 / 0.25);
}
@supports (color: rgb(from white r g b / 0.25)) {
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder > #qr--modal-message::selection {
.dialogue_popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder > #qr--modal-message::selection {
background-color: rgb(from var(--ac-style-color-matchedText) r g b / 0.25);
}
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder #qr--modal-message,
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder #qr--modal-messageSyntaxInner {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder #qr--modal-message,
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder #qr--modal-messageSyntaxInner {
font-family: var(--monoFontFamily);
padding: 0.75em;
margin: 0;
@@ -363,11 +363,11 @@
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 5px;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeButtons {
display: flex;
gap: 1em;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons .qr--modal-executeButton {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeButtons .qr--modal-executeButton {
border-width: 2px;
border-style: solid;
display: flex;
@@ -375,42 +375,42 @@
gap: 0.5em;
padding: 0.5em 0.75em;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons .qr--modal-executeButton .qr--modal-executeComboIcon {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeButtons .qr--modal-executeButton .qr--modal-executeComboIcon {
display: flex;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons #qr--modal-execute {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeButtons #qr--modal-execute {
transition: 200ms;
filter: grayscale(0);
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons #qr--modal-execute.qr--busy {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeButtons #qr--modal-execute.qr--busy {
cursor: wait;
opacity: 0.5;
filter: grayscale(1);
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons #qr--modal-execute {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeButtons #qr--modal-execute {
border-color: #51a351;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons #qr--modal-pause,
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons #qr--modal-stop {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeButtons #qr--modal-pause,
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeButtons #qr--modal-stop {
cursor: default;
opacity: 0.5;
filter: grayscale(1);
pointer-events: none;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons .qr--busy ~ #qr--modal-pause,
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons .qr--busy ~ #qr--modal-stop {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeButtons .qr--busy ~ #qr--modal-pause,
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeButtons .qr--busy ~ #qr--modal-stop {
cursor: pointer;
opacity: 1;
filter: grayscale(0);
pointer-events: all;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons #qr--modal-pause {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeButtons #qr--modal-pause {
border-color: #92befc;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeButtons #qr--modal-stop {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeButtons #qr--modal-stop {
border-color: #d78872;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeProgress {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeProgress {
--prog: 0;
--progColor: #92befc;
--progFlashColor: #d78872;
@@ -421,7 +421,7 @@
background-color: var(--black50a);
position: relative;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeProgress:after {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeProgress:after {
content: '';
background-color: var(--progColor);
position: absolute;
@@ -429,23 +429,23 @@
right: calc(100% - var(--prog) * 1%);
transition: 200ms;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeProgress.qr--paused:after {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeProgress.qr--paused:after {
animation-name: qr--progressPulse;
animation-duration: 1500ms;
animation-timing-function: ease-in-out;
animation-delay: 0s;
animation-iteration-count: infinite;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeProgress.qr--aborted:after {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeProgress.qr--aborted:after {
background-color: var(--progAbortedColor);
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeProgress.qr--success:after {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeProgress.qr--success:after {
background-color: var(--progSuccessColor);
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeProgress.qr--error:after {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeProgress.qr--error:after {
background-color: var(--progErrorColor);
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeErrors {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeErrors {
display: none;
text-align: left;
font-size: smaller;
@@ -456,10 +456,10 @@
min-width: 100%;
width: 0;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeErrors.qr--hasErrors {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeErrors.qr--hasErrors {
display: block;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeResult {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeResult {
display: none;
text-align: left;
font-size: smaller;
@@ -470,10 +470,10 @@
min-width: 100%;
width: 0;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeResult.qr--hasResult {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeResult.qr--hasResult {
display: block;
}
.dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor #qr--modal-executeResult:before {
.popup:has(#qr--modalEditor) .popup-content > #qr--modalEditor #qr--modal-executeResult:before {
content: 'Result: ';
}
@keyframes qr--progressPulse {
@@ -485,6 +485,6 @@
background-color: var(--progFlashColor);
}
}
.shadow_popup.qr--hide {
.popup.qr--hide {
opacity: 0 !important;
}

View File

@@ -244,7 +244,7 @@
@media screen and (max-width: 750px) {
body .dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor {
body .popup:has(#qr--modalEditor) .popup-content>#qr--modalEditor {
flex-direction: column;
overflow: auto;
> #qr--main {
@@ -259,10 +259,10 @@
}
}
}
.dialogue_popup:has(#qr--modalEditor) {
.popup:has(#qr--modalEditor) {
aspect-ratio: unset;
.dialogue_popup_text {
.popup-content {
display: flex;
flex-direction: column;
@@ -507,6 +507,6 @@
}
}
.shadow_popup.qr--hide {
.popup.qr--hide {
opacity: 0 !important;
}

View File

@@ -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();

243
public/scripts/keyboard.js Normal file
View File

@@ -0,0 +1,243 @@
/* All selectors that should act as interactables / keyboard buttons by default */
const interactableSelectors = [
'.interactable', // Main interactable class for ALL interactable controls (can also be manually added in code, so that's why its listed here)
'.custom_interactable', // Manually made interactable controls via code (see 'makeKeyboardInteractable()')
'.menu_button', // General menu button in ST
'.right_menu_button', // Button-likes in many menus
'.drawer-icon', // Main "menu bar" icons
'.inline-drawer-icon', // Buttons/icons inside the drawer menus
'.paginationjs-pages li a', // Pagination buttons
'.group_select, .character_select, .bogus_folder_select', // Cards to select char, group or folder in character list and other places
'.avatar-container', // Persona list blocks
'.tag .tag_remove', // Remove button in removable tags
'.bg_example', // Background elements in the background menu
'.bg_example .bg_button', // The inline buttons on the backgrounds
'#options a', // Option entries in the popup options menu
'#extensionsMenu div:has(.extensionsMenuExtensionButton)', // Option entries in the extension menu popup that are coming from extensions
'.mes_buttons .mes_button', // Small inline buttons on the chat messages
'.extraMesButtons>div:not(.mes_button)', // The extra/extension buttons inline on the chat messages
'.swipe_left, .swipe_right', // Swipe buttons on the last message
'.stscript_btn', // STscript buttons in the chat bar
'.select2_choice_clickable+span.select2-container .select2-selection__choice__display', // select2 control elements if they are meant to be clickable
'.avatar_load_preview' // Char display avatar selection
];
export const INTERACTABLE_CONTROL_CLASS = 'interactable';
export const CUSTOM_INTERACTABLE_CONTROL_CLASS = 'custom_interactable';
export const NOT_FOCUSABLE_CONTROL_CLASS = 'not_focusable';
export const DISABLED_CONTROL_CLASS = 'disabled';
/**
* An observer that will check if any new interactables or scroll reset containers are added to the body
* @type {MutationObserver}
*/
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(handleNodeChange);
}
if (mutation.type === 'attributes') {
const target = mutation.target;
if (mutation.attributeName === 'class' && target instanceof Element) {
handleNodeChange(target);
}
}
});
});
/**
* Function to handle node changes (added or modified nodes)
* @param {Element} node
*/
function handleNodeChange(node) {
if (node.nodeType === Node.ELEMENT_NODE && node instanceof Element) {
// Handle keyboard interactables
if (isKeyboardInteractable(node)) {
makeKeyboardInteractable(node);
}
initializeInteractables(node);
// Handle scroll reset containers
if (node.classList.contains('scroll-reset-container')) {
applyScrollResetBehavior(node);
}
initializeScrollResetBehaviors(node);
}
}
/**
* Registers an interactable class (for example for an extension) and makes it keyboard interactable.
* Optionally apply the 'not_focusable' and 'disabled' classes if needed.
*
* @param {string} interactableSelector - The CSS selector for the interactable (Supports class combinations, chained via dots like <c>tag.actionable</c>, and sub selectors)
* @param {object} [options={}] - Optional settings for the interactable
* @param {boolean} [options.disabledByDefault=false] - Whether interactables of this class should be disabled by default
* @param {boolean} [options.notFocusableByDefault=false] - Whether interactables of this class should not be focusable by default
*/
export function registerInteractableType(interactableSelector, { disabledByDefault = false, notFocusableByDefault = false } = {}) {
interactableSelectors.push(interactableSelector);
const interactables = document.querySelectorAll(interactableSelector);
if (disabledByDefault || notFocusableByDefault) {
interactables.forEach(interactable => {
if (disabledByDefault) interactable.classList.add(DISABLED_CONTROL_CLASS);
if (notFocusableByDefault) interactable.classList.add(NOT_FOCUSABLE_CONTROL_CLASS);
});
}
makeKeyboardInteractable(...interactables);
}
/**
* Checks if the given control is a keyboard-enabled interactable.
*
* @param {Element} control - The control element to check
* @returns {boolean} Returns true if the control is a keyboard interactable, false otherwise
*/
export function isKeyboardInteractable(control) {
// Check if this control matches any of the selectors
return interactableSelectors.some(selector => control.matches(selector));
}
/**
* Makes all the given controls keyboard interactable and sets their state.
* If the control doesn't have any of the classes, it will be set to a custom-enabled keyboard interactable.
*
* @param {Element[]} interactables - The controls to make interactable and set their state
*/
export function makeKeyboardInteractable(...interactables) {
interactables.forEach(interactable => {
// If this control doesn't have any of the classes, lets say the caller knows this and wants this to be a custom-enabled keyboard control.
if (!isKeyboardInteractable(interactable)) {
interactable.classList.add(CUSTOM_INTERACTABLE_CONTROL_CLASS);
}
// Just for CSS styling and future reference, every keyboard interactable control should have a common class
if (!interactable.classList.contains(INTERACTABLE_CONTROL_CLASS)) {
interactable.classList.add(INTERACTABLE_CONTROL_CLASS);
}
/**
* Check if the element or any parent element has 'disabled' or 'not_focusable' class
* @param {Element} el
* @returns {boolean}
*/
const hasDisabledOrNotFocusableAncestor = (el) => {
while (el) {
if (el.classList.contains(NOT_FOCUSABLE_CONTROL_CLASS) || el.classList.contains(DISABLED_CONTROL_CLASS)) {
return true;
}
el = el.parentElement;
}
return false;
};
// Set/remove the tabindex accordingly to the classes. Remembering if it had a custom value.
if (!hasDisabledOrNotFocusableAncestor(interactable)) {
if (!interactable.hasAttribute('tabindex')) {
const tabIndex = interactable.getAttribute('data-original-tabindex') ?? '0';
interactable.setAttribute('tabindex', tabIndex);
}
} else {
interactable.setAttribute('data-original-tabindex', interactable.getAttribute('tabindex'));
interactable.removeAttribute('tabindex');
}
});
}
/**
* Initializes the focusability of controls on the given element or the document
*
* @param {Element|Document} [element=document] - The element on which to initialize the interactable state. Defaults to the document.
*/
function initializeInteractables(element = document) {
const interactables = getAllInteractables(element);
makeKeyboardInteractable(...interactables);
}
/**
* Queries all interactables within the given element based on the given selectors and returns them as an array
*
* @param {Element|Document} element - The element within which to query the interactables
* @returns {HTMLElement[]} An array containing all the interactables that match the given selectors
*/
function getAllInteractables(element) {
// Query each selector individually and combine all to a big array to return
return [].concat(...interactableSelectors.map(selector => Array.from(element.querySelectorAll(`${selector}`))));
}
/**
* Function to apply scroll reset behavior to a container
* @param {Element} container - The container
*/
const applyScrollResetBehavior = (container) => {
container.addEventListener('focusout', (e) => {
setTimeout(() => {
const focusedElement = document.activeElement;
if (!container.contains(focusedElement)) {
container.scrollTop = 0;
container.scrollLeft = 0;
}
}, 0);
});
};
/**
* Initializes the scroll reset behavior on the given element or the document
*
* @param {Element|Document} [element=document] - The element on which to initialize the scroll reset behavior. Defaults to the document.
*/
function initializeScrollResetBehaviors(element = document) {
const scrollResetContainers = element.querySelectorAll('.scroll-reset-container');
scrollResetContainers.forEach(container => applyScrollResetBehavior(container));
}
/**
* Handles keydown events on the document to trigger click on Enter key press for interactables
*
* @param {KeyboardEvent} event - The keyboard event
*/
function handleGlobalKeyDown(event) {
if (event.key === 'Enter') {
if (!(event.target instanceof HTMLElement))
return;
// Only count enter on this interactable if no modifier key is pressed
if (event.altKey || event.ctrlKey || event.shiftKey)
return;
// Traverse up the DOM tree to find the actual interactable element
let target = event.target;
while (target && !isKeyboardInteractable(target)) {
target = target.parentElement;
}
// Trigger click if a valid interactable is found and it's not disabled
if (target && !target.classList.contains(DISABLED_CONTROL_CLASS)) {
console.debug('Triggering click on keyboard-focused interactable control via Enter', target);
target.click();
}
}
}
/**
* Initializes several keyboard functionalities for ST
*/
export function initKeyboard() {
// Start observing the body for added elements and attribute changes
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class']
});
// Initialize already existing controls
initializeInteractables();
initializeScrollResetBehaviors();
// Add a global keydown listener
document.addEventListener('keydown', handleGlobalKeyDown);
}

View File

@@ -1,156 +1,262 @@
import { animation_duration, animation_easing } from '../script.js';
import { delay } from './utils.js';
import { removeFromArray, runAfterAnimation, uuidv4 } 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,
'CANCELLED': undefined,
'AFFIRMATIVE': 1,
'NEGATIVE': 0,
'CANCELLED': null,
};
/**
* @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 (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`.
* @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
*/
/**
* @typedef {object} ShowPopupHelper
* Local implementation of the helper functionality to show several popups.
*
* Should be called via `Popup.show.xxxx()`.
*/
const showPopupHelper = {
/**
* Asynchronously displays an input popup with the given header and text, and returns the user's input.
*
* @param {string} header - The header text for the popup.
* @param {string} text - The main text for the popup.
* @param {string} [defaultValue=''] - The default value for the input field.
* @param {PopupOptions} [popupOptions={}] - Options for the popup.
* @return {Promise<string?>} A Promise that resolves with the user's input.
*/
input: async (header, text, defaultValue = '', popupOptions = {}) => {
const content = PopupUtils.BuildTextWithHeader(header, text);
const popup = new Popup(content, POPUP_TYPE.INPUT, defaultValue, popupOptions);
const value = await popup.show();
return value ? String(value) : null;
},
}
export class Popup {
/**@type {POPUP_TYPE}*/ type;
/** @type {POPUP_TYPE} */ type;
/**@type {HTMLElement}*/ dom;
/**@type {HTMLElement}*/ dlg;
/**@type {HTMLElement}*/ text;
/**@type {HTMLTextAreaElement}*/ input;
/**@type {HTMLElement}*/ ok;
/**@type {HTMLElement}*/ cancel;
/** @type {string} */ id;
/**@type {POPUP_RESULT}*/ result;
/**@type {any}*/ value;
/** @type {HTMLDialogElement} */ dlg;
/** @type {HTMLElement} */ body;
/** @type {HTMLElement} */ content;
/** @type {HTMLTextAreaElement} */ input;
/** @type {HTMLElement} */ controls;
/** @type {HTMLElement} */ ok;
/** @type {HTMLElement} */ cancel;
/** @type {POPUP_RESULT|number?} */ defaultResult;
/** @type {CustomPopupButton[]|string[]?} */ customButtons;
/**@type {Promise}*/ promise;
/**@type {Function}*/ resolver;
/**@type {Function}*/ keyListenerBound;
/** @type {POPUP_RESULT|number} */ result;
/** @type {any} */ value;
/** @type {HTMLElement} */ lastFocus;
/** @type {Promise<any>} */ promise;
/** @type {(result: any) => any} */ resolver;
/**
* @typedef {{okButton?: string, cancelButton?: string, rows?: number, wide?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean }} PopupOptions - Options for the popup.
* @param {JQuery<HTMLElement>|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 content, type, inputValue, and options
*
* @param {JQuery<HTMLElement>|string|Element} content - Text content 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, wider, large, allowHorizontalScrolling, allowVerticalScrolling } = {}) {
constructor(content, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, wider = false, large = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null } = {}) {
Popup.util.popups.push(this);
// Make this popup uniquely identifiable
this.id = uuidv4();
this.type = type;
/**@type {HTMLTemplateElement}*/
const template = document.querySelector('#shadow_popup_template');
const template = document.querySelector('#popup_template');
// @ts-ignore
this.dom = template.content.cloneNode(true).querySelector('.shadow_popup');
const dlg = this.dom.querySelector('.dialogue_popup');
// @ts-ignore
this.dlg = dlg;
this.text = this.dom.querySelector('.dialogue_popup_text');
this.input = this.dom.querySelector('.dialogue_popup_input');
this.ok = this.dom.querySelector('.dialogue_popup_ok');
this.cancel = this.dom.querySelector('.dialogue_popup_cancel');
this.dlg = template.content.cloneNode(true).querySelector('.popup');
this.body = this.dlg.querySelector('.popup-body');
this.content = this.dlg.querySelector('.popup-content');
this.input = this.dlg.querySelector('.popup-input');
this.controls = this.dlg.querySelector('.popup-controls');
this.ok = this.dlg.querySelector('.popup-button-ok');
this.cancel = this.dlg.querySelector('.popup-button-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');
this.dlg.setAttribute('data-id', this.id);
if (wide) this.dlg.classList.add('wide_dialogue_popup');
if (wider) this.dlg.classList.add('wider_dialogue_popup');
if (large) this.dlg.classList.add('large_dialogue_popup');
if (allowHorizontalScrolling) this.dlg.classList.add('horizontal_scrolling_dialogue_popup');
if (allowVerticalScrolling) this.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-button-cancel');
this.defaultResult = defaultResult;
this.customButtons = customButtons;
this.customButtons?.forEach((x, index) => {
/** @type {CustomPopupButton} */
const button = typeof x === 'string' ? { text: x, result: index + 2 } : x;
const buttonElement = document.createElement('div');
buttonElement.classList.add('menu_button', 'popup-button-custom', 'result-control');
buttonElement.classList.add(...(button.classes ?? []));
buttonElement.setAttribute('data-result', String(button.result ?? undefined));
buttonElement.textContent = button.text;
buttonElement.tabIndex = 0;
if (button.action) buttonElement.addEventListener('click', button.action);
if (button.result) buttonElement.addEventListener('click', () => this.complete(button.result));
if (button.appendAtEnd) {
this.controls.appendChild(buttonElement);
} else {
this.controls.insertBefore(buttonElement, this.ok);
}
});
// 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-button-yes');
if (!cancelButton) this.cancel.textContent = template.getAttribute('popup-button-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-button-save');
break;
}
default: {
// illegal argument
console.warn('Unknown popup type.', type);
break;
}
}
this.input.value = inputValue;
this.input.rows = rows ?? 1;
this.text.innerHTML = '';
if (text instanceof jQuery) {
$(this.text).append(text);
} else if (text instanceof HTMLElement) {
this.text.append(text);
} else if (typeof text == 'string') {
this.text.innerHTML = text;
this.content.innerHTML = '';
if (content instanceof jQuery) {
$(this.content).append(content);
} else if (content instanceof HTMLElement) {
this.content.append(content);
} else if (typeof content == 'string') {
this.content.innerHTML = content;
} else {
// illegal argument
console.warn('Unknown popup text type. Should be jQuery, HTMLElement or string.', content);
}
this.input.addEventListener('keydown', (evt) => {
if (evt.key != 'Enter' || evt.altKey || evt.ctrlKey || evt.shiftKey) return;
evt.preventDefault();
evt.stopPropagation();
this.completeAffirmative();
});
// Already prepare the auto-focus control by adding the "autofocus" attribute, this should be respected by showModal()
this.setAutoFocus({ applyAutoFocus: true });
this.ok.addEventListener('click', () => this.completeAffirmative());
this.cancel.addEventListener('click', () => this.completeNegative());
// Set focus event that remembers the focused element
this.dlg.addEventListener('focusin', (evt) => { if (evt.target instanceof HTMLElement && evt.target != this.dlg) this.lastFocus = evt.target; });
this.ok.addEventListener('click', () => this.complete(POPUP_RESULT.AFFIRMATIVE));
this.cancel.addEventListener('click', () => this.complete(POPUP_RESULT.NEGATIVE));
const keyListener = (evt) => {
switch (evt.key) {
case 'Escape': {
// does it really matter where we check?
const topModal = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)?.closest('.shadow_popup');
if (topModal == this.dom) {
evt.preventDefault();
evt.stopPropagation();
this.completeCancelled();
window.removeEventListener('keydown', keyListenerBound);
break;
}
// Check if we are the currently active popup
if (this.dlg != document.activeElement?.closest('.popup'))
return;
this.complete(POPUP_RESULT.CANCELLED);
evt.preventDefault();
evt.stopPropagation();
window.removeEventListener('keydown', keyListenerBound);
break;
}
case 'Enter': {
// CTRL+Enter counts as a closing action, but all other modifiers (ALT, SHIFT) should not trigger this
if (evt.altKey || evt.shiftKey)
return;
// Check if we are the currently active popup
if (this.dlg != document.activeElement?.closest('.popup'))
return;
// Check if the current focus is a result control. Only should we apply the compelete action
const resultControl = document.activeElement?.closest('.result-control');
if (!resultControl)
return;
const result = Number(document.activeElement.getAttribute('data-result') ?? this.defaultResult);
this.complete(result);
evt.preventDefault();
evt.stopPropagation();
window.removeEventListener('keydown', keyListenerBound);
break;
}
}
};
const keyListenerBound = keyListener.bind(this);
window.addEventListener('keydown', keyListenerBound);
this.dlg.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<string|number|boolean?>} A promise that resolves with the value of the popup when it is completed.
*/
async show() {
document.body.append(this.dom);
this.dom.style.display = 'block';
switch (this.type) {
case POPUP_TYPE.INPUT: {
this.input.focus();
break;
}
}
document.body.append(this.dlg);
$(this.dom).transition({
opacity: 1,
duration: animation_duration,
easing: animation_easing,
});
// Run opening animation
this.dlg.setAttribute('opening', '');
this.dlg.showModal();
// We need to fix the toastr to be present inside this dialog
fixToastrForDialogs();
runAfterAnimation(this.dlg, () => {
this.dlg.removeAttribute('opening');
})
this.promise = new Promise((resolve) => {
this.resolver = resolve;
@@ -158,80 +264,201 @@ export class Popup {
return this.promise;
}
completeAffirmative() {
switch (this.type) {
case POPUP_TYPE.TEXT:
case POPUP_TYPE.CONFIRM: {
this.value = true;
break;
}
case POPUP_TYPE.INPUT: {
this.value = this.input.value;
break;
setAutoFocus({ applyAutoFocus = false } = {}) {
/** @type {HTMLElement} */
let control;
// Try to find if we have an autofocus control already present
control = this.dlg.querySelector('[autofocus]');
// If not, find the default control for this popup type
if (!control) {
switch (this.type) {
case POPUP_TYPE.INPUT: {
control = this.input;
break;
}
default:
// Select default button
control = this.controls.querySelector(`[data-result="${this.defaultResult}"]`);
break;
}
}
this.result = POPUP_RESULT.AFFIRMATIVE;
if (applyAutoFocus) {
control.setAttribute('autofocus', '');
} else {
control.focus();
}
}
/**
* Completes the popup and sets its result and value
*
* The completion handling will make the popup return the result to the original show promise.
*
* There will be two different types of result values:
* - popup with `POPUP_TYPE.INPUT` will return the input value - or `false` on negative and `null` on cancelled
* - All other will return the result value as provided as `POPUP_RESULT` or a custom number value
*
* @param {POPUP_RESULT|number} result - The result of the popup (either an existing `POPUP_RESULT` or a custom result value)
*/
complete(result) {
// In all cases besides INPUT the popup value should be the result
/** @type {POPUP_RESULT|number|boolean|string?} */
let value = result;
// Input type have special results, so the input can be accessed directly without the need to save the popup and access both result and value
if (this.type === POPUP_TYPE.INPUT) {
if (result >= POPUP_RESULT.AFFIRMATIVE) value = this.input.value;
else if (result === POPUP_RESULT.NEGATIVE) value = false;
else if (result === POPUP_RESULT.CANCELLED) value = null;
else value = false; // Might a custom negative value?
}
this.value = value;
this.result = result;
Popup.util.lastResult = { value, result };
this.hide();
}
completeNegative() {
switch (this.type) {
case POPUP_TYPE.TEXT:
case POPUP_TYPE.CONFIRM:
case POPUP_TYPE.INPUT: {
this.value = false;
break;
}
}
this.result = POPUP_RESULT.NEGATIVE;
this.hide();
}
completeCancelled() {
switch (this.type) {
case POPUP_TYPE.TEXT:
case POPUP_TYPE.CONFIRM:
case POPUP_TYPE.INPUT: {
this.value = null;
break;
}
}
this.result = POPUP_RESULT.CANCELLED;
this.hide();
}
/**
* Hides the popup, using the internal resolver to return the value to the original show promise
* @private
*/
hide() {
$(this.dom).transition({
opacity: 0,
duration: animation_duration,
easing: animation_easing,
});
delay(animation_duration).then(() => {
this.dom.remove();
// We close the dialog, first running the animation
this.dlg.setAttribute('closing', '');
// Once the hiding starts, we need to fix the toastr to the layer below
fixToastrForDialogs();
// After the dialog is actually completely closed, remove it from the DOM
runAfterAnimation(this.dlg, () => {
// Call the close on the dialog
this.dlg.close();
// Remove it from the dom
this.dlg.remove();
// Remove it from the popup references
removeFromArray(Popup.util.popups, this);
// If there is any popup below this one, see if we can set the focus
if (Popup.util.popups.length > 0) {
const activeDialog = document.activeElement?.closest('.popup');
const id = activeDialog?.getAttribute('data-id');
const popup = Popup.util.popups.find(x => x.id == id);
if (popup) {
if (popup.lastFocus) popup.lastFocus.focus();
else popup.setAutoFocus();
}
}
});
this.resolver(this.value);
}
/**
* Show a popup with any of the given helper methods. Use `await` to make them blocking.
*/
static show = showPopupHelper;
/**
* Utility for popup and popup management.
*
* Contains the list of all currently open popups, and it'll remember the result of the last closed popup.
*/
static util = {
/** @type {Popup[]} Remember all popups */
popups: [],
/** @type {{value: any, result: POPUP_RESULT|number?}?} Last popup result */
lastResult: null,
/** @returns {boolean} Checks if any modal popup dialog is open */
isPopupOpen() {
return Popup.util.popups.length > 0;
},
/**
* Returns the topmost modal layer in the document. If there is an open dialog popup,
* it returns the dialog element. Otherwise, it returns the document body.
*
* @return {HTMLElement} The topmost modal layer element
*/
getTopmostModalLayer() {
return getTopmostModalLayer();
},
}
}
class PopupUtils {
static BuildTextWithHeader(header, text) {
return `
<h3>${header}</h1>
${text}`;
}
}
/**
* Displays a blocking popup with a given text and type.
* @param {JQuery<HTMLElement>|string|Element} text - Text to display in the popup.
* Displays a blocking popup with a given content and type
*
* @param {JQuery<HTMLElement>|string|Element} content - Content or 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<POPUP_RESULT|string|boolean?>} 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, wider, large, allowHorizontalScrolling, allowVerticalScrolling } = {}) {
export function callGenericPopup(content, type, inputValue = '', popupOptions = {}) {
const popup = new Popup(
text,
content,
type,
inputValue,
{ okButton, cancelButton, rows, wide, wider, large, allowHorizontalScrolling, allowVerticalScrolling },
popupOptions,
);
return popup.show();
}
/**
* Returns the topmost modal layer in the document. If there is an open dialog,
* it returns the dialog element. Otherwise, it returns the document body.
*
* @return {HTMLElement} The topmost modal layer element
*/
export function getTopmostModalLayer() {
const dlg = Array.from(document.querySelectorAll('dialog[open]:not([closing])')).pop();
if (dlg instanceof HTMLElement) return dlg;
return document.body;
}
/**
* Fixes the issue with toastr not displaying on top of the dialog by moving the toastr container inside the dialog or back to the main body
*/
export function fixToastrForDialogs() {
// Hacky way of getting toastr to actually display on top of the popup...
const dlg = Array.from(document.querySelectorAll('dialog[open]:not([closing])')).pop();
let toastContainer = document.getElementById('toast-container');
const isAlreadyPresent = !!toastContainer;
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.setAttribute('id', 'toast-container');
if (toastr.options.positionClass) toastContainer.classList.add(toastr.options.positionClass);
}
// Check if toastr is already a child. If not, we need to move it inside this dialog.
// This is either the existing toastr container or the newly created one.
if (dlg && !dlg.contains(toastContainer)) {
dlg?.appendChild(toastContainer);
return;
}
// Now another case is if we only have one popup and that is currently closing. In that case the toastr container exists,
// but we don't have an open dialog to move it into. It's just inside the existing one that will be gone in milliseconds.
// To prevent new toasts from being showing up in there and then vanish in an instant,
// we move the toastr back to the main body
if (!dlg && isAlreadyPresent) {
document.body.appendChild(toastContainer);
}
}

View File

@@ -4,22 +4,26 @@ import {
this_chid,
callPopup,
menu_type,
getCharacters,
entitiesFilter,
printCharactersDebounced,
buildAvatarList,
eventSource,
event_types,
DEFAULT_PRINT_TIMEOUT,
} from '../script.js';
// eslint-disable-next-line no-unused-vars
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, 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';
import { INTERACTABLE_CONTROL_CLASS } from './keyboard.js';
import { SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
import { SlashCommandExecutor } from './slash-commands/SlashCommandExecutor.js';
@@ -46,6 +50,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');
@@ -329,7 +335,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;
}
@@ -468,29 +474,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<HTMLElement>|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);
@@ -588,10 +599,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);
@@ -612,7 +623,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) {
@@ -626,7 +637,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;
@@ -635,70 +646,200 @@ 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 tag of new_tags) {
let foundTag = tags.find(t => t.name.toLowerCase() === tag.toLowerCase());
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(`<h3>Importing Tags For ${imported_char.name}</h3><p>${existingTags.length} existing tags have been found${existingTagsString}.</p>`, 'text');
} else {
selected_tags = await callPopup(`<h3>Importing Tags For ${imported_char.name}</h3><p>${existingTags.length} existing tags have been found${existingTagsString}.</p><p>The following ${newTags.length} new tags will be imported.</p>`, '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 = tags.find(t => t.name === 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>} 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:<br />${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<string[]>} 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<string[]>} 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 => `&quot;${button.text}&quot;`);
const customButtonsString = customButtonsCaptions.slice(0, -1).join(', ') + ' or ' + customButtonsCaptions.slice(-1);
const popupContent = $(`
<h3>Import Tags For ${character.name}</h3>
<div class="import_avatar_placeholder"></div>
<div class="import_tags_content justifyLeft">
<small>
Click remove on any tag to remove it from this import.<br />
Select one of the import options to finish importing the tags.
</small>
<h4 class="m-t-1">Existing Tags</h4>
<div id="import_existing_tags_list" class="tags"></div>
<h4 class="m-t-1">New Tags</h4>
<div id="import_new_tags_list" class="tags"></div>
<small>
<label class="checkbox flex-container alignitemscenter flexNoGap m-t-3" for="import_remember_option">
<input type="checkbox" id="import_remember_option" name="import_remember_option" />
<span data-i18n="Remember my choice">
Remember my choice
<div class="fa-solid fa-circle-info opacity50p" data-i18n="[title]Remember the chosen import option\nIf ${customButtonsString} is selected, this dialog will not show up anymore.\nTo change this, go to the settings and modify &quot;Tag Import Option&quot;.\n\nIf the &quot;Import&quot; option is chosen, the global setting will stay on &quot;Ask&quot;."
title="Remember the chosen import option\nIf ${customButtonsString} is selected, this dialog will not show up anymore.\nTo change this, go to the settings and modify &quot;Tag Import Option&quot;.\n\nIf the &quot;Import&quot; option is chosen, the global setting will stay on &quot;Ask&quot;.">
</div>
</span>
</label>
</small>
</div>`);
// 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
* @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, { 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}
* @returns {Tag} the newly created tag, or the existing tag if it already exists (with a logged warning)
*/
function createNewTag(tagName) {
const tag = {
const existing = getTag(tagName);
if (existing) {
toastr.warning(`Cannot create new tag. A tag with the name already exists:<br />${existing.name}`, 'Creating Tag', { escapeHtml: false });
return existing;
}
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,
@@ -708,16 +849,14 @@ function createNewTag(tagName) {
color2: '',
create_date: Date.now(),
};
tags.push(tag);
console.debug('Created new tag', tag.name, 'with id', tag.id);
return tag;
}
/**
* @typedef {object} TagOptions - Options for tag behavior. (Same object will be passed into "appendTagToList")
* @property {boolean} [removable=false] - Whether tags can be removed.
* @property {boolean} [selectable=false] - Whether tags can be selected.
* @property {boolean} [isFilter=false] - Whether tags can be selected as a filter.
* @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.
*/
@@ -725,7 +864,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.
@@ -749,8 +888,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
@@ -834,7 +974,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, isFilter = false, action = undefined, removeAction = undefined, isGeneralList = false, skipExistsCheck = false } = {}) {
if (!listElement) {
return;
}
@@ -852,6 +992,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);
@@ -867,19 +1014,20 @@ function appendTagToList(listElement, tag, { removable = false, selectable = fal
// We could have multiple ways of actions passed in. The manual arguments have precendence in front of a specified tag action
const clickableAction = action ?? tag.action;
// If this is a tag for a general list and its either selectable or actionable, lets mark its current state
if ((selectable || clickableAction) && isGeneralList) {
// If this is a tag for a general list and its either a filter or actionable, lets mark its current state
if ((isFilter || clickableAction) && isGeneralList) {
toggleTagThreeState(tagElement, { stateOverride: tag.filter_state ?? DEFAULT_FILTER_STATE });
}
if (selectable) {
if (isFilter) {
tagElement.on('click', () => onTagFilterClick.bind(tagElement)(listElement));
tagElement.addClass(INTERACTABLE_CONTROL_CLASS);
}
if (clickableAction) {
const filter = getFilterHelper($(listElement));
tagElement.on('click', (e) => clickableAction.bind(tagElement)(filter, e));
tagElement.addClass('clickable-action');
tagElement.addClass('clickable-action').addClass(INTERACTABLE_CONTROL_CLASS);
}
$(listElement).append(tagElement);
@@ -888,6 +1036,7 @@ function appendTagToList(listElement, tag, { removable = false, selectable = fal
function onTagFilterClick(listElement) {
const tagId = $(this).attr('id');
const existingTag = tags.find((tag) => tag.id === tagId);
const parent = $(this).parents('.tags');
let state = toggleTagThreeState($(this));
@@ -898,6 +1047,9 @@ function onTagFilterClick(listElement) {
// We don't print anything manually, updating the filter will automatically trigger a redraw of all relevant stuff
runTagFilters(listElement);
// Focus the tag again we were at, if possible. To improve keyboard navigation
setTimeout(() => parent.find(`.tag[id="${tagId}"]`).trigger('focus'), DEFAULT_PRINT_TIMEOUT + 1);
}
/**
@@ -975,7 +1127,7 @@ function printTagFilters(type = tag_filter_types.character) {
const characterTagIds = Object.values(tag_map).flat();
const tagsToDisplay = tags.filter(x => characterTagIds.includes(x.id)).sort(compareTagsForSort);
printTagList($(FILTER_SELECTOR), { empty: false, tags: tagsToDisplay, tagOptions: { selectable: true, isGeneralList: true } });
printTagList($(FILTER_SELECTOR), { empty: false, tags: tagsToDisplay, tagOptions: { isFilter: true, isGeneralList: true } });
// Print bogus folder navigation
const bogusDrilldown = $(FILTER_SELECTOR).siblings('.rm_tag_bogus_drilldown');
@@ -1010,6 +1162,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);
@@ -1029,7 +1187,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);
}
@@ -1047,7 +1205,7 @@ function onGroupCreateClick() {
// Nothing to do here at the moment. Tags in group interface get automatically redrawn.
}
export function applyTagsOnCharacterSelect() {
export function applyTagsOnCharacterSelect(chid = null) {
// If we are in create window, we cannot simply redraw, as there are no real persisted tags. Grab them, and pass them in
if (menu_type === 'create') {
const currentTagIds = $('#tagList').find('.tag').map((_, el) => $(el).attr('id')).get();
@@ -1056,11 +1214,11 @@ export function applyTagsOnCharacterSelect() {
return;
}
const chid = this_chid ? Number(this_chid) : null;
chid = chid ?? Number(this_chid);
printTagList($('#tagList'), { forEntityOrKey: chid, tagOptions: { removable: true } });
}
export function applyTagsOnGroupSelect() {
export function applyTagsOnGroupSelect(groupId = null) {
// If we are in create window, we explicitly have to tell the system to print for the new group, not the one selected in the background
if (menu_type === 'group_create') {
const currentTagIds = $('#groupTagList').find('.tag').map((_, el) => $(el).attr('id')).get();
@@ -1069,7 +1227,7 @@ export function applyTagsOnGroupSelect() {
return;
}
const groupId = selected_group;
groupId = groupId ?? Number(selected_group);
printTagList($('#groupTagList'), { forEntityOrKey: groupId, tagOptions: { removable: true } });
}
@@ -1091,9 +1249,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(`
@@ -1134,13 +1290,10 @@ function onViewTagsListClick() {
const tagContainer = $('<div class="tag_view_list_tags ui-sortable"></div>');
html.append(tagContainer);
callPopup(html, 'text', null, { allowVerticalScrolling: true });
printViewTagList();
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 });
}
/**
@@ -1202,7 +1355,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.');
}
@@ -1329,7 +1482,7 @@ async function onTagRestoreFileSelect(e) {
printCharactersDebounced();
saveSettingsDebounced();
onViewTagsListClick();
await onViewTagsListClick();
}
function onBackupRestoreClick() {
@@ -1351,14 +1504,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');
}
function appendViewTagToList(list, tag, everything) {
@@ -1382,25 +1539,47 @@ function appendViewTagToList(list, tag, everything) {
const primaryColorPicker = $('<toolcool-color-picker></toolcool-color-picker>')
.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 = $('<toolcool-color-picker></toolcool-color-picker>')
.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_view_color_picker[data-value="color"]').append(primaryColorPicker)
.append($('<div class="fas fa-link fa-xs link_icon right_menu_button" title="Link to theme color"></div>'));
template.find('.tag_view_color_picker[data-value="color2"]').append(secondaryColorPicker)
.append($('<div class="fas fa-link fa-xs link_icon right_menu_button" title="Link to theme color"></div>'));
template.find('.tag_as_folder').attr('id', tagAsFolderId);
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);
updateDrawTagFolder(template, tag);
// We prevent the popup from auto-close on Escape press on the color pickups. If the user really wants to, he can hit it again
// Not the "cleanest" way, that would be actually using and observer, remembering whether the popup was open just before, but eh
// Not gonna invest too much time into this small control here
let lastHit = 0;
template.on('keydown', (evt) => {
if (evt.key === 'Escape') {
if (evt.target === primaryColorPicker[0] || evt.target === secondaryColorPicker[0]) {
if (Date.now() - lastHit < 5000) // If user hits it twice in five seconds
return;
lastHit = Date.now();
evt.stopPropagation();
evt.preventDefault();
}
}
});
// @ts-ignore
$(colorPickerId).color = tag.color;
// @ts-ignore
$(colorPicker2Id).color = tag.color2;
updateDrawTagFolder(template, tag);
}
function onTagAsFolderClick() {
@@ -1438,20 +1617,56 @@ 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 popupContent = $(`
<h3>Delete Tag</h3>
<div>Do you want to delete the tag <div id="tag_to_delete" class="tags_inline inline-flex margin-r2"></div>?</div>
<div class="m-t-2 marginBot5">If you want to merge all references to this tag into another tag, select it below:</div>
<select id="merge_tag_select">
<option value="">--- None ---</option>
${otherTags.map(x => `<option value="${x.id}">${x.name}</option>`).join('')}
</select>`);
appendTagToList(popupContent.find('#tag_to_delete'), tag);
// 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
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 result = await callGenericPopup(popupContent, POPUP_TYPE.CONFIRM);
if (result !== POPUP_RESULT.AFFIRMATIVE) {
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();
$(`.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();
}
@@ -1461,35 +1676,41 @@ 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) {
/**
* 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;
$(evt.target).closest('.tag_view_color_picker').find('.link_icon').toggle(!isDefaultColor);
const id = $(evt.target).closest('.tag_view_item').attr('id');
const newColor = evt.detail.rgba;
$(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);
let newColor = evt.detail.rgba;
if (isDefaultColor) 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);
const id = $(evt.target).closest('.tag_view_item').attr('id');
const newColor = evt.detail.rgba;
$(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');
@@ -1529,9 +1750,7 @@ 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(tagContainer, empty = true) {
if (empty) tagContainer.empty();
const everything = Object.values(tag_map).flat();
const sortedTags = sortTags(tags);
@@ -1569,7 +1788,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);
}
@@ -1580,21 +1799,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',
@@ -1604,8 +1808,7 @@ function registerTagsSlashCommands() {
if (!key) return 'false';
const tag = paraGetTag(tagName, { allowCreate: true });
if (!tag) return 'false';
const result = addTagToEntity(tag, key);
updateTagsList();
const result = addTagsToEntity(tag, key);
return String(result);
},
namedArgumentList: [
@@ -1658,7 +1861,6 @@ function registerTagsSlashCommands() {
const tag = paraGetTag(tagName);
if (!tag) return 'false';
const result = removeTagFromEntity(tag, key);
updateTagsList();
return String(result);
},
namedArgumentList: [
@@ -1781,22 +1983,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}"]`));
}
});

View File

@@ -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.
@@ -1518,6 +1532,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.
*
@@ -1774,3 +1817,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)}`;
}