Blame Raw
Cohee · e3f41666 · · 254 lines (10.9 KB)
2 contributors
1/* All selectors that should act as interactables / keyboard buttons by default */
2const 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
31if (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
36export const INTERACTABLE_CONTROL_CLASS = 'interactable';
37export const CUSTOM_INTERACTABLE_CONTROL_CLASS = 'custom_interactable';
38
39export const NOT_FOCUSABLE_CONTROL_CLASS = 'not_focusable';
40export 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 */
46const 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 */
64function 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 */
89export 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 */
110export 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 */
121export 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 */
166function 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 */
177function 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 */
186const 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 */
203function 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 */
213function 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 */
239export 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}