mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
Merge pull request #2330 from Wolfsblvt/smol-tag-improvements
Smol tag improvements & Huuuuuge popup/dialog rework (heh)
This commit is contained in:
@@ -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
|
||||
|
@@ -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();
|
||||
|
@@ -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');
|
||||
}
|
||||
|
||||
|
@@ -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
107
public/scripts/dragdrop.js
vendored
Normal 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);
|
||||
}
|
||||
}
|
162
public/scripts/dynamic-styles.js
Normal file
162
public/scripts/dynamic-styles.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
@@ -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');
|
||||
|
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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
243
public/scripts/keyboard.js
Normal 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);
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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 => `"${button.text}"`);
|
||||
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 "Tag Import Option".\n\nIf the "Import" option is chosen, the global setting will stay on "Ask"."
|
||||
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 "Tag Import Option".\n\nIf the "Import" option is chosen, the global setting will stay on "Ask".">
|
||||
</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}"]`));
|
||||
}
|
||||
});
|
||||
|
||||
|
@@ -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)}`;
|
||||
}
|
||||
|
Reference in New Issue
Block a user