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

Smol tag improvements & Huuuuuge popup/dialog rework (heh)
This commit is contained in:
Cohee 2024-06-18 01:58:52 +03:00 committed by GitHub
commit c3cbf33ba0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 2007 additions and 764 deletions

122
public/css/animations.css Normal file
View File

@ -0,0 +1,122 @@
/* Fade animations with opacity */
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
/* Pop animations with opacity and vertical scaling */
@keyframes pop-in {
0% {
opacity: 0;
transform: scaleY(0);
}
/* Make the scaling faster on pop-in, otherwise it looks a bit weird */
33% {
transform: scaleY(1);
}
100% {
opacity: 1;
transform: scaleY(1);
}
}
@keyframes pop-out {
0% {
opacity: 1;
transform: scaleY(1);
}
100% {
opacity: 0;
transform: scaleY(0);
}
}
/* Flashing for highlighting animation */
@keyframes flash {
20%,
60%,
100% {
opacity: 1;
}
0%,
40%,
80% {
opacity: 0.2;
}
}
/* Pulsing highlight, slightly resizing the element */
@keyframes pulse {
from {
transform: scale(1);
filter: brightness(1.1);
}
to {
transform: scale(1.01);
filter: brightness(1.3);
}
}
/* Ellipsis animation */
@keyframes ellipsis {
0% {
content: ""
}
25% {
content: "."
}
50% {
content: ".."
}
75% {
content: "..."
}
}
/* HEINOUS */
@keyframes infinite-spinning {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* STscript animation */
@keyframes script_progress_pulse {
0%,
100% {
border-top-color: var(--progColor);
}
50% {
border-top-color: var(--progFlashColor);
}
}

View File

@ -98,7 +98,7 @@
font-weight: bold;
}
.logprobs_top_candidate:not([disabled]):hover, .logprobs_top_candidate:not([disabled]):focus {
.logprobs_top_candidate:not([disabled]):hover {
background-color: rgba(0, 0, 0, 0.3);
}

131
public/css/popup.css Normal file
View File

@ -0,0 +1,131 @@
dialog {
color: var(--SmartThemeBodyColor);
}
/* Closed state of the dialog */
.popup {
width: 500px;
text-align: center;
box-shadow: 0px 0px 14px var(--black70a);
border: 1px solid var(--SmartThemeBorderColor);
padding: 4px 14px;
background-color: var(--SmartThemeBlurTintColor);
border-radius: 10px;
display: flex;
flex-direction: column;
/* Overflow visible so elements (like toasts) can appear outside of the dialog. '.popup-body' is hiding overflow for the real content. */
overflow: visible;
/* Fix weird animation issue with font-scaling during popup open */
backface-visibility: hidden;
transform: translateZ(0);
-webkit-font-smoothing: subpixel-antialiased;
}
.popup .popup-body {
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
height: 100%;
padding: 1px;
}
.popup .popup-content {
margin-top: 10px;
padding: 0 8px;
overflow: hidden;
flex-grow: 1;
}
.popup .popup-content h3:first-child {
/* No double spacing for the first heading needed, the .popup-content already has margin */
margin-top: 0px;
}
.popup.vertical_scrolling_dialogue_popup .popup-content {
overflow-y: auto;
}
.popup.horizontal_scrolling_dialogue_popup .popup-content {
overflow-x: auto;
}
/* Opening animation */
.popup[opening] {
animation: pop-in var(--animation-duration-slow) ease-in-out;
}
.popup[opening]::backdrop {
animation: fade-in var(--animation-duration-slow) ease-in-out;
}
/* Open state of the dialog */
.popup[open] {
color: var(--SmartThemeBodyColor);
}
.popup[open]::backdrop {
backdrop-filter: blur(calc(var(--SmartThemeBlurStrength) * 2));
-webkit-backdrop-filter: blur(calc(var(--SmartThemeBlurStrength) * 2));
background-color: var(--black30a);
}
/* Closing animation */
.popup[closing] {
animation: pop-out var(--animation-duration-slow) ease-in-out;
}
.popup[closing]::backdrop {
animation: fade-out var(--animation-duration-slow) ease-in-out;
}
/* Fix toastr in dialogs by actually placing it at the top of the screen via transform */
.popup #toast-container {
height: 100svh;
top: calc(50% + var(--topBarBlockSize));
left: 50%;
transform: translate(-50%, -50%);
}
.popup-input {
margin-top: 10px;
}
.popup-controls {
margin-top: 10px;
display: flex;
align-self: center;
gap: 20px;
}
.menu_button.menu_button_default {
box-shadow: 0 0 5px var(--white20a);
}
.menu_button.popup-button-ok {
background-color: var(--crimson70a);
cursor: pointer;
}
.menu_button.popup-button-ok:hover {
background-color: var(--crimson-hover);
}
.menu_button.popup-button-custom {
/* Custom buttons should not scale to smallest size, otherwise they will always break to multiline */
width: unset;
}
.popup-controls .menu_button {
/* Fix weird animation issue with fonts on brightness filter */
backface-visibility: hidden;
transform: translateZ(0);
-webkit-font-smoothing: subpixel-antialiased;
}
.popup-controls .menu_button:hover:focus-visible {
filter: brightness(1.3) saturate(1.3);
}

View File

@ -181,8 +181,9 @@
}
.select2-selection__choice__display {
/* Fix weird alignment on the left side */
margin-left: 1px;
/* Fix weird alignment of the inside block */
margin-left: 3px;
margin-right: 1px;
}
/* Styling for choice remove icon */

View File

@ -292,6 +292,14 @@
flex-wrap: nowrap;
}
.inline-flex {
display: inline-flex;
}
.inline-block {
display: inline-block;
}
.alignitemscenter,
.alignItemsCenter {
align-items: center;
@ -348,6 +356,10 @@
margin-right: 5px;
}
.margin-r2 {
margin-right: 2px;
}
.flex0 {
flex: 0;
}

View File

@ -14,7 +14,7 @@
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
gap: 6px;
margin-bottom: 5px;
}
@ -27,8 +27,19 @@
flex: 1;
}
.tag_view_color_picker {
position: relative;
}
.tag_view_color_picker .link_icon {
position: absolute;
top: 50%;
right: 0px;
opacity: 0.5;
}
.tag_delete {
padding-right: 0;
padding: 2px 4px;
color: var(--SmartThemeBodyColor) !important;
}
@ -108,6 +119,14 @@
opacity: 0.6;
}
#tagList .tag:has(.tag_remove:hover) {
opacity: 1;
}
#tagList .tag:has(.tag_remove:hover) .tag_name {
opacity: 0.6;
}
.tags.tags_inline {
opacity: 0.6;
column-gap: 0.2rem;
@ -164,6 +183,7 @@
.tag.selected {
opacity: 1 !important;
filter: none !important;
border: 1px solid lightgreen;
}
.tag.excluded {

View File

@ -33,7 +33,6 @@
<link rel="apple-touch-icon" sizes="144x144" href="img/apple-icon-144x144.png" />
<link rel="stylesheet" type="text/css" href="style.css">
<link rel="stylesheet" type="text/css" href="css/st-tailwind.css">
<link rel="stylesheet" type="text/css" href="css/tags.css">
<link rel="stylesheet" type="text/css" href="css/rm-groups.css">
<link rel="stylesheet" type="text/css" href="css/group-avatars.css">
<link rel="stylesheet" type="text/css" href="css/toggle-dependent.css">
@ -73,8 +72,8 @@
<div id="lm_button_panel_pin_div" title="Locked = AI Configuration panel will stay open" data-i18n="[title]AI Configuration panel will stay open">
<input type="checkbox" id="lm_button_panel_pin">
<label for="lm_button_panel_pin">
<div class="unchecked fa-solid fa-unlock "></div>
<div class="checked fa-solid fa-lock "></div>
<div class="unchecked fa-solid fa-unlock right_menu_button"></div>
<div class="checked fa-solid fa-lock right_menu_button"></div>
</label>
</div>
<div id="clickSlidersTips" data-i18n="clickslidertips" class="toggle-description wide100p editable-slider-notification">
@ -4476,14 +4475,14 @@
<div id="rm_button_panel_pin_div" class="alignitemsflexstart" title="Locked = Character Management panel will stay open" data-i18n="[title]Locked = Character Management panel will stay open">
<input type="checkbox" id="rm_button_panel_pin">
<label for="rm_button_panel_pin">
<div class="fa-solid unchecked fa-unlock" alt=""></div>
<div class="fa-solid checked fa-lock" alt=""></div>
<div class="fa-solid unchecked fa-unlock right_menu_button" alt=""></div>
<div class="fa-solid checked fa-lock right_menu_button" alt=""></div>
</label>
</div>
<div class="right_menu_button fa-solid fa-list-ul" id="rm_button_characters" title="Select/Create Characters" data-i18n="[title]Select/Create Characters"></div>
</div>
<div id="HotSwapWrapper" class="alignitemscenter flex-container margin0auto wide100p">
<div class="hotswap avatars_inline flex-container expander" data-i18n="[no_favs]Favorite characters to add them to HotSwaps" no_favs="Favorite characters to add them to HotSwaps"></div>
<div class="hotswap avatars_inline flex-container scroll-reset-container expander" data-i18n="[no_favs]Favorite characters to add them to HotSwaps" no_favs="Favorite characters to add them to HotSwaps"></div>
</div>
</div>
<hr>
@ -4491,7 +4490,7 @@
<div id="rm_PinAndTabs">
<div id="right-nav-panel-tabs" class="">
<div id="rm_button_selected_ch">
<h2></h2>
<h2 class="interactable"></h2>
</div>
<div id="result_info" class="flex-container" style="display: none;">
<div id="result_info_text" title="Token counts may be inaccurate and provided just for reference." data-i18n="[title]Token counts may be inaccurate and provided just for reference.">
@ -4842,21 +4841,19 @@
</div>
</div>
<!-- various fullscreen popups -->
<template id="shadow_popup_template" data-i18n="[popup_text_save]popup_text_save;[popup_text_yes]popup_text_yes;[popup_text_no]popup_text_no;[popup_text_cancel]popup_text_cancel;[popup_text_import]popup_text_import" popup_text_save="Save" popup_text_yes="Yes" popup_text_no="No" popup_text_cancel="Cancel" popup_text_import="Import"> <!-- localization data holder for popups -->
<div class="shadow_popup">
<div class="dialogue_popup">
<div class="dialogue_popup_holder">
<div class="dialogue_popup_text">
<h3 class="margin0">text</h3>
</div>
<textarea class="dialogue_popup_input text_pole" rows="1"></textarea>
<div class="dialogue_popup_controls">
<div class="dialogue_popup_ok menu_button" data-i18n="Delete">Delete</div>
<div class="dialogue_popup_cancel menu_button" data-i18n="Cancel">Cancel</div>
</div>
<template id="popup_template" data-i18n="[popup-button-save]popup-button-save;[popup-button-yes]popup-button-yes;[popup-button-no]popup-button-no;[popup-button-cancel]popup-button-cancel;[popup-button-import]popup-button-import" popup-button-save="Save" popup-button-yes="Yes" popup-button-no="No" popup-button-cancel="Cancel" popup-button-import="Import"> <!-- localization data holder for popups -->
<dialog class="popup">
<div class="popup-body">
<div class="popup-content">
<h3 class="popup-header">text</h3>
</div>
<textarea class="popup-input text_pole result-control" rows="1" data-result="1"></textarea>
<div class="popup-controls">
<div class="popup-button-ok menu_button result-control" data-i18n="Delete" data-result="1" tabindex="0">Delete</div>
<div class="popup-button-cancel menu_button result-control" data-i18n="Cancel" data-result="0" tabindex="0">Cancel</div>
</div>
</div>
</div>
</dialog>
</template>
<div id="shadow_popup">
<div id="dialogue_popup">
@ -4866,15 +4863,15 @@
</div>
<textarea id="dialogue_popup_input" class="text_pole" rows="1"></textarea>
<div id="dialogue_popup_controls">
<div id="dialogue_popup_ok" class="menu_button" data-i18n="Delete">Delete</div>
<div id="dialogue_popup_cancel" class="menu_button" data-i18n="Cancel">Cancel</div>
<div id="dialogue_popup_ok" class="menu_button" data-i18n="Delete" data-result="1">Delete</div>
<div id="dialogue_popup_cancel" class="menu_button" data-i18n="Cancel" data-result="0">Cancel</div>
</div>
</div>
</div>
</div>
<div id="character_popup" class="flex-container flexFlowColumn flexNoGap">
<div id="character_popup_text">
<h3 id="character_popup_text_h3" class="margin0"></h3> <span data-i18n="Advanced Defininitions">- Advanced
<h3 id="character_popup-button-h3" class="margin0"></h3> <span data-i18n="Advanced Defininitions">- Advanced
Definitions</span>
</div>
<hr class="margin-bot-10px">
@ -5193,8 +5190,8 @@
<div title="Tag as folder" class="tag_as_folder fa-solid fa-folder-open right_menu_button" data-i18n="[title]Use tag as folder">
<span class="tag_folder_indicator"></span>
</div>
<div class="tagColorPickerHolder"></div>
<div class="tagColorPicker2Holder"></div>
<div class="tag_view_color_picker" data-value="color"></div>
<div class="tag_view_color_picker" data-value="color2"></div>
<div class="tag_view_name" contenteditable="true"></div>
<div class="tag_view_counter"><span class="tag_view_counter_value"></span>&nbsp;entries</div>
<div title="Delete tag" class="tag_delete fa-solid fa-trash-can right_menu_button" data-i18n="[title]Delete tag"></div>
@ -5617,21 +5614,21 @@
</div>
</div>
<div class="mes_buttons">
<div title="Message Actions" class="extraMesButtonsHint fa-solid fa-ellipsis" data-i18n="[title]Message Actions"></div>
<div title="Message Actions" class="mes_button extraMesButtonsHint fa-solid fa-ellipsis" data-i18n="[title]Message Actions"></div>
<div class="extraMesButtons">
<div title="Translate message" class="mes_translate fa-solid fa-language" data-i18n="[title]Translate message"></div>
<div title="Generate Image" class="sd_message_gen fa-solid fa-paintbrush" data-i18n="[title]Generate Image"></div>
<div title="Narrate" class="mes_narrate fa-solid fa-bullhorn" data-i18n="[title]Narrate"></div>
<div title="Prompt" class="mes_prompt fa-solid fa-square-poll-horizontal " data-i18n="[title]Prompt" style="display: none;"></div>
<div title="Exclude message from prompts" class="mes_hide fa-solid fa-eye" data-i18n="[title]Exclude message from prompts"></div>
<div title="Include message in prompts" class="mes_unhide fa-solid fa-eye-slash" data-i18n="[title]Include message in prompts"></div>
<div title="Embed file or image" class="mes_embed fa-solid fa-paperclip" data-i18n="[title]Embed file or image"></div>
<div title="Create checkpoint" class="mes_create_bookmark fa-regular fa-solid fa-flag-checkered" data-i18n="[title]Create checkpoint"></div>
<div title="Create branch" class="mes_create_branch fa-regular fa-code-branch" data-i18n="[title]Create Branch"></div>
<div title="Copy" class="mes_copy fa-solid fa-copy " data-i18n="[title]Copy"></div>
<div title="Translate message" class="mes_button mes_translate fa-solid fa-language" data-i18n="[title]Translate message"></div>
<div title="Generate Image" class="mes_button sd_message_gen fa-solid fa-paintbrush" data-i18n="[title]Generate Image"></div>
<div title="Narrate" class="mes_button mes_narrate fa-solid fa-bullhorn" data-i18n="[title]Narrate"></div>
<div title="Prompt" class="mes_button mes_prompt fa-solid fa-square-poll-horizontal " data-i18n="[title]Prompt" style="display: none;"></div>
<div title="Exclude message from prompts" class="mes_button mes_hide fa-solid fa-eye" data-i18n="[title]Exclude message from prompts"></div>
<div title="Include message in prompts" class="mes_button mes_unhide fa-solid fa-eye-slash" data-i18n="[title]Include message in prompts"></div>
<div title="Embed file or image" class="mes_button mes_embed fa-solid fa-paperclip" data-i18n="[title]Embed file or image"></div>
<div title="Create checkpoint" class="mes_button mes_create_bookmark fa-regular fa-solid fa-flag-checkered" data-i18n="[title]Create checkpoint"></div>
<div title="Create branch" class="mes_button mes_create_branch fa-regular fa-code-branch" data-i18n="[title]Create Branch"></div>
<div title="Copy" class="mes_button mes_copy fa-solid fa-copy " data-i18n="[title]Copy"></div>
</div>
<div title="Open checkpoint chat" class="mes_bookmark fa-solid fa-flag" data-i18n="[title]Open checkpoint chat"></div>
<div title="Edit" class="mes_edit fa-solid fa-pencil " data-i18n="[title]Edit"></div>
<div title="Open checkpoint chat" class="mes_button mes_bookmark fa-solid fa-flag" data-i18n="[title]Open checkpoint chat"></div>
<div title="Edit" class="mes_button mes_edit fa-solid fa-pencil " data-i18n="[title]Edit"></div>
</div>
<div class="mes_edit_buttons">
<div class="mes_edit_done menu_button fa-solid fa-check" title="Confirm" data-i18n="[title]Confirm"></div>
@ -6251,7 +6248,7 @@
</form>
<div id="nonQRFormItems">
<div id="leftSendForm" class="alignContentCenter">
<div id="options_button" class="fa-solid fa-bars"></div>
<div id="options_button" class="fa-solid fa-bars interactable"></div>
</div>
<textarea id="send_textarea" name="text" data-i18n="[no_connection_text]Not connected to API!;[connected_text]Type a message, or /? for help" placeholder="Not connected to API!" no_connection_text="Not connected to API!" connected_text="Type a message, or /? for help"></textarea>
<div id="rightSendForm" class="alignContentCenter">
@ -6267,8 +6264,8 @@
<div id="mes_stop" title="Abort request" class="mes_stop" data-i18n="[title]Abort request">
<i class="fa-solid fa-circle-stop"></i>
</div>
<div id="mes_continue" class="fa-fw fa-solid fa-arrow-right displayNone" title="Continue the last message" data-i18n="[title]Continue the last message"></div>
<div id="send_but" class="fa-solid fa-paper-plane displayNone" title="Send a message" data-i18n="[title]Send a message"></div>
<div id="mes_continue" class="fa-fw fa-solid fa-arrow-right interactable displayNone" title="Continue the last message" data-i18n="[title]Continue the last message"></div>
<div id="send_but" class="fa-solid fa-paper-plane interactable displayNone" title="Send a message" data-i18n="[title]Send a message"></div>
</div>
</div>
</div>
@ -6432,15 +6429,6 @@
<script type="module" src="scripts/setting-search.js"></script>
<script type="module" src="scripts/server-history.js"></script>
<script type="module" src="script.js"></script>
<script>
// Configure toast library:
toastr.options.escapeHtml = true; // Prevent raw HTML inserts
toastr.options.timeOut = 4000; // How long the toast will display without user interaction
toastr.options.extendedTimeOut = 10000; // How long the toast will display after a user hovers over it
toastr.options.progressBar = true; // Visually indicate how long before a toast expires.
toastr.options.closeButton = true; // enable a close button
toastr.options.positionClass = "toast-top-center"; // Where to position the toast container
</script>
<script>
window.addEventListener('load', (event) => {
const documentHeight = () => {

View File

@ -885,11 +885,11 @@
"Bulk_edit_characters": "تحرير الشخصيات جميعها",
"Bulk select all characters": "تحديد كافة الشخصيات بالجملة",
"Bulk delete characters": "حذف الشخصيات جميعها",
"popup_text_save": "يحفظ",
"popup_text_yes": "نعم",
"popup_text_no": "لا",
"popup_text_cancel": "يلغي",
"popup_text_import": "يستورد",
"popup-button-save": "يحفظ",
"popup-button-yes": "نعم",
"popup-button-no": "لا",
"popup-button-cancel": "يلغي",
"popup-button-import": "يستورد",
"Advanced Defininitions": "تعريفات متقدمة",
"Prompt Overrides": "التجاوزات السريعة",
"(For Chat Completion and Instruct Mode)": "(لاستكمال الدردشة ووضع التعليمات)",

View File

@ -885,11 +885,11 @@
"Bulk_edit_characters": "Massenbearbeitung von Charakteren",
"Bulk select all characters": "Massenauswahl aller Zeichen",
"Bulk delete characters": "Massenlöschung von Charakteren",
"popup_text_save": "Speichern",
"popup_text_yes": "Ja",
"popup_text_no": "NEIN",
"popup_text_cancel": "Stornieren",
"popup_text_import": "Importieren",
"popup-button-save": "Speichern",
"popup-button-yes": "Ja",
"popup-button-no": "NEIN",
"popup-button-cancel": "Stornieren",
"popup-button-import": "Importieren",
"Advanced Defininitions": "Erweiterte Definitionen",
"Prompt Overrides": "Eingabeaufforderungsüberschreibungen",
"(For Chat Completion and Instruct Mode)": "(Für Chat-Abschluss und Anweisungsmodus)",

View File

@ -885,11 +885,11 @@
"Bulk_edit_characters": "Editar personajes masivamente",
"Bulk select all characters": "Seleccionar de forma masiva todos los personajes",
"Bulk delete characters": "Eliminar personajes masivamente",
"popup_text_save": "Ahorrar",
"popup_text_yes": "Sí",
"popup_text_no": "No",
"popup_text_cancel": "Cancelar",
"popup_text_import": "Importar",
"popup-button-save": "Ahorrar",
"popup-button-yes": "Sí",
"popup-button-no": "No",
"popup-button-cancel": "Cancelar",
"popup-button-import": "Importar",
"Advanced Defininitions": "Definiciones avanzadas",
"Prompt Overrides": "Anulaciones rápidas",
"(For Chat Completion and Instruct Mode)": "(Para completar el chat y el modo de instrucción)",

View File

@ -885,11 +885,11 @@
"Bulk_edit_characters": "Édition en masse des personnages",
"Bulk select all characters": "Sélection groupée de tous les caractères",
"Bulk delete characters": "Suppression en masse des personnages",
"popup_text_save": "Sauvegarder",
"popup_text_yes": "Oui",
"popup_text_no": "Non",
"popup_text_cancel": "Annuler",
"popup_text_import": "Importer",
"popup-button-save": "Sauvegarder",
"popup-button-yes": "Oui",
"popup-button-no": "Non",
"popup-button-cancel": "Annuler",
"popup-button-import": "Importer",
"Advanced Defininitions": "Définitions avancées",
"Prompt Overrides": "Remplacements d'invite",
"(For Chat Completion and Instruct Mode)": "(Pour l'achèvement du chat et le mode instruction)",

View File

@ -885,11 +885,11 @@
"Bulk_edit_characters": "Breyta mörgum persónum í einu",
"Bulk select all characters": "Velja alla stafi í magni",
"Bulk delete characters": "Eyða mörgum persónum í einu",
"popup_text_save": "Vista",
"popup_text_yes": "Já",
"popup_text_no": "Nei",
"popup_text_cancel": "Hætta við",
"popup_text_import": "Flytja inn",
"popup-button-save": "Vista",
"popup-button-yes": "Já",
"popup-button-no": "Nei",
"popup-button-cancel": "Hætta við",
"popup-button-import": "Flytja inn",
"Advanced Defininitions": "Ítarleg skilgreiningar",
"Prompt Overrides": "Hnekkja hvetjandi",
"(For Chat Completion and Instruct Mode)": "(Til að ljúka spjalli og leiðbeiningarham)",

View File

@ -885,11 +885,11 @@
"Bulk_edit_characters": "Modifica personaggi in blocco",
"Bulk select all characters": "Seleziona in blocco tutti i personaggi",
"Bulk delete characters": "Elimina personaggi in blocco",
"popup_text_save": "Salva",
"popup_text_yes": "SÌ",
"popup_text_no": "NO",
"popup_text_cancel": "Annulla",
"popup_text_import": "Importare",
"popup-button-save": "Salva",
"popup-button-yes": "SÌ",
"popup-button-no": "NO",
"popup-button-cancel": "Annulla",
"popup-button-import": "Importare",
"Advanced Defininitions": "Definizioni avanzate",
"Prompt Overrides": "Sostituzioni richieste",
"(For Chat Completion and Instruct Mode)": "(Per il completamento della chat e la modalità istruzione)",

View File

@ -885,11 +885,11 @@
"Bulk_edit_characters": "キャラクターを一括編集",
"Bulk select all characters": "すべての文字を一括選択",
"Bulk delete characters": "キャラクターを一括削除",
"popup_text_save": "保存",
"popup_text_yes": "はい",
"popup_text_no": "いいえ",
"popup_text_cancel": "キャンセル",
"popup_text_import": "輸入",
"popup-button-save": "保存",
"popup-button-yes": "はい",
"popup-button-no": "いいえ",
"popup-button-cancel": "キャンセル",
"popup-button-import": "輸入",
"Advanced Defininitions": "高度な定義",
"Prompt Overrides": "プロンプトのオーバーライド",
"(For Chat Completion and Instruct Mode)": "(チャット補完と指示モード用)",

View File

@ -885,11 +885,11 @@
"Bulk_edit_characters": "대량 캐릭터 편집",
"Bulk select all characters": "모든 문자 일괄 선택",
"Bulk delete characters": "대량 캐릭터 삭제",
"popup_text_save": "구하다",
"popup_text_yes": "예",
"popup_text_no": "아니요",
"popup_text_cancel": "취소",
"popup_text_import": "수입",
"popup-button-save": "구하다",
"popup-button-yes": "예",
"popup-button-no": "아니요",
"popup-button-cancel": "취소",
"popup-button-import": "수입",
"Advanced Defininitions": "고급 정의",
"Prompt Overrides": "프롬프트 무시",
"(For Chat Completion and Instruct Mode)": "(채팅 완료 및 지시 모드의 경우)",

View File

@ -885,11 +885,11 @@
"Bulk_edit_characters": "Massaal bewerken personages",
"Bulk select all characters": "Selecteer alle tekens in bulk",
"Bulk delete characters": "Massaal verwijderen personages",
"popup_text_save": "Redden",
"popup_text_yes": "Ja",
"popup_text_no": "Nee",
"popup_text_cancel": "Annuleren",
"popup_text_import": "Importeren",
"popup-button-save": "Redden",
"popup-button-yes": "Ja",
"popup-button-no": "Nee",
"popup-button-cancel": "Annuleren",
"popup-button-import": "Importeren",
"Advanced Defininitions": "Geavanceerde definities",
"Prompt Overrides": "Prompt-overschrijvingen",
"(For Chat Completion and Instruct Mode)": "(Voor voltooiing van chat en instructiemodus)",

View File

@ -885,11 +885,11 @@
"Bulk_edit_characters": "Editar personagens em massa",
"Bulk select all characters": "Selecione em massa todos os caracteres",
"Bulk delete characters": "Excluir personagens em massa",
"popup_text_save": "Salvar",
"popup_text_yes": "Sim",
"popup_text_no": "Não",
"popup_text_cancel": "Cancelar",
"popup_text_import": "Importar",
"popup-button-save": "Salvar",
"popup-button-yes": "Sim",
"popup-button-no": "Não",
"popup-button-cancel": "Cancelar",
"popup-button-import": "Importar",
"Advanced Defininitions": "Definições Avançadas",
"Prompt Overrides": "Substituições de prompt",
"(For Chat Completion and Instruct Mode)": "(Para conclusão de bate-papo e modo de instrução)",

View File

@ -962,10 +962,11 @@
"onboarding_import": "Импортируйте",
"from supported sources or view": "из источника или посмотрите",
"Sample characters": "Стандартных персонажей",
"popup_text_save": "Сохранить",
"popup_text_yes": "Да",
"popup_text_no": "Нет",
"popup_text_cancel": "Отмена",
"popup-button-save": "Сохранить",
"popup-button-yes": "Да",
"popup-button-no": "Нет",
"popup-button-cancel": "Отмена",
"popup-button-import": "Импортировать",
"Enter the URL of the content to import": "Введите URL-адрес импортируемого контента",
"Supported sources:": "Поддерживаются следующие источники:",
"char_import_example": "Пример:",
@ -976,7 +977,6 @@
"char_import_5": "Персонаж с AICharacterCard.com (прямая ссылка или ID)",
"char_import_6": "Прямая ссылка на PNG-файл (чтобы узнать список разрешённых хостов, загляните в",
"char_import_7": ")",
"popup_text_import": "Импортировать",
"Grammar String": "Грамматика",
"GNBF or ENBF, depends on the backend in use. If you're using this you should know which.": "GNBF или ENBF, зависит от бэкенда. Если вы это используете, то, скорее всего, сами знаете, какой именно.",
"Account": "Аккаунт",

View File

@ -885,11 +885,11 @@
"Bulk_edit_characters": "Масове редагування персонажів",
"Bulk select all characters": "Масове виділення всіх символів",
"Bulk delete characters": "Масове видалення персонажів",
"popup_text_save": "зберегти",
"popup_text_yes": "Так",
"popup_text_no": "Немає",
"popup_text_cancel": "Скасувати",
"popup_text_import": "Імпорт",
"popup-button-save": "зберегти",
"popup-button-yes": "Так",
"popup-button-no": "Немає",
"popup-button-cancel": "Скасувати",
"popup-button-import": "Імпорт",
"Advanced Defininitions": "Розширені визначення",
"Prompt Overrides": "Перевизначення підказок",
"(For Chat Completion and Instruct Mode)": "(Для завершення чату та режиму інструктажу)",

View File

@ -885,11 +885,11 @@
"Bulk_edit_characters": "Chỉnh sửa nhân vật theo lô",
"Bulk select all characters": "Chọn hàng loạt tất cả các ký tự",
"Bulk delete characters": "Xóa nhân vật theo lô",
"popup_text_save": "Cứu",
"popup_text_yes": "Đúng",
"popup_text_no": "KHÔNG",
"popup_text_cancel": "Hủy bỏ",
"popup_text_import": "Nhập khẩu",
"popup-button-save": "Cứu",
"popup-button-yes": "Đúng",
"popup-button-no": "KHÔNG",
"popup-button-cancel": "Hủy bỏ",
"popup-button-import": "Nhập khẩu",
"Advanced Defininitions": "Các Định nghĩa Nâng cao",
"Prompt Overrides": "Ghi đè nhắc nhở",
"(For Chat Completion and Instruct Mode)": "(Đối với chế độ hoàn thành trò chuyện và hướng dẫn)",

View File

@ -899,11 +899,11 @@
"Bulk_edit_characters": "批量编辑角色",
"Bulk select all characters": "批量选择所有角色",
"Bulk delete characters": "批量删除角色",
"popup_text_save": "保存",
"popup_text_yes": "是",
"popup_text_no": "否",
"popup_text_cancel": "取消",
"popup_text_import": "导入",
"popup-button-save": "保存",
"popup-button-yes": "是",
"popup-button-no": "否",
"popup-button-cancel": "取消",
"popup-button-import": "导入",
"Advanced Defininitions": "高级定义",
"Prompt Overrides": "提示词覆盖",
"(For Chat Completion and Instruct Mode)": "(用于聊天补全和指导模式)",

View File

@ -886,11 +886,11 @@
"Bulk_edit_characters": "批量編輯角色人物\n\n點選以切換角色人物\nShift + 點選以選擇/取消選擇一範圍的角色人物\n右鍵以查看動作",
"Bulk select all characters": "全選所有角色人物",
"Bulk delete characters": "批量刪除角色人物",
"popup_text_save": "儲存",
"popup_text_yes": "是",
"popup_text_no": "否",
"popup_text_cancel": "取消",
"popup_text_import": "匯入",
"popup-button-save": "儲存",
"popup-button-yes": "是",
"popup-button-no": "否",
"popup-button-cancel": "取消",
"popup-button-import": "匯入",
"Advanced Defininitions": "- 進階定義",
"Prompt Overrides": "提示詞覆寫",
"(For Chat Completion and Instruct Mode)": "(用於聊天補充和指令模式)",

View File

@ -227,7 +227,7 @@ import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, de
import { initPresetManager } from './scripts/preset-manager.js';
import { evaluateMacros } from './scripts/macros.js';
import { currentUser, setUserControls } from './scripts/user.js';
import { POPUP_TYPE, callGenericPopup } from './scripts/popup.js';
import { POPUP_TYPE, callGenericPopup, fixToastrForDialogs } from './scripts/popup.js';
import { renderTemplate, renderTemplateAsync } from './scripts/templates.js';
import { ScraperManager } from './scripts/scrapers.js';
import { SlashCommandParser } from './scripts/slash-commands/SlashCommandParser.js';
@ -235,6 +235,9 @@ import { SlashCommand } from './scripts/slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './scripts/slash-commands/SlashCommandArgument.js';
import { SlashCommandBrowser } from './scripts/slash-commands/SlashCommandBrowser.js';
import { initCustomSelectedSamplers, validateDisabledSamplers } from './scripts/samplerSelect.js';
import { DragAndDropHandler } from './scripts/dragdrop.js';
import { INTERACTABLE_CONTROL_CLASS, initKeyboard } from './scripts/keyboard.js';
import { initDynamicStyles } from './scripts/dynamic-styles.js';
//exporting functions and vars for mods
export {
@ -263,6 +266,19 @@ showLoader();
// Yoink preloader entirely; it only exists to cover up unstyled content while loading JS
document.getElementById('preloader').remove();
// Configure toast library:
toastr.options.escapeHtml = true; // Prevent raw HTML inserts
toastr.options.timeOut = 4000; // How long the toast will display without user interaction
toastr.options.extendedTimeOut = 10000; // How long the toast will display after a user hovers over it
toastr.options.progressBar = true; // Visually indicate how long before a toast expires.
toastr.options.closeButton = true; // enable a close button
toastr.options.positionClass = "toast-top-center"; // Where to position the toast container
toastr.options.onHidden = () => {
// If we have any dialog still open, the last "hidden" toastr will remove the toastr-container. We need to keep it alive inside the dialog though
// so the toasts still show up inside there.
fixToastrForDialogs();
}
// Allow target="_blank" in links
DOMPurify.addHook('afterSanitizeAttributes', function (node) {
if ('target' in node) {
@ -509,11 +525,15 @@ let is_delete_mode = false;
let fav_ch_checked = false;
let scrollLock = false;
export let abortStatusCheck = new AbortController();
let charDragDropHandler = null;
/** @type {number} The debounce timeout used for chat/settings save. debounce_timeout.long: 1.000 ms */
const durationSaveEdit = debounce_timeout.relaxed;
export const saveSettingsDebounced = debounce(() => saveSettings(), durationSaveEdit);
export const saveCharacterDebounced = debounce(() => $('#create_button').trigger('click'), durationSaveEdit);
/** @type {debounce_timeout} The debounce timeout used for chat/settings save. debounce_timeout.long: 1.000 ms */
export const DEFAULT_SAVE_EDIT_TIMEOUT = debounce_timeout.relaxed;
/** @type {debounce_timeout} The debounce timeout used for printing. debounce_timeout.quick: 100 ms */
export const DEFAULT_PRINT_TIMEOUT = debounce_timeout.quick;
export const saveSettingsDebounced = debounce(() => saveSettings(), DEFAULT_SAVE_EDIT_TIMEOUT);
export const saveCharacterDebounced = debounce(() => $('#create_button').trigger('click'), DEFAULT_SAVE_EDIT_TIMEOUT);
/**
* Prints the character list in a debounced fashion without blocking, with a delay of 100 milliseconds.
@ -521,7 +541,7 @@ export const saveCharacterDebounced = debounce(() => $('#create_button').trigger
*
* The printing will also always reprint all filter options of the global list, to keep them up to date.
*/
export const printCharactersDebounced = debounce(() => { printCharacters(false); }, debounce_timeout.quick);
export const printCharactersDebounced = debounce(() => { printCharacters(false); }, DEFAULT_PRINT_TIMEOUT);
/**
* @enum {string} System message types
@ -885,6 +905,8 @@ async function firstLoadInit() {
await getSystemMessages();
sendSystemMessage(system_message_types.WELCOME);
await getSettings();
initKeyboard();
initDynamicStyles();
initTags();
await getUserAvatars(true, user_avatar);
await getCharacters();
@ -925,6 +947,8 @@ export function displayOnlineStatus() {
*/
export function setAnimationDuration(ms = null) {
animation_duration = ms ?? ANIMATION_DURATION_DEFAULT;
// Set CSS variable to document
document.documentElement.style.setProperty('--animation-duration', `${animation_duration}ms`);
}
export function setActiveCharacter(entityOrKey) {
@ -5482,7 +5506,7 @@ export async function renameCharacter(name = null, { silent = false, renameChats
}
const oldAvatar = characters[this_chid].avatar;
const newValue = name || await callPopup('<h3>New name:</h3>', 'input', characters[this_chid].name);
const newValue = name || await callGenericPopup('<h3>New name:</h3>', POPUP_TYPE.INPUT, characters[this_chid].name);
if (!newValue) {
toastr.warning('No character name provided.', 'Rename Character');
@ -5730,7 +5754,7 @@ async function read_avatar_load(input) {
}
await createOrEditCharacter();
await delay(durationSaveEdit);
await delay(DEFAULT_SAVE_EDIT_TIMEOUT);
const formData = new FormData($('#form_create').get(0));
await fetch(getThumbnailUrl('avatar', formData.get('avatar_url')), {
@ -5774,7 +5798,7 @@ export function getThumbnailUrl(type, file) {
return `/thumbnail?type=${type}&file=${encodeURIComponent(file)}`;
}
export function buildAvatarList(block, entities, { templateId = 'inline_avatar_template', empty = true, selectable = false, highlightFavs = true } = {}) {
export function buildAvatarList(block, entities, { templateId = 'inline_avatar_template', empty = true, interactable = false, highlightFavs = true } = {}) {
if (empty) {
block.empty();
}
@ -5809,8 +5833,8 @@ export function buildAvatarList(block, entities, { templateId = 'inline_avatar_t
avatarTemplate.attr('title', `[Group] ${entity.item.name}`);
}
if (selectable) {
avatarTemplate.addClass('selectable');
if (interactable) {
avatarTemplate.addClass(INTERACTABLE_CONTROL_CLASS);
avatarTemplate.toggleClass('character_select', entity.type === 'character');
avatarTemplate.toggleClass('group_select', entity.type === 'group');
}
@ -6847,7 +6871,7 @@ export function select_selected_character(chid) {
$('#add_avatar_button').val('');
$('#character_popup_text_h3').text(characters[chid].name);
$('#character_popup-button-h3').text(characters[chid].name);
$('#character_name_pole').val(characters[chid].name);
$('#description_textarea').val(characters[chid].description);
$('#character_world').val(characters[chid].data?.extensions?.world || '');
@ -6924,7 +6948,7 @@ function select_rm_create() {
//create text poles
$('#rm_button_back').css('display', '');
$('#character_import_button').css('display', '');
$('#character_popup_text_h3').text('Create character');
$('#character_popup-button-h3').text('Create character');
$('#character_name_pole').val(create_save.name);
$('#description_textarea').val(create_save.description);
$('#character_world').val(create_save.world);
@ -7070,10 +7094,10 @@ function onScenarioOverrideRemoveClick() {
* @param {string} type
* @param {string} inputValue - Value to set the input to.
* @param {PopupOptions} options - Options for the popup.
* @typedef {{okButton?: string, rows?: number, wide?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean, cropAspect?: number }} PopupOptions - Options for the popup.
* @typedef {{okButton?: string, rows?: number, wide?: boolean, wider?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean, cropAspect?: number }} PopupOptions - Options for the popup.
* @returns
*/
export function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling, cropAspect } = {}) {
export function callPopup(text, type, inputValue = '', { okButton, rows, wide, wider, large, allowHorizontalScrolling, allowVerticalScrolling, cropAspect } = {}) {
function getOkButtonText() {
if (['avatarToCrop'].includes(popup_type)) {
return okButton ?? 'Accept';
@ -7103,6 +7127,7 @@ export function callPopup(text, type, inputValue = '', { okButton, rows, wide, l
const $shadowPopup = $('#shadow_popup');
$dialoguePopup.toggleClass('wide_dialogue_popup', !!wide)
.toggleClass('wider_dialogue_popup', !!wider)
.toggleClass('large_dialogue_popup', !!large)
.toggleClass('horizontal_scrolling_dialogue_popup', !!allowHorizontalScrolling)
.toggleClass('vertical_scrolling_dialogue_popup', !!allowVerticalScrolling);
@ -7215,7 +7240,7 @@ export async function saveMetadata() {
export async function saveChatConditional() {
try {
await waitUntilCondition(() => !isChatSaving, durationSaveEdit, 100);
await waitUntilCondition(() => !isChatSaving, DEFAULT_SAVE_EDIT_TIMEOUT, 100);
} catch {
console.warn('Timeout waiting for chat to save');
return;
@ -7592,7 +7617,7 @@ async function createOrEditCharacter(e) {
field.callback && field.callback(fieldValue);
});
$('#character_popup_text_h3').text('Create character');
$('#character_popup-button-h3').text('Create character');
create_save.avatar = '';
@ -8943,7 +8968,7 @@ jQuery(async function () {
$('#send_textarea').on('focusin focus click', () => {
S_TAPreviouslyFocused = true;
});
$('#options_button, #send_but, #option_regenerate, #option_continue, #mes_continue').on('click', () => {
$('#send_but, #option_regenerate, #option_continue, #mes_continue').on('click', () => {
if (S_TAPreviouslyFocused) {
$('#send_textarea').focus();
}
@ -9452,7 +9477,7 @@ jQuery(async function () {
}
function isMouseOverButtonOrMenu() {
return menu.is(':hover') || button.is(':hover');
return menu.is(':hover, :focus-within') || button.is(':hover, :focus');
}
button.on('click', function () {
@ -10352,7 +10377,7 @@ jQuery(async function () {
'#character_cross',
'#avatar-and-name-block',
'#shadow_popup',
'.shadow_popup',
'.popup',
'#world_popup',
'.ui-widget',
'.text_pole',
@ -10657,7 +10682,7 @@ jQuery(async function () {
const html = await renderTemplateAsync('importCharacters');
/** @type {string?} */
const input = await callGenericPopup(html, POPUP_TYPE.INPUT, '', { wider: true, okButton: $('#shadow_popup_template').attr('popup_text_import'), rows: 4 });
const input = await callGenericPopup(html, POPUP_TYPE.INPUT, '', { wider: true, okButton: $('#popup_template').attr('popup-button-import'), rows: 4 });
if (!input) {
console.debug('Custom content import cancelled');
@ -10712,32 +10737,12 @@ jQuery(async function () {
}
});
const $dropzone = $(document.body);
$dropzone.on('dragover', (event) => {
event.preventDefault();
event.stopPropagation();
$dropzone.addClass('dragover');
});
$dropzone.on('dragleave', (event) => {
event.preventDefault();
event.stopPropagation();
$dropzone.removeClass('dragover');
});
$dropzone.on('drop', async (event) => {
event.preventDefault();
event.stopPropagation();
$dropzone.removeClass('dragover');
const files = Array.from(event.originalEvent.dataTransfer.files);
charDragDropHandler = new DragAndDropHandler('body', async (files, event) => {
if (!files.length) {
await importFromURL(event.originalEvent.dataTransfer.items, files);
}
await processDroppedFiles(files);
});
}, { noAnimation: true });
$('#charListGridToggle').on('click', async () => {
doCharListDisplaySwitch();

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -73,7 +73,7 @@ import {
depth_prompt_role_default,
shouldAutoContinue,
} from '../script.js';
import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map } from './tags.js';
import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js';
import { FILTER_TYPES, FilterHelper } from './filters.js';
import { isExternalMediaAllowed } from './chats.js';
@ -1356,7 +1356,7 @@ function select_group_chats(groupId, skipAnimation) {
}
// render tags
printTagList($('#groupTagList'), { forEntityOrKey: groupId, tagOptions: { removable: true } });
applyTagsOnGroupSelect(groupId);
// render characters list
printGroupCandidates();

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

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

View File

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

View File

@ -4,22 +4,26 @@ import {
this_chid,
callPopup,
menu_type,
getCharacters,
entitiesFilter,
printCharactersDebounced,
buildAvatarList,
eventSource,
event_types,
DEFAULT_PRINT_TIMEOUT,
} from '../script.js';
// eslint-disable-next-line no-unused-vars
import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js';
import { groupCandidatesFilter, groups, select_group_chats, selected_group } from './group-chats.js';
import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight } from './utils.js';
import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents, removeFromArray, getFreeName, debounce } from './utils.js';
import { power_user } from './power-user.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
import { isMobile } from './RossAscends-mods.js';
import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from './popup.js';
import { debounce_timeout } from './constants.js';
import { INTERACTABLE_CONTROL_CLASS } from './keyboard.js';
import { SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
import { SlashCommandExecutor } from './slash-commands/SlashCommandExecutor.js';
@ -46,6 +50,8 @@ export {
removeTagFromMap,
};
/** @typedef {import('../script.js').Character} Character */
const CHARACTER_FILTER_SELECTOR = '#rm_characters_block .rm_tag_filter';
const GROUP_FILTER_SELECTOR = '#rm_group_chats_block .rm_tag_filter';
const TAG_TEMPLATE = $('#tag_template .tag');
@ -329,7 +335,7 @@ function filterByFolder(filterHelper) {
if (!power_user.bogus_folders) {
$('#bogus_folders').prop('checked', true).trigger('input');
onViewTagsListClick();
flashHighlight($('#dialogue_popup .tag_as_folder, #dialogue_popup .tag_folder_indicator'));
flashHighlight($('#tag_view_list .tag_as_folder, #tag_view_list .tag_folder_indicator'));
return;
}
@ -468,29 +474,34 @@ export function getTagKeyForEntityElement(element) {
}
/**
* Adds a tag to a given entity
* @param {Tag} tag - The tag to add
* @param {string|string[]} entityId - The entity to add this tag to. Has to be the entity key (e.g. `addTagToEntity`). (Also allows multiple entities to be passed in)
* Adds one or more tags to a given entity
*
* @param {Tag|Tag[]} tag - The tag or tags to add
* @param {string|string[]} entityId - The entity or entities to add this tag to. Has to be the entity key (e.g. `addTagToEntity`).
* @param {object} [options={}] - Optional arguments
* @param {JQuery<HTMLElement>|string?} [options.tagListSelector=null] - An optional selector if a specific list should be updated with the new tag too (for example because the add was triggered for that function)
* @param {PrintTagListOptions} [options.tagListOptions] - Optional parameters for printing the tag list. Can be set to be consistent with the expected behavior of tags in the list that was defined before.
* @returns {boolean} Whether at least one tag was added
*/
export function addTagToEntity(tag, entityId, { tagListSelector = null, tagListOptions = {} } = {}) {
export function addTagsToEntity(tag, entityId, { tagListSelector = null, tagListOptions = {} } = {}) {
const tags = Array.isArray(tag) ? tag : [tag];
const entityIds = Array.isArray(entityId) ? entityId : [entityId];
let result = false;
// Add tags to the map
if (Array.isArray(entityId)) {
entityId.forEach((id) => result = addTagToMap(tag.id, id) || result);
} else {
result = addTagToMap(tag.id, entityId);
}
entityIds.forEach((id) => {
tags.forEach((tag) => {
result = addTagToMap(tag.id, id) || result;
});
});
// Save and redraw
printCharactersDebounced();
saveSettingsDebounced();
// We should manually add the selected tag to the print tag function, so we cover places where the tag list did not automatically include it
tagListOptions.addTag = tag;
tagListOptions.addTag = tags;
// add tag to the UI and internal map - we reprint so sorting and new markup is done correctly
if (tagListSelector) printTagList(tagListSelector, tagListOptions);
@ -588,10 +599,10 @@ function removeTagFromMap(tagId, characterId = null) {
function findTag(request, resolve, listSelector) {
const skipIds = [...($(listSelector).find('.tag').map((_, el) => $(el).attr('id')))];
const haystack = tags.filter(t => !skipIds.includes(t.id)).map(t => t.name).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
const needle = request.term.toLowerCase();
const hasExactMatch = haystack.findIndex(x => x.toLowerCase() == needle) !== -1;
const result = haystack.filter(x => x.toLowerCase().includes(needle));
const haystack = tags.filter(t => !skipIds.includes(t.id)).sort(compareTagsForSort).map(t => t.name);
const needle = request.term;
const hasExactMatch = haystack.findIndex(x => equalsIgnoreCaseAndAccents(x, needle)) !== -1;
const result = haystack.filter(x => includesIgnoreCaseAndAccents(x, needle));
if (request.term && !hasExactMatch) {
result.unshift(request.term);
@ -612,7 +623,7 @@ function findTag(request, resolve, listSelector) {
*/
function selectTag(event, ui, listSelector, { tagListOptions = {} } = {}) {
let tagName = ui.item.value;
let tag = tags.find(t => t.name === tagName);
let tag = getTag(tagName);
// create new tag if it doesn't exist
if (!tag) {
@ -626,7 +637,7 @@ function selectTag(event, ui, listSelector, { tagListOptions = {} } = {}) {
const characterData = event.target.closest('#bulk_tags_div')?.dataset.characters;
const characterIds = characterData ? JSON.parse(characterData).characterIds : null;
addTagToEntity(tag, characterIds, { tagListSelector: listSelector, tagListOptions: tagListOptions });
addTagsToEntity(tag, characterIds, { tagListSelector: listSelector, tagListOptions: tagListOptions });
// need to return false to keep the input clear
return false;
@ -635,70 +646,200 @@ function selectTag(event, ui, listSelector, { tagListOptions = {} } = {}) {
/**
* Get a list of existing tags matching a list of provided new tag names
*
* @param {string[]} new_tags - A list of strings representing tag names
* @returns List of existing tags
* @param {string[]} newTags - A list of strings representing tag names
* @returns {Tag[]} List of existing tags
*/
function getExistingTags(new_tags) {
let existing_tags = [];
for (let tag of new_tags) {
let foundTag = tags.find(t => t.name.toLowerCase() === tag.toLowerCase());
function getExistingTags(newTags) {
let existingTags = [];
for (let tagName of newTags) {
let foundTag = getTag(tagName);
if (foundTag) {
existing_tags.push(foundTag.name);
existingTags.push(foundTag);
}
}
return existing_tags;
return existingTags;
}
async function importTags(imported_char) {
let imported_tags = imported_char.tags.filter(t => t !== 'ROOT' && t !== 'TAVERN');
let existingTags = await getExistingTags(imported_tags);
//make this case insensitive
let newTags = imported_tags.filter(t => !existingTags.some(existingTag => existingTag.toLowerCase() === t.toLowerCase()));
let selected_tags = '';
const existingTagsString = existingTags.length ? (': ' + existingTags.join(', ')) : '';
if (newTags.length === 0) {
await callPopup(`<h3>Importing Tags For ${imported_char.name}</h3><p>${existingTags.length} existing tags have been found${existingTagsString}.</p>`, 'text');
} else {
selected_tags = await callPopup(`<h3>Importing Tags For ${imported_char.name}</h3><p>${existingTags.length} existing tags have been found${existingTagsString}.</p><p>The following ${newTags.length} new tags will be imported.</p>`, 'input', newTags.join(', '));
}
// @ts-ignore
selected_tags = existingTags.concat(selected_tags.split(','));
// @ts-ignore
selected_tags = selected_tags.map(t => t.trim()).filter(t => t !== '');
//Anti-troll measure
if (selected_tags.length > 15) {
selected_tags = selected_tags.slice(0, 15);
}
for (let tagName of selected_tags) {
let tag = tags.find(t => t.name === tagName);
const tagImportSettings = {
ALWAYS_IMPORT_ALL: 1,
ONLY_IMPORT_EXISTING: 2,
IMPORT_NONE: 3,
ASK: 4,
};
if (!tag) {
tag = createNewTag(tagName);
}
let globalTagImportSetting = tagImportSettings.ASK; // Default setting
if (!tag_map[imported_char.avatar].includes(tag.id)) {
tag_map[imported_char.avatar].push(tag.id);
console.debug('added tag to map', tag, imported_char.name);
}
const IMPORT_EXLCUDED_TAGS = ['ROOT', 'TAVERN'];
const ANTI_TROLL_MAX_TAGS = 15;
/**
* Imports tags for a given character
*
* @param {Character} character - The character
* @returns {Promise<boolean>} Boolean indicating whether any tag was imported
*/
async function importTags(character) {
// Gather the tags to import based on the selected setting
const tagNamesToImport = await handleTagImport(character);
if (!tagNamesToImport?.length) {
toastr.info('No tags imported', 'Importing Tags');
return;
}
saveSettingsDebounced();
const tagsToImport = tagNamesToImport.map(tag => getTag(tag, { createNew: true }));
const added = addTagsToEntity(tagsToImport, character.avatar);
// Await the character list, which will automatically reprint it and all tag filters
await getCharacters();
toastr.success(`Imported tags:<br />${tagsToImport.map(x => x.name).join(', ')}`, 'Importing Tags', { escapeHtml: false });
// need to return false to keep the input clear
return false;
return added;
}
/**
* Handles the import of tags for a given character and returns the resulting list of tags to add
*
* @param {Character} character - The character
* @returns {Promise<string[]>} Array of strings representing the tags to import
*/
async function handleTagImport(character) {
/** @type {string[]} */
const importTags = character.tags.map(t => t.trim()).filter(t => t)
.filter(t => !IMPORT_EXLCUDED_TAGS.includes(t))
.slice(0, ANTI_TROLL_MAX_TAGS);
const existingTags = getExistingTags(importTags);
const newTags = importTags.filter(t => !existingTags.some(existingTag => existingTag.name.toLowerCase() === t.toLowerCase()))
.map(newTag);
switch (globalTagImportSetting) {
case tagImportSettings.ALWAYS_IMPORT_ALL:
return existingTags.concat(newTags).map(t => t.name);
case tagImportSettings.ONLY_IMPORT_EXISTING:
return existingTags.map(t => t.name);
case tagImportSettings.ASK:
return await showTagImportPopup(character, existingTags, newTags);
case tagImportSettings.IMPORT_NONE:
default:
return [];
}
}
/**
* Shows a popup to import tags for a given character and returns the resulting list of tags to add
*
* @param {Character} character - The character
* @param {Tag[]} existingTags - List of existing tags
* @param {Tag[]} newTags - List of new tags
* @returns {Promise<string[]>} Array of strings representing the tags to import
*/
async function showTagImportPopup(character, existingTags, newTags) {
/** @type {{[key: string]: import('./popup.js').CustomPopupButton}} */
const importButtons = {
EXISTING: { result: 2, text: 'Import Existing' },
ALL: { result: 3, text: 'Import All' },
NONE: { result: 4, text: 'Import None' },
};
const customButtonsCaptions = Object.values(importButtons).map(button => `&quot;${button.text}&quot;`);
const customButtonsString = customButtonsCaptions.slice(0, -1).join(', ') + ' or ' + customButtonsCaptions.slice(-1);
const popupContent = $(`
<h3>Import Tags For ${character.name}</h3>
<div class="import_avatar_placeholder"></div>
<div class="import_tags_content justifyLeft">
<small>
Click remove on any tag to remove it from this import.<br />
Select one of the import options to finish importing the tags.
</small>
<h4 class="m-t-1">Existing Tags</h4>
<div id="import_existing_tags_list" class="tags"></div>
<h4 class="m-t-1">New Tags</h4>
<div id="import_new_tags_list" class="tags"></div>
<small>
<label class="checkbox flex-container alignitemscenter flexNoGap m-t-3" for="import_remember_option">
<input type="checkbox" id="import_remember_option" name="import_remember_option" />
<span data-i18n="Remember my choice">
Remember my choice
<div class="fa-solid fa-circle-info opacity50p" data-i18n="[title]Remember the chosen import option\nIf ${customButtonsString} is selected, this dialog will not show up anymore.\nTo change this, go to the settings and modify &quot;Tag Import Option&quot;.\n\nIf the &quot;Import&quot; option is chosen, the global setting will stay on &quot;Ask&quot;."
title="Remember the chosen import option\nIf ${customButtonsString} is selected, this dialog will not show up anymore.\nTo change this, go to the settings and modify &quot;Tag Import Option&quot;.\n\nIf the &quot;Import&quot; option is chosen, the global setting will stay on &quot;Ask&quot;.">
</div>
</span>
</label>
</small>
</div>`);
// Print tags after popup is shown, so that events can be added
printTagList(popupContent.find('#import_existing_tags_list'), { tags: existingTags, tagOptions: { removable: true, removeAction: tag => removeFromArray(existingTags, tag) } });
printTagList(popupContent.find('#import_new_tags_list'), { tags: newTags, tagOptions: { removable: true, removeAction: tag => removeFromArray(newTags, tag) } });
const result = await callGenericPopup(popupContent, POPUP_TYPE.TEXT, null, { wider: true, okButton: 'Import', cancelButton: true, customButtons: Object.values(importButtons) });
if (!result) {
return [];
}
switch (result) {
case 1:
case true:
case importButtons.ALL.result: // Default 'Import' option where it imports all selected
return existingTags.concat(newTags).map(t => t.name);
case importButtons.EXISTING.result:
return existingTags.map(t => t.name);
case importButtons.NONE.result:
default:
return [];
}
}
/**
* Gets a tag from the tags array based on the provided tag name (insensitive soft matching)
* Optionally creates the tag if it doesn't exist
*
* @param {string} tagName - The name of the tag to search for
* @param {object} [options={}] - Optional parameters
* @param {boolean} [options.createNew=false] - Whether to create the tag if it doesn't exist
* @returns {Tag?} The tag object that matches the provided tag name, or undefined if no match is found
*/
function getTag(tagName, { createNew = false } = {}) {
let tag = tags.find(t => equalsIgnoreCaseAndAccents(t.name, tagName));
if (!tag && createNew) {
tag = createNewTag(tagName);
}
return tag;
}
/**
* Creates a new tag with default properties and a randomly generated id
*
* Does **not** trigger a save, so it's up to the caller to do that
*
* @param {string} tagName - name of the tag
* @returns {Tag}
* @returns {Tag} the newly created tag, or the existing tag if it already exists (with a logged warning)
*/
function createNewTag(tagName) {
const tag = {
const existing = getTag(tagName);
if (existing) {
toastr.warning(`Cannot create new tag. A tag with the name already exists:<br />${existing.name}`, 'Creating Tag', { escapeHtml: false });
return existing;
}
const tag = newTag(tagName);
tags.push(tag);
console.debug('Created new tag', tag.name, 'with id', tag.id);
return tag;
}
/**
* Creates a new tag object with the given tag name and default properties
*
* Not to be confused with `createNewTag`, which actually creates the tag and adds it to the existing list of tags.
* Use this one to create temporary tag objects, for example for drawing.
*
* @param {string} tagName - The name of the tag
* @return {Tag} The newly created tag object
*/
function newTag(tagName) {
return {
id: uuidv4(),
name: tagName,
folder_type: TAG_FOLDER_DEFAULT_TYPE,
@ -708,16 +849,14 @@ function createNewTag(tagName) {
color2: '',
create_date: Date.now(),
};
tags.push(tag);
console.debug('Created new tag', tag.name, 'with id', tag.id);
return tag;
}
/**
* @typedef {object} TagOptions - Options for tag behavior. (Same object will be passed into "appendTagToList")
* @property {boolean} [removable=false] - Whether tags can be removed.
* @property {boolean} [selectable=false] - Whether tags can be selected.
* @property {boolean} [isFilter=false] - Whether tags can be selected as a filter.
* @property {function} [action=undefined] - Action to perform on tag interaction.
* @property {(tag: Tag)=>boolean} [removeAction=undefined] - Action to perform on tag removal instead of the default remove action. If the action returns false, the tag will not be removed.
* @property {boolean} [isGeneralList=false] - If true, indicates that this is the general list of tags.
* @property {boolean} [skipExistsCheck=false] - If true, the tag gets added even if a tag with the same id already exists.
*/
@ -725,7 +864,7 @@ function createNewTag(tagName) {
/**
* @typedef {object} PrintTagListOptions - Optional parameters for printing the tag list.
* @property {Tag[]|function(): Tag[]} [tags=undefined] - Optional override of tags that should be printed. Those will not be sorted. If no supplied, tags for the relevant character are printed. Can also be a function that returns the tags.
* @property {Tag} [addTag=undefined] - Optionally provide a tag that should be manually added to this print. Either to the overriden tag list or the found tags based on the entity/key. Will respect the tag exists check.
* @property {Tag|Tag[]} [addTag=undefined] - Optionally provide one or multiple tags that should be manually added to this print. Either to the overriden tag list or the found tags based on the entity/key. Will respect the tag exists check.
* @property {object|number|string} [forEntityOrKey=undefined] - Optional override for the chosen entity, otherwise the currently selected is chosen. Can be an entity with id property (character, group, tag), or directly an id or tag key.
* @property {boolean|string} [empty=true] - Whether the list should be initially empty. If a string string is provided, 'always' will always empty the list, otherwise it'll evaluate to a boolean.
* @property {boolean} [sort=true] - Whether the tags should be sorted via the sort function, or kept as is.
@ -749,8 +888,9 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity
$element.empty();
}
if (addTag && (tagOptions.skipExistsCheck || !printableTags.some(x => x.id === addTag.id))) {
printableTags = [...printableTags, addTag];
if (addTag) {
const addTags = Array.isArray(addTag) ? addTag : [addTag];
printableTags = printableTags.concat(addTags.filter(tag => tagOptions.skipExistsCheck || !printableTags.some(t => t.id === tag.id)));
}
// one last sort, because we might have modified the tag list or manually retrieved it from a function
@ -834,7 +974,7 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity
* @param {TagOptions} [options={}] - Options for tag behavior
* @returns {void}
*/
function appendTagToList(listElement, tag, { removable = false, selectable = false, action = undefined, isGeneralList = false, skipExistsCheck = false } = {}) {
function appendTagToList(listElement, tag, { removable = false, isFilter = false, action = undefined, removeAction = undefined, isGeneralList = false, skipExistsCheck = false } = {}) {
if (!listElement) {
return;
}
@ -852,6 +992,13 @@ function appendTagToList(listElement, tag, { removable = false, selectable = fal
tagElement.find('.tag_name').text(tag.name);
const removeButton = tagElement.find('.tag_remove');
removable ? removeButton.show() : removeButton.hide();
if (removable && removeAction) {
tagElement.attr('custom-remove-action', String(true));
removeButton.on('click', () => {
const result = removeAction(tag);
if (result !== false) tagElement.remove();
});
}
if (tag.class) {
tagElement.addClass(tag.class);
@ -867,19 +1014,20 @@ function appendTagToList(listElement, tag, { removable = false, selectable = fal
// We could have multiple ways of actions passed in. The manual arguments have precendence in front of a specified tag action
const clickableAction = action ?? tag.action;
// If this is a tag for a general list and its either selectable or actionable, lets mark its current state
if ((selectable || clickableAction) && isGeneralList) {
// If this is a tag for a general list and its either a filter or actionable, lets mark its current state
if ((isFilter || clickableAction) && isGeneralList) {
toggleTagThreeState(tagElement, { stateOverride: tag.filter_state ?? DEFAULT_FILTER_STATE });
}
if (selectable) {
if (isFilter) {
tagElement.on('click', () => onTagFilterClick.bind(tagElement)(listElement));
tagElement.addClass(INTERACTABLE_CONTROL_CLASS);
}
if (clickableAction) {
const filter = getFilterHelper($(listElement));
tagElement.on('click', (e) => clickableAction.bind(tagElement)(filter, e));
tagElement.addClass('clickable-action');
tagElement.addClass('clickable-action').addClass(INTERACTABLE_CONTROL_CLASS);
}
$(listElement).append(tagElement);
@ -888,6 +1036,7 @@ function appendTagToList(listElement, tag, { removable = false, selectable = fal
function onTagFilterClick(listElement) {
const tagId = $(this).attr('id');
const existingTag = tags.find((tag) => tag.id === tagId);
const parent = $(this).parents('.tags');
let state = toggleTagThreeState($(this));
@ -898,6 +1047,9 @@ function onTagFilterClick(listElement) {
// We don't print anything manually, updating the filter will automatically trigger a redraw of all relevant stuff
runTagFilters(listElement);
// Focus the tag again we were at, if possible. To improve keyboard navigation
setTimeout(() => parent.find(`.tag[id="${tagId}"]`).trigger('focus'), DEFAULT_PRINT_TIMEOUT + 1);
}
/**
@ -975,7 +1127,7 @@ function printTagFilters(type = tag_filter_types.character) {
const characterTagIds = Object.values(tag_map).flat();
const tagsToDisplay = tags.filter(x => characterTagIds.includes(x.id)).sort(compareTagsForSort);
printTagList($(FILTER_SELECTOR), { empty: false, tags: tagsToDisplay, tagOptions: { selectable: true, isGeneralList: true } });
printTagList($(FILTER_SELECTOR), { empty: false, tags: tagsToDisplay, tagOptions: { isFilter: true, isGeneralList: true } });
// Print bogus folder navigation
const bogusDrilldown = $(FILTER_SELECTOR).siblings('.rm_tag_bogus_drilldown');
@ -1010,6 +1162,12 @@ function onTagRemoveClick(event) {
const tagElement = $(this).closest('.tag');
const tagId = tagElement.attr('id');
// If we have a custom remove action, we are not executing anything here in the default handler
if (tagElement.attr('custom-remove-action')) {
console.debug('Custom remove action', tagId);
return;
}
// Check if we are inside the drilldown. If so, we call remove on the bogus folder
if ($(this).closest('.rm_tag_bogus_drilldown').length > 0) {
console.debug('Bogus drilldown remove', tagId);
@ -1029,7 +1187,7 @@ function onTagRemoveClick(event) {
// @ts-ignore
function onTagInput(event) {
let val = $(this).val();
if (tags.find(t => t.name === val)) return;
if (getTag(String(val))) return;
// @ts-ignore
$(this).autocomplete('search', val);
}
@ -1047,7 +1205,7 @@ function onGroupCreateClick() {
// Nothing to do here at the moment. Tags in group interface get automatically redrawn.
}
export function applyTagsOnCharacterSelect() {
export function applyTagsOnCharacterSelect(chid = null) {
// If we are in create window, we cannot simply redraw, as there are no real persisted tags. Grab them, and pass them in
if (menu_type === 'create') {
const currentTagIds = $('#tagList').find('.tag').map((_, el) => $(el).attr('id')).get();
@ -1056,11 +1214,11 @@ export function applyTagsOnCharacterSelect() {
return;
}
const chid = this_chid ? Number(this_chid) : null;
chid = chid ?? Number(this_chid);
printTagList($('#tagList'), { forEntityOrKey: chid, tagOptions: { removable: true } });
}
export function applyTagsOnGroupSelect() {
export function applyTagsOnGroupSelect(groupId = null) {
// If we are in create window, we explicitly have to tell the system to print for the new group, not the one selected in the background
if (menu_type === 'group_create') {
const currentTagIds = $('#groupTagList').find('.tag').map((_, el) => $(el).attr('id')).get();
@ -1069,7 +1227,7 @@ export function applyTagsOnGroupSelect() {
return;
}
const groupId = selected_group;
groupId = groupId ?? Number(selected_group);
printTagList($('#groupTagList'), { forEntityOrKey: groupId, tagOptions: { removable: true } });
}
@ -1091,9 +1249,7 @@ export function createTagInput(inputSelector, listSelector, tagListOptions = {})
.focus(onTagInputFocus); // <== show tag list on click
}
function onViewTagsListClick() {
const popup = $('#dialogue_popup');
popup.addClass('large_dialogue_popup');
async function onViewTagsListClick() {
const html = $(document.createElement('div'));
html.attr('id', 'tag_view_list');
html.append(`
@ -1134,13 +1290,10 @@ function onViewTagsListClick() {
const tagContainer = $('<div class="tag_view_list_tags ui-sortable"></div>');
html.append(tagContainer);
callPopup(html, 'text', null, { allowVerticalScrolling: true });
printViewTagList();
printViewTagList(tagContainer);
makeTagListDraggable(tagContainer);
$('#dialogue_popup .tag-color').on('change', (evt) => onTagColorize(evt));
$('#dialogue_popup .tag-color2').on('change', (evt) => onTagColorize2(evt));
await callGenericPopup(html, POPUP_TYPE.TEXT, null, { allowVerticalScrolling: true });
}
/**
@ -1202,7 +1355,7 @@ function makeTagListDraggable(tagContainer) {
// If tags were dragged manually, we have to disable auto sorting
if (power_user.auto_sort_tags) {
power_user.auto_sort_tags = false;
$('#dialogue_popup input[name="auto_sort_tags"]').prop('checked', false);
$('#tag_view_list input[name="auto_sort_tags"]').prop('checked', false);
toastr.info('Automatic sorting of tags deactivated.');
}
@ -1329,7 +1482,7 @@ async function onTagRestoreFileSelect(e) {
printCharactersDebounced();
saveSettingsDebounced();
onViewTagsListClick();
await onViewTagsListClick();
}
function onBackupRestoreClick() {
@ -1351,14 +1504,18 @@ function onTagsBackupClick() {
}
function onTagCreateClick() {
const tag = createNewTag('New Tag');
printViewTagList();
const tagName = getFreeName('New Tag', tags.map(x => x.name));
const tag = createNewTag(tagName);
printViewTagList($('#tag_view_list .tag_view_list_tags'));
const tagElement = ($('#dialogue_popup .tag_view_list_tags')).find(`.tag_view_item[id="${tag.id}"]`);
const tagElement = ($('#tag_view_list .tag_view_list_tags')).find(`.tag_view_item[id="${tag.id}"]`);
tagElement[0]?.scrollIntoView();
flashHighlight(tagElement);
printCharactersDebounced();
saveSettingsDebounced();
toastr.success('Tag created', 'Create Tag');
}
function appendViewTagToList(list, tag, everything) {
@ -1382,25 +1539,47 @@ function appendViewTagToList(list, tag, everything) {
const primaryColorPicker = $('<toolcool-color-picker></toolcool-color-picker>')
.addClass('tag-color')
.attr({ id: colorPickerId, color: tag.color });
.attr({ id: colorPickerId, color: tag.color || 'rgba(0, 0, 0, 0.3)', 'data-default-color': 'rgba(0, 0, 0, 0.3)' });
const secondaryColorPicker = $('<toolcool-color-picker></toolcool-color-picker>')
.addClass('tag-color2')
.attr({ id: colorPicker2Id, color: tag.color2 });
.attr({ id: colorPicker2Id, color: tag.color2 || power_user.main_text_color, 'data-default-color': power_user.main_text_color });
template.find('.tagColorPickerHolder').append(primaryColorPicker);
template.find('.tagColorPicker2Holder').append(secondaryColorPicker);
template.find('.tag_view_color_picker[data-value="color"]').append(primaryColorPicker)
.append($('<div class="fas fa-link fa-xs link_icon right_menu_button" title="Link to theme color"></div>'));
template.find('.tag_view_color_picker[data-value="color2"]').append(secondaryColorPicker)
.append($('<div class="fas fa-link fa-xs link_icon right_menu_button" title="Link to theme color"></div>'));
template.find('.tag_as_folder').attr('id', tagAsFolderId);
primaryColorPicker.on('change', (evt) => onTagColorize(evt, (tag, color) => tag.color = color, 'background-color'));
secondaryColorPicker.on('change', (evt) => onTagColorize(evt, (tag, color) => tag.color2 = color, 'color'));
template.find('.tag_view_color_picker .link_icon').on('click', (evt) => {
const colorPicker = $(evt.target).closest('.tag_view_color_picker').find('toolcool-color-picker');
const defaultColor = colorPicker.attr('data-default-color');
// @ts-ignore
colorPicker[0].color = defaultColor;
});
list.append(template);
updateDrawTagFolder(template, tag);
// We prevent the popup from auto-close on Escape press on the color pickups. If the user really wants to, he can hit it again
// Not the "cleanest" way, that would be actually using and observer, remembering whether the popup was open just before, but eh
// Not gonna invest too much time into this small control here
let lastHit = 0;
template.on('keydown', (evt) => {
if (evt.key === 'Escape') {
if (evt.target === primaryColorPicker[0] || evt.target === secondaryColorPicker[0]) {
if (Date.now() - lastHit < 5000) // If user hits it twice in five seconds
return;
lastHit = Date.now();
evt.stopPropagation();
evt.preventDefault();
}
}
});
// @ts-ignore
$(colorPickerId).color = tag.color;
// @ts-ignore
$(colorPicker2Id).color = tag.color2;
updateDrawTagFolder(template, tag);
}
function onTagAsFolderClick() {
@ -1438,20 +1617,56 @@ function updateDrawTagFolder(element, tag) {
indicator.css('font-size', `calc(var(--mainFontSize) * ${tagFolder.size})`);
}
function onTagDeleteClick() {
if (!confirm('Are you sure?')) {
async function onTagDeleteClick() {
const id = $(this).closest('.tag_view_item').attr('id');
const tag = tags.find(x => x.id === id);
const otherTags = sortTags(tags.filter(x => x.id !== id).map(x => ({ id: x.id, name: x.name })));
const popupContent = $(`
<h3>Delete Tag</h3>
<div>Do you want to delete the tag <div id="tag_to_delete" class="tags_inline inline-flex margin-r2"></div>?</div>
<div class="m-t-2 marginBot5">If you want to merge all references to this tag into another tag, select it below:</div>
<select id="merge_tag_select">
<option value="">--- None ---</option>
${otherTags.map(x => `<option value="${x.id}">${x.name}</option>`).join('')}
</select>`);
appendTagToList(popupContent.find('#tag_to_delete'), tag);
// Make the select control more fancy on not mobile
if (!isMobile()) {
// Delete the empty option in the dropdown, and make the select2 be empty by default
popupContent.find('#merge_tag_select option[value=""]').remove();
popupContent.find('#merge_tag_select').select2({
width: '50%',
placeholder: 'Select tag to merge into',
allowClear: true,
}).val(null).trigger('change');
}
const result = await callGenericPopup(popupContent, POPUP_TYPE.CONFIRM);
if (result !== POPUP_RESULT.AFFIRMATIVE) {
return;
}
const id = $(this).closest('.tag_view_item').attr('id');
const mergeTagId = $('#merge_tag_select').val() ? String($('#merge_tag_select').val()) : null;
// Remove the tag from all entities that use it
// If we have a replacement tag, add that one instead
for (const key of Object.keys(tag_map)) {
tag_map[key] = tag_map[key].filter(x => x !== id);
if (tag_map[key].includes(id)) {
tag_map[key] = tag_map[key].filter(x => x !== id);
if (mergeTagId) tag_map[key].push(mergeTagId);
}
}
const index = tags.findIndex(x => x.id === id);
tags.splice(index, 1);
$(`.tag[id="${id}"]`).remove();
$(`.tag_view_item[id="${id}"]`).remove();
toastr.success(`'${tag.name}' deleted${mergeTagId ? ` and merged into '${tags.find(x => x.id === mergeTagId).name}'` : ''}`, 'Delete Tag');
printCharactersDebounced();
saveSettingsDebounced();
}
@ -1461,35 +1676,41 @@ function onTagRenameInput() {
const newName = $(this).text();
const tag = tags.find(x => x.id === id);
tag.name = newName;
$(this).attr('dirty', '');
$(`.tag[id="${id}"] .tag_name`).text(newName);
saveSettingsDebounced();
}
function onTagColorize(evt) {
/**
* Handles the colorization of a tag when the user interacts with the color picker
*
* @param {*} evt - The custom colorize event object
* @param {(tag: Tag, val: string) => void} setColor - A function that sets the color of the tag
* @param {string} cssProperty - The CSS property to apply the color to
*/
function onTagColorize(evt, setColor, cssProperty) {
console.debug(evt);
const isDefaultColor = $(evt.target).data('default-color') === evt.detail.rgba;
$(evt.target).closest('.tag_view_color_picker').find('.link_icon').toggle(!isDefaultColor);
const id = $(evt.target).closest('.tag_view_item').attr('id');
const newColor = evt.detail.rgba;
$(evt.target).parent().parent().find('.tag_view_name').css('background-color', newColor);
$(`.tag[id="${id}"]`).css('background-color', newColor);
$(`.bogus_folder_select[tagid="${id}"] .avatar`).css('background-color', newColor);
let newColor = evt.detail.rgba;
if (isDefaultColor) newColor = '';
$(evt.target).closest('.tag_view_item').find('.tag_view_name').css(cssProperty, newColor);
const tag = tags.find(x => x.id === id);
tag.color = newColor;
setColor(tag, newColor);
console.debug(tag);
saveSettingsDebounced();
// Debounce redrawing color of the tag in other elements
debouncedTagColoring(tag.id, cssProperty, newColor);
}
function onTagColorize2(evt) {
console.debug(evt);
const id = $(evt.target).closest('.tag_view_item').attr('id');
const newColor = evt.detail.rgba;
$(evt.target).parent().parent().find('.tag_view_name').css('color', newColor);
$(`.tag[id="${id}"]`).css('color', newColor);
$(`.bogus_folder_select[tagid="${id}"] .avatar`).css('color', newColor);
const tag = tags.find(x => x.id === id);
tag.color2 = newColor;
console.debug(tag);
saveSettingsDebounced();
}
const debouncedTagColoring = debounce((tagId, cssProperty, newColor) => {
$(`.tag[id="${tagId}"]`).css(cssProperty, newColor);
$(`.bogus_folder_select[tagid="${tagId}"] .avatar`).css(cssProperty, newColor);
}, debounce_timeout.quick);
function onTagListHintClick() {
$(this).toggleClass('selected');
@ -1529,9 +1750,7 @@ function copyTags(data) {
tag_map[data.newAvatar] = Array.from(new Set([...prevTagMap, ...newTagMap]));
}
function printViewTagList(empty = true) {
const tagContainer = $('#dialogue_popup .tag_view_list_tags');
function printViewTagList(tagContainer, empty = true) {
if (empty) tagContainer.empty();
const everything = Object.values(tag_map).flat();
const sortedTags = sortTags(tags);
@ -1569,7 +1788,7 @@ function registerTagsSlashCommands() {
toastr.warning('Tag name must be provided.');
return null;
}
let tag = tags.find(t => t.name === tagName);
let tag = getTag(tagName);
if (allowCreate && !tag) {
tag = createNewTag(tagName);
}
@ -1580,21 +1799,6 @@ function registerTagsSlashCommands() {
return tag;
}
function updateTagsList() {
switch (menu_type) {
case 'characters':
printTagFilters(tag_filter_types.character);
printTagFilters(tag_filter_types.group_member);
break;
case 'character_edit':
applyTagsOnCharacterSelect();
break;
case 'group_edit':
select_group_chats(selected_group, true);
break;
}
}
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'tag-add',
returns: 'true/false - Whether the tag was added or was assigned already',
@ -1604,8 +1808,7 @@ function registerTagsSlashCommands() {
if (!key) return 'false';
const tag = paraGetTag(tagName, { allowCreate: true });
if (!tag) return 'false';
const result = addTagToEntity(tag, key);
updateTagsList();
const result = addTagsToEntity(tag, key);
return String(result);
},
namedArgumentList: [
@ -1658,7 +1861,6 @@ function registerTagsSlashCommands() {
const tag = paraGetTag(tagName);
if (!tag) return 'false';
const result = removeTagFromEntity(tag, key);
updateTagsList();
return String(result);
},
namedArgumentList: [
@ -1781,22 +1983,31 @@ export function initTags() {
eventSource.on(event_types.CHARACTER_DUPLICATED, copyTags);
eventSource.makeFirst(event_types.CHAT_CHANGED, () => selected_group ? applyTagsOnGroupSelect() : applyTagsOnCharacterSelect());
$(document).on('input', '#dialogue_popup input[name="auto_sort_tags"]', (evt) => {
$(document).on('input', '#tag_view_list input[name="auto_sort_tags"]', (evt) => {
const toggle = $(evt.target).is(':checked');
toggleAutoSortTags(evt.originalEvent, toggle);
printViewTagList();
printViewTagList($('#tag_view_list .tag_view_list_tags'));
});
$(document).on('focusout', '#dialogue_popup .tag_view_name', (evt) => {
$(document).on('focusout', '#tag_view_list .tag_view_name', (evt) => {
// Reorder/reprint tags, but only if the name actually has changed, and only if we auto sort tags
if (!power_user.auto_sort_tags || !$(evt.target).is('[dirty]')) return;
// Remember the order, so we can flash highlight if it changed after reprinting
const tagId = $(evt.target).parent('.tag_view_item').attr('id');
const oldOrder = $('#dialogue_popup .tag_view_item').map((_, el) => el.id).get();
const tagId = ($(evt.target).closest('.tag_view_item')).attr('id');
const oldOrder = $('#tag_view_list .tag_view_item').map((_, el) => el.id).get();
printViewTagList();
printViewTagList($('#tag_view_list .tag_view_list_tags'));
const newOrder = $('#dialogue_popup .tag_view_item').map((_, el) => el.id).get();
// If the new focus would've been inside the now redrawn tag list, we should at least move back the focus to the current name
// Otherwise tab-navigation gets a bit weird
if (evt.relatedTarget instanceof HTMLElement && $(evt.relatedTarget).closest('#tag_view_list')) {
$(`#tag_view_list .tag_view_item[id="${tagId}"] .tag_view_name`)[0]?.focus();
}
const newOrder = $('#tag_view_list .tag_view_item').map((_, el) => el.id).get();
const orderChanged = !oldOrder.every((id, index) => id === newOrder[index]);
if (orderChanged) {
flashHighlight($(`#dialogue_popup .tag_view_item[id="${tagId}"]`));
flashHighlight($(`#tag_view_list .tag_view_item[id="${tagId}"]`));
}
});

View File

@ -74,6 +74,20 @@ export function onlyUnique(value, index, array) {
return array.indexOf(value) === index;
}
/**
* Removes the first occurrence of a specified item from an array
*
* @param {*[]} array - The array from which to remove the item
* @param {*} item - The item to remove from the array
* @returns {boolean} - Returns true if the item was successfully removed, false otherwise.
*/
export function removeFromArray(array, item) {
const index = array.indexOf(item);
if (index === -1) return false;
array.splice(index, 1);
return true;
}
/**
* Checks if a string only contains digits.
* @param {string} str The string to check.
@ -1518,6 +1532,35 @@ export function flashHighlight(element, timespan = 2000) {
setTimeout(() => element.removeClass('flash animated'), timespan);
}
/**
* Checks if the given control has an animation applied to it
*
* @param {HTMLElement} control - The control element to check for animation
* @returns {boolean} Whether the control has an animation applied
*/
export function hasAnimation(control) {
const animatioName = getComputedStyle(control, null)["animation-name"];
return animatioName != "none";
}
/**
* Run an action once an animation on a control ends. If the control has no animation, the action will be executed immediately.
*
* @param {HTMLElement} control - The control element to listen for animation end event
* @param {(control:*?) => void} callback - The callback function to be executed when the animation ends
*/
export function runAfterAnimation(control, callback) {
if (hasAnimation(control)) {
const onAnimationEnd = () => {
control.removeEventListener('animationend', onAnimationEnd);
callback(control);
};
control.addEventListener('animationend', onAnimationEnd);
} else {
callback(control);
}
}
/**
* A common base function for case-insensitive and accent-insensitive string comparisons.
*
@ -1774,3 +1817,22 @@ export async function checkOverwriteExistingData(type, existingNames, name, { in
return true;
}
/**
* Generates a free name by appending a counter to the given name if it already exists in the list
*
* @param {string} name - The original name to check for existence in the list
* @param {string[]} list - The list of names to check for existence
* @param {(n: number) => string} [numberFormatter=(n) => ` #${n}`] - The function used to format the counter
* @returns {string} The generated free name
*/
export function getFreeName(name, list, numberFormatter = (n) => ` #${n}`) {
if (!list.includes(name)) {
return name;
}
let counter = 1;
while (list.includes(`${name} #${counter}`)) {
counter++;
}
return `${name}${numberFormatter(counter)}`;
}

View File

@ -1,11 +1,14 @@
@charset "UTF-8";
@import url(css/animations.css);
@import url(css/popup.css);
@import url(css/promptmanager.css);
@import url(css/loader.css);
@import url(css/character-group-overlay.css);
@import url(css/file-form.css);
@import url(css/logprobs.css);
@import url(css/accounts.css);
@import url(css/tags.css);
:root {
--doc-height: 100%;
@ -48,6 +51,9 @@
--active: rgb(88, 182, 0);
--preferred: rgb(244, 67, 54);
--interactable-outline-color: var(--white100);
--interactable-outline-color-faint: var(--white50a);
/*Default Theme, will be changed by ToolCool Color Picker*/
--SmartThemeBodyColor: rgb(220, 220, 210);
@ -106,6 +112,9 @@
--avatar-base-border-radius: 2px;
--avatar-base-border-radius-round: 50%;
--inline-avatar-small-factor: 0.6;
--animation-duration: 125ms;
--animation-duration-slow: calc(var(--animation-duration) * 3);
}
* {
@ -205,25 +214,43 @@ table.responsiveTable {
box-shadow: inset 0 0 5px var(--SmartThemeQuoteColor);
}
@keyframes flash {
20%,
60%,
100% {
opacity: 1;
}
0%,
40%,
80% {
opacity: 0.2;
}
}
.flash {
animation-name: flash;
}
/* Keyboard/focus navigation styling */
/* Mimic the outline of keyboard navigation for most most focusable controls */
.interactable {
border-radius: 5px;
}
/* Outline for "normal" buttons should only be when actual keyboard navigation is used */
.interactable:focus-visible {
outline: 1px solid var(--interactable-outline-color);
}
/* The specific input controls can always have a faint outline, even on mouse interaction, to give a better hint */
select:focus-visible,
input:focus-visible,
textarea:focus-visible {
outline: 1px solid var(--interactable-outline-color-faint);
}
input[type='checkbox']:focus-visible {
outline: 1px solid var(--interactable-outline-color);
}
/* General dragover styling */
.dragover {
filter: brightness(1.1) saturate(1.0);
outline: 3px dashed var(--SmartThemeBorderColor);
animation: pulse 0.5s infinite alternate;
}
.dragover.no_animation {
animation: none;
}
.tokenItemizingSubclass {
font-size: calc(var(--mainFontSize) * 0.8);
color: var(--SmartThemeEmColor);
@ -246,6 +273,11 @@ table.responsiveTable {
text-align: center;
}
.fa-lock.right_menu_button,
.fa-unlock.right_menu_button {
padding: 2px 4px;
}
.text_muted {
font-size: calc(var(--mainFontSize) - 0.2rem);
color: var(--white50a);
@ -670,7 +702,6 @@ body .panelControlBar {
width: var(--bottomFormBlockSize);
height: var(--bottomFormBlockSize);
margin: 0;
outline: none;
border: none;
cursor: pointer;
opacity: 0.7;
@ -787,7 +818,6 @@ body .panelControlBar {
height: var(--bottomFormBlockSize);
font-size: var(--bottomFormIconSize);
margin: 0;
outline: none;
border: none;
position: relative;
opacity: 0.7;
@ -809,7 +839,7 @@ body .panelControlBar {
#options,
#extensionsMenu,
.shadow_popup .popper-modal {
.popup .popper-modal {
display: flex;
z-index: 29999;
background-color: var(--SmartThemeBlurTintColor);
@ -818,6 +848,12 @@ body .panelControlBar {
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
flex-flow: column;
border-radius: 10px;
padding: 2px;
}
#extensionsMenu,
.options-content {
padding: 2px;
}
.options-content,
@ -853,11 +889,6 @@ body .panelControlBar {
padding: 1px;
}
#extensionsMenuButton:hover {
opacity: 1;
filter: brightness(1.2);
}
.options-content a,
#extensionsMenu>div,
.list-group>div,
@ -1026,11 +1057,11 @@ body .panelControlBar {
justify-content: space-evenly;
}
.avatar.selectable {
.avatar.interactable {
opacity: 0.6;
}
.avatar.selectable:hover {
.avatar.interactable:hover {
opacity: 1;
background-color: transparent !important;
cursor: pointer;
@ -1186,16 +1217,14 @@ select {
transition: clip-path 200ms;
}
@keyframes script_progress_pulse {
#send_textarea:focus-visible {
/* Disable outline for the chat bar itself, we add it to the outer div */
outline: none;
}
0%,
100% {
border-top-color: var(--progColor);
}
50% {
border-top-color: var(--progFlashColor);
}
#send_form:has(#send_textarea:focus-visible) {
border-color: var(--interactable-outline-color-faint);
outline: 1px solid var(--interactable-outline-color-faint);
}
#form_sheld.isExecutingCommandsFromChatInput.script_paused #send_textarea {
@ -2028,9 +2057,13 @@ body[data-stscript-style] .hljs.language-stscript {
}
}
.editor_maximize {
padding: 2px;
}
#character_popup .editor_maximize {
cursor: pointer;
margin: 5px;
margin: 3px;
opacity: 0.75;
filter: grayscale(1);
-webkit-transition: all 250ms ease-in-out;
@ -2147,12 +2180,6 @@ h3 {
padding: 5px 0;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
}
input[type="file"] {
display: none;
}
@ -2173,12 +2200,6 @@ input[type="file"] {
padding: 0px 10px 0px 5px;
}
#right-nav-panel-tabs .right_menu_button,
#CharListButtonAndHotSwaps .right_menu_button {
padding-right: 0;
color: unset;
}
#chartokenwarning.menu_button {
font-size: unset;
height: fit-content;
@ -2194,13 +2215,14 @@ input[type="file"] {
cursor: pointer;
text-align: center;
margin-top: 0;
padding: 1px;
filter: grayscale(1) brightness(75%);
-webkit-transition: all 0.5s ease-in-out;
transition: all 0.5s ease-in-out;
}
.right_menu_button:hover {
filter: brightness(150%) grayscale(1);
filter: brightness(150%);
}
#rm_button_characters,
@ -2209,6 +2231,7 @@ input[type="file"] {
#WI_button_panel_pin_div {
font-size: 24px;
display: inline;
padding: 1px;
}
#rm_button_panel_pin_div,
@ -2219,8 +2242,11 @@ input[type="file"] {
}
#rm_button_panel_pin_div:hover,
#rm_button_panel_pin_div:has(:focus-visible),
#lm_button_panel_pin_div:hover,
#WI_button_panel_pin_div:hover {
#lm_button_panel_pin_div:has(:focus-visible),
#WI_button_panel_pin_div:hover,
#WI_button_panel_pin_div:has(:focus-visible) {
opacity: 1;
}
@ -2268,6 +2294,7 @@ input[type="file"] {
flex: 1;
overflow: hidden;
opacity: 0.5;
padding: 1px;
}
#rm_button_selected_ch:hover {
@ -2295,6 +2322,7 @@ input[type="file"] {
justify-content: center;
align-items: center;
flex-wrap: wrap;
padding: 1px;
}
.bulk_select_checkbox {
@ -2332,12 +2360,13 @@ input[type="file"] {
flex-grow: 1;
display: flex;
height: 100%;
padding: 1px;
}
#rm_ch_create_block {
display: none;
overflow-y: auto;
padding: 5px;
padding: 4px;
height: 100%;
}
@ -2747,19 +2776,23 @@ input[type=search]:focus::-webkit-search-cancel-button {
outline: 2px solid var(--golden);
}
.bg_example:hover.locked .bg_example_lock {
.bg_example:hover.locked .bg_example_lock,
.bg_example:focus-within.locked .bg_example_lock {
display: none;
}
.bg_example:hover:not(.locked) .bg_example_unlock {
.bg_example:hover:not(.locked) .bg_example_unlock,
.bg_example:focus-within:not(.locked) .bg_example_unlock {
display: none;
}
.bg_example:hover[custom="true"] .bg_example_edit {
.bg_example:hover[custom="true"] .bg_example_edit,
.bg_example:focus-within[custom="true"] .bg_example_edit {
display: none;
}
.bg_example:hover[custom="false"] .bg_example_copy {
.bg_example:hover[custom="false"] .bg_example_copy,
.bg_example:focus-within[custom="false"] .bg_example_copy {
display: none;
}
@ -2784,24 +2817,23 @@ input[type=search]:focus::-webkit-search-cancel-button {
}
.bg_button {
width: 15px;
height: 15px;
padding: 4px;
position: absolute;
top: 5px;
cursor: pointer;
opacity: 0.8;
border-radius: 50%;
border-radius: 3px;
font-size: 20px;
color: var(--black70a);
text-shadow: none;
padding: 0;
margin: 0;
filter: drop-shadow(0px 0px 3px white);
transition: opacity 0.2s ease-in-out;
display: none;
}
.bg_example:hover .bg_button {
.bg_example:hover .bg_button,
.bg_example:focus-within .bg_button {
display: block;
}
@ -2810,15 +2842,15 @@ input[type=search]:focus::-webkit-search-cancel-button {
}
.bg_example_cross {
right: 10px;
right: 6px;
}
.bg_example_edit {
left: 10px;
left: 6px;
}
.bg_example_copy {
left: 10px;
left: 6px;
}
.bg_example_lock,
@ -2847,6 +2879,7 @@ input[type=search]:focus::-webkit-search-cancel-button {
flex-direction: column;
height: 100%;
overflow-y: auto;
padding: 1px;
}
.avatar_div {
@ -2936,6 +2969,7 @@ input[type=search]:focus::-webkit-search-cancel-button {
flex-direction: row;
align-items: flex-start;
gap: 5px;
margin: 1px;
padding: 5px;
border-radius: 10px;
cursor: pointer;
@ -3035,7 +3069,8 @@ grammarly-extension {
font-size: calc(var(--mainFontSize) * 0.9);
display: flex;
align-items: center;
gap: 10px;
gap: 6px;
padding: 1px 3px;
}
#result_info_text {
@ -3045,15 +3080,14 @@ grammarly-extension {
text-align: right;
}
.rm_stats_button {
cursor: pointer;
#result_info .right_menu_button {
padding: 4px;
}
/* Focus */
#bulk_tag_popup,
#dialogue_popup,
.dialogue_popup {
#dialogue_popup {
width: 500px;
max-width: 90vw;
max-width: 90svw;
@ -3101,17 +3135,16 @@ grammarly-extension {
min-width: 750px;
}
.horizontal_scrolling_dialogue_popup {
#dialogue_popup .horizontal_scrolling_dialogue_popup {
overflow-x: unset !important;
}
.vertical_scrolling_dialogue_popup {
#dialogue_popup .vertical_scrolling_dialogue_popup {
overflow-y: unset !important;
}
#bulk_tag_popup_holder,
#dialogue_popup_holder,
.dialogue_popup_holder {
#dialogue_popup_holder {
display: flex;
flex-direction: column;
height: 100%;
@ -3119,15 +3152,14 @@ grammarly-extension {
padding: 0 10px;
}
#dialogue_popup_text,
.dialogue_popup_text {
#dialogue_popup_text {
flex-grow: 1;
overflow-y: auto;
height: 100%;
}
#dialogue_popup_controls,
.dialogue_popup_controls {
#dialogue_popup_controls {
margin-top: 10px;
display: flex;
align-self: center;
gap: 20px;
@ -3135,16 +3167,14 @@ grammarly-extension {
#bulk_tag_popup_reset,
#bulk_tag_popup_remove_mutual,
#dialogue_popup_ok,
.dialogue_popup_ok {
#dialogue_popup_ok {
background-color: var(--crimson70a);
cursor: pointer;
}
#bulk_tag_popup_reset:hover,
#bulk_tag_popup_remove_mutual:hover,
#dialogue_popup_ok:hover,
.dialogue_popup_ok:hover {
#dialogue_popup_ok:hover {
background-color: var(--crimson-hover);
}
@ -3152,15 +3182,13 @@ grammarly-extension {
max-height: 70vh;
}
#dialogue_popup_input,
.dialogue_popup_input {
margin: 10px 0;
#dialogue_popup_input {
margin: 10px 0 0 0;
width: 100%;
}
#bulk_tag_popup_cancel,
#dialogue_popup_cancel,
.dialogue_popup_cancel {
#dialogue_popup_cancel {
cursor: pointer;
}
@ -3215,13 +3243,11 @@ grammarly-extension {
}
#dialogue_del_mes .menu_button {
margin-left: 25px;
margin-right: 25px;
}
#shadow_popup,
.shadow_popup {
#shadow_popup {
backdrop-filter: blur(calc(var(--SmartThemeBlurStrength) * 2));
-webkit-backdrop-filter: blur(calc(var(--SmartThemeBlurStrength) * 2));
background-color: var(--black30a);
@ -3233,15 +3259,6 @@ grammarly-extension {
height: 100svh;
z-index: 9999;
top: 0;
&.shadow_popup {
z-index: 9998;
}
}
.dialogue_popup.dragover {
filter: brightness(1.1) saturate(1.1);
outline: 3px dashed var(--SmartThemeBorderColor);
}
#bgtest {
@ -3386,7 +3403,7 @@ body #toast-container>div {
display: block;
}
input[type='checkbox']:not(#nav-toggle):not(#rm_button_panel_pin):not(#lm_button_panel_pin):not(#WI_panel_pin) {
input[type='checkbox'] {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
@ -3404,13 +3421,10 @@ input[type='checkbox']:not(#nav-toggle):not(#rm_button_panel_pin):not(#lm_button
flex-shrink: 0;
place-content: center;
filter: brightness(1.2);
}
input[type='checkbox']:not(#nav-toggle):not(#rm_button_panel_pin):not(#lm_button_panel_pin):not(#WI_panel_pin):not(.del_checkbox) {
display: grid;
}
input[type="checkbox"]:not(#nav-toggle):not(#rm_button_panel_pin):not(#lm_button_panel_pin):not(#WI_panel_pin)::before {
input[type="checkbox"]::before {
content: "";
width: 0.65em;
height: 0.65em;
@ -3421,16 +3435,16 @@ input[type="checkbox"]:not(#nav-toggle):not(#rm_button_panel_pin):not(#lm_button
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
input[type="checkbox"]:not(#nav-toggle):not(#rm_button_panel_pin):not(#lm_button_panel_pin):not(#WI_panel_pin):checked::before {
input[type="checkbox"]:checked::before {
transform: scale(1);
}
input[type="checkbox"]:not(#nav-toggle):not(#rm_button_panel_pin):not(#lm_button_panel_pin):not(#WI_panel_pin):disabled {
input[type="checkbox"]:disabled {
color: grey;
cursor: not-allowed;
}
.del_checkbox {
input[type='checkbox'].del_checkbox {
display: none;
opacity: 0.7;
margin-top: 12px;
@ -3442,6 +3456,7 @@ input[type="checkbox"]:not(#nav-toggle):not(#rm_button_panel_pin):not(#lm_button
display: flex;
flex-wrap: wrap;
justify-content: space-around;
padding: 1px;
}
.avatar-container .avatar {
@ -3559,6 +3574,16 @@ input[type="checkbox"]:not(#nav-toggle):not(#rm_button_panel_pin):not(#lm_button
z-index: 1;
}
.neo-range-slider:hover,
input[type="range"]:hover {
filter: brightness(1.25);
}
.neo-range-slider:focus-visible,
input[type="range"]:focus-visible {
outline: 1px solid var(--interactable-outline-color);
}
.range-block-range {
margin: 0;
flex: 5;
@ -3758,35 +3783,29 @@ input[type="range"]::-webkit-slider-thumb {
/* height: 20px; */
position: relative;
display: flex;
gap: 10px;
gap: 4px;
flex-wrap: nowrap;
justify-content: flex-end;
transition: all 200ms;
overflow-x: hidden;
padding: 1px;
}
.extraMesButtons {
display: none;
}
.mes_buttons .mes_edit,
.mes_buttons .mes_bookmark,
.mes_buttons .mes_create_bookmark,
.extraMesButtonsHint,
.tagListHint,
.extraMesButtons div {
.mes_button,
.extraMesButtons>div {
cursor: pointer;
transition: 0.3s ease-in-out;
filter: drop-shadow(0px 0px 2px black);
opacity: 0.3;
padding: 1px 3px;
}
.mes_buttons .mes_edit:hover,
.mes_buttons .mes_bookmark:hover,
.mes_buttons .mes_create_bookmark:hover,
.extraMesButtonsHint:hover,
.tagListHint:hover,
.extraMesButtons div:hover {
.mes_button:hover,
.extraMesButtons>div:hover {
opacity: 1;
}
@ -4093,17 +4112,6 @@ h5 {
animation: infinite-spinning 1s ease-out 0s infinite normal;
}
/* HEINOUS */
@keyframes infinite-spinning {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
#export_character_div {
display: grid;
grid-template-columns: 340px auto;
@ -4257,24 +4265,6 @@ body.big-avatars .missing-avatar {
margin: 5px 0;
}
@keyframes ellipsis {
0% {
content: ""
}
25% {
content: "."
}
50% {
content: ".."
}
75% {
content: "..."
}
}
.warning {
color: var(--warning);
font-weight: bolder;
@ -4588,6 +4578,7 @@ body:has(#character_popup.open) #top-settings-holder:has(.drawer-content.openDra
display: inline-block;
cursor: pointer;
font-size: var(--topBarIconSize);
padding: 1px 3px;
}
.drawer-icon.openIcon {
@ -5177,5 +5168,3 @@ body:not(.movingUI) .drawer-content.maximized {
.regex-highlight {
color: #FAF8F6;
}
/* Pastel White */