mirror of
				https://github.com/SillyTavern/SillyTavern.git
				synced 2025-06-05 21:59:27 +02:00 
			
		
		
		
	
		
			
				
	
	
		
			248 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			248 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /* 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
 | |
|     '.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
 | |
| ];
 | |
| 
 | |
| if (CSS.supports('selector(:has(*))')) {
 | |
|     // Option entries in the extension menu popup that are coming from extensions
 | |
|     interactableSelectors.push('#extensionsMenu div:has(.extensionsMenuExtensionButton)');
 | |
| }
 | |
| 
 | |
| 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);
 | |
| }
 |