| 1 | /* All selectors that should act as interactables / keyboard buttons by default */ |
| 2 | const interactableSelectors = [ |
| 3 | '.interactable', // Main interactable class for ALL interactable controls (can also be manually added in code, so that's why its listed here) |
| 4 | '.custom_interactable', // Manually made interactable controls via code (see 'makeKeyboardInteractable()') |
| 5 | '.menu_button', // General menu button in ST |
| 6 | '.right_menu_button', // Button-likes in many menus |
| 7 | '.drawer-icon', // Main "menu bar" icons |
| 8 | '.inline-drawer-icon', // Buttons/icons inside the drawer menus |
| 9 | '.paginationjs-pages li a', // Pagination buttons |
| 10 | '.group_select, .character_select, .bogus_folder_select', // Cards to select char, group or folder in character list and other places |
| 11 | '.swipe_picker_block', // Swipe picker entries in the swipe history popup |
| 12 | '.avatar-container', // Persona list blocks |
| 13 | '.tag .tag_remove', // Remove button in removable tags |
| 14 | '.bg_example', // Background elements in the background menu |
| 15 | '.bg_example .jg-button, .bg_example .mobile-only-menu-toggle', // The inline buttons on the backgrounds |
| 16 | '#options a', // Option entries in the popup options menu |
| 17 | '.mes_buttons .mes_button', // Small inline buttons on the chat messages |
| 18 | '.extraMesButtons>div:not(.mes_button)', // The extra/extension buttons inline on the chat messages |
| 19 | '.swipe_left, .swipe_right', // Swipe buttons on the last message |
| 20 | '.stscript_btn', // STscript buttons in the chat bar |
| 21 | '.select2_choice_clickable+span.select2-container .select2-selection__choice__display', // select2 control elements if they are meant to be clickable |
| 22 | '.avatar_load_preview', // Char display avatar selection |
| 23 | '.bg_tabs_list .bg_tab_button', // Background tabs |
| 24 | '.select_chat_block', // The blocks to select a past chat in the past chats menu |
| 25 | '.select_chat_block .exportRawChatButton', // Export raw chat button in the past chats menu |
| 26 | '.select_chat_block .exportChatButton', // Export chat button in the past chats menu |
| 27 | '.select_chat_block .PastChat_cross', // Delete chat button in the past chats menu |
| 28 | '.select_chat_block .renameChatButton', // The button to rename a past chat in the past chats menu |
| 29 | ]; |
| 30 | |
| 31 | if (CSS.supports('selector(:has(*))')) { |
| 32 | // Option entries in the extension menu popup that are coming from extensions |
| 33 | interactableSelectors.push('#extensionsMenu div:has(.extensionsMenuExtensionButton)'); |
| 34 | } |
| 35 | |
| 36 | export const INTERACTABLE_CONTROL_CLASS = 'interactable'; |
| 37 | export const CUSTOM_INTERACTABLE_CONTROL_CLASS = 'custom_interactable'; |
| 38 | |
| 39 | export const NOT_FOCUSABLE_CONTROL_CLASS = 'not_focusable'; |
| 40 | export const DISABLED_CONTROL_CLASS = 'disabled'; |
| 41 | |
| 42 | /** |
| 43 | * An observer that will check if any new interactables or scroll reset containers are added to the body |
| 44 | * @type {MutationObserver} |
| 45 | */ |
| 46 | const observer = new MutationObserver(mutations => { |
| 47 | mutations.forEach(mutation => { |
| 48 | if (mutation.type === 'childList') { |
| 49 | mutation.addedNodes.forEach(handleNodeChange); |
| 50 | } |
| 51 | if (mutation.type === 'attributes') { |
| 52 | const target = mutation.target; |
| 53 | if (mutation.attributeName === 'class' && target instanceof Element) { |
| 54 | handleNodeChange(target); |
| 55 | } |
| 56 | } |
| 57 | }); |
| 58 | }); |
| 59 | |
| 60 | /** |
| 61 | * Function to handle node changes (added or modified nodes) |
| 62 | * @param {Element} node |
| 63 | */ |
| 64 | function handleNodeChange(node) { |
| 65 | if (node.nodeType === Node.ELEMENT_NODE && node instanceof Element) { |
| 66 | // Handle keyboard interactables |
| 67 | if (isKeyboardInteractable(node)) { |
| 68 | makeKeyboardInteractable(node); |
| 69 | } |
| 70 | initializeInteractables(node); |
| 71 | |
| 72 | // Handle scroll reset containers |
| 73 | if (node.classList.contains('scroll-reset-container')) { |
| 74 | applyScrollResetBehavior(node); |
| 75 | } |
| 76 | initializeScrollResetBehaviors(node); |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * Registers an interactable class (for example for an extension) and makes it keyboard interactable. |
| 82 | * Optionally apply the 'not_focusable' and 'disabled' classes if needed. |
| 83 | * |
| 84 | * @param {string} interactableSelector - The CSS selector for the interactable (Supports class combinations, chained via dots like <c>tag.actionable</c>, and sub selectors) |
| 85 | * @param {object} [options={}] - Optional settings for the interactable |
| 86 | * @param {boolean} [options.disabledByDefault=false] - Whether interactables of this class should be disabled by default |
| 87 | * @param {boolean} [options.notFocusableByDefault=false] - Whether interactables of this class should not be focusable by default |
| 88 | */ |
| 89 | export function registerInteractableType(interactableSelector, { disabledByDefault = false, notFocusableByDefault = false } = {}) { |
| 90 | interactableSelectors.push(interactableSelector); |
| 91 | |
| 92 | const interactables = document.querySelectorAll(interactableSelector); |
| 93 | |
| 94 | if (disabledByDefault || notFocusableByDefault) { |
| 95 | interactables.forEach(interactable => { |
| 96 | if (disabledByDefault) interactable.classList.add(DISABLED_CONTROL_CLASS); |
| 97 | if (notFocusableByDefault) interactable.classList.add(NOT_FOCUSABLE_CONTROL_CLASS); |
| 98 | }); |
| 99 | } |
| 100 | |
| 101 | makeKeyboardInteractable(...interactables); |
| 102 | } |
| 103 | |
| 104 | /** |
| 105 | * Checks if the given control is a keyboard-enabled interactable. |
| 106 | * |
| 107 | * @param {Element} control - The control element to check |
| 108 | * @returns {boolean} Returns true if the control is a keyboard interactable, false otherwise |
| 109 | */ |
| 110 | export function isKeyboardInteractable(control) { |
| 111 | // Check if this control matches any of the selectors |
| 112 | return interactableSelectors.some(selector => control.matches(selector)); |
| 113 | } |
| 114 | |
| 115 | /** |
| 116 | * Makes all the given controls keyboard interactable and sets their state. |
| 117 | * If the control doesn't have any of the classes, it will be set to a custom-enabled keyboard interactable. |
| 118 | * |
| 119 | * @param {Element[]} interactables - The controls to make interactable and set their state |
| 120 | */ |
| 121 | export function makeKeyboardInteractable(...interactables) { |
| 122 | interactables.forEach(interactable => { |
| 123 | // 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. |
| 124 | if (!isKeyboardInteractable(interactable)) { |
| 125 | interactable.classList.add(CUSTOM_INTERACTABLE_CONTROL_CLASS); |
| 126 | } |
| 127 | |
| 128 | // Just for CSS styling and future reference, every keyboard interactable control should have a common class |
| 129 | if (!interactable.classList.contains(INTERACTABLE_CONTROL_CLASS)) { |
| 130 | interactable.classList.add(INTERACTABLE_CONTROL_CLASS); |
| 131 | } |
| 132 | |
| 133 | /** |
| 134 | * Check if the element or any parent element has 'disabled' or 'not_focusable' class |
| 135 | * @param {Element} el |
| 136 | * @returns {boolean} |
| 137 | */ |
| 138 | const hasDisabledOrNotFocusableAncestor = (el) => { |
| 139 | while (el) { |
| 140 | if (el.classList.contains(NOT_FOCUSABLE_CONTROL_CLASS) || el.classList.contains(DISABLED_CONTROL_CLASS)) { |
| 141 | return true; |
| 142 | } |
| 143 | el = el.parentElement; |
| 144 | } |
| 145 | return false; |
| 146 | }; |
| 147 | |
| 148 | // Set/remove the tabindex accordingly to the classes. Remembering if it had a custom value. |
| 149 | if (!hasDisabledOrNotFocusableAncestor(interactable)) { |
| 150 | if (!interactable.hasAttribute('tabindex')) { |
| 151 | const tabIndex = interactable.getAttribute('data-original-tabindex') ?? '0'; |
| 152 | interactable.setAttribute('tabindex', tabIndex); |
| 153 | } |
| 154 | } else { |
| 155 | interactable.setAttribute('data-original-tabindex', interactable.getAttribute('tabindex')); |
| 156 | interactable.removeAttribute('tabindex'); |
| 157 | } |
| 158 | }); |
| 159 | } |
| 160 | |
| 161 | /** |
| 162 | * Initializes the focusability of controls on the given element or the document |
| 163 | * |
| 164 | * @param {Element|Document} [element=document] - The element on which to initialize the interactable state. Defaults to the document. |
| 165 | */ |
| 166 | function initializeInteractables(element = document) { |
| 167 | const interactables = getAllInteractables(element); |
| 168 | makeKeyboardInteractable(...interactables); |
| 169 | } |
| 170 | |
| 171 | /** |
| 172 | * Queries all interactables within the given element based on the given selectors and returns them as an array |
| 173 | * |
| 174 | * @param {Element|Document} element - The element within which to query the interactables |
| 175 | * @returns {HTMLElement[]} An array containing all the interactables that match the given selectors |
| 176 | */ |
| 177 | function getAllInteractables(element) { |
| 178 | // Query each selector individually and combine all to a big array to return |
| 179 | return [].concat(...interactableSelectors.map(selector => Array.from(element.querySelectorAll(`${selector}`)))); |
| 180 | } |
| 181 | |
| 182 | /** |
| 183 | * Function to apply scroll reset behavior to a container |
| 184 | * @param {Element} container - The container |
| 185 | */ |
| 186 | const applyScrollResetBehavior = (container) => { |
| 187 | container.addEventListener('focusout', (e) => { |
| 188 | setTimeout(() => { |
| 189 | const focusedElement = document.activeElement; |
| 190 | if (!container.contains(focusedElement)) { |
| 191 | container.scrollTop = 0; |
| 192 | container.scrollLeft = 0; |
| 193 | } |
| 194 | }, 0); |
| 195 | }); |
| 196 | }; |
| 197 | |
| 198 | /** |
| 199 | * Initializes the scroll reset behavior on the given element or the document |
| 200 | * |
| 201 | * @param {Element|Document} [element=document] - The element on which to initialize the scroll reset behavior. Defaults to the document. |
| 202 | */ |
| 203 | function initializeScrollResetBehaviors(element = document) { |
| 204 | const scrollResetContainers = element.querySelectorAll('.scroll-reset-container'); |
| 205 | scrollResetContainers.forEach(container => applyScrollResetBehavior(container)); |
| 206 | } |
| 207 | |
| 208 | /** |
| 209 | * Handles keydown events on the document to trigger click on Enter key press for interactables |
| 210 | * |
| 211 | * @param {KeyboardEvent} event - The keyboard event |
| 212 | */ |
| 213 | function handleGlobalKeyDown(event) { |
| 214 | if (event.key === 'Enter') { |
| 215 | if (!(event.target instanceof HTMLElement)) |
| 216 | return; |
| 217 | |
| 218 | // Only count enter on this interactable if no modifier key is pressed |
| 219 | if (event.altKey || event.ctrlKey || event.shiftKey) |
| 220 | return; |
| 221 | |
| 222 | // Traverse up the DOM tree to find the actual interactable element |
| 223 | let target = event.target; |
| 224 | while (target && !isKeyboardInteractable(target)) { |
| 225 | target = target.parentElement; |
| 226 | } |
| 227 | |
| 228 | // Trigger click if a valid interactable is found and it's not disabled |
| 229 | if (target && !target.classList.contains(DISABLED_CONTROL_CLASS)) { |
| 230 | console.debug('Triggering click on keyboard-focused interactable control via Enter', target); |
| 231 | target.click(); |
| 232 | } |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | /** |
| 237 | * Initializes several keyboard functionalities for ST |
| 238 | */ |
| 239 | export function initKeyboard() { |
| 240 | // Start observing the body for added elements and attribute changes |
| 241 | observer.observe(document.body, { |
| 242 | childList: true, |
| 243 | subtree: true, |
| 244 | attributes: true, |
| 245 | attributeFilter: ['class'], |
| 246 | }); |
| 247 | |
| 248 | // Initialize already existing controls |
| 249 | initializeInteractables(); |
| 250 | initializeScrollResetBehaviors(); |
| 251 | |
| 252 | // Add a global keydown listener |
| 253 | document.addEventListener('keydown', handleGlobalKeyDown); |
| 254 | } |