Spaces:
Running
Running
File size: 10,491 Bytes
b82d373 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 |
/* 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);
}
|