b04c974407
* feat(ui): add popup to jump to a specific swipe Adds a new "Jump to swipe history" button to message actions and makes the swipe counter clickable on the latest message. This opens a searchable popup allowing users to quickly find and jump to a specific alternate swipe without having to click through them sequentially. - Adds a searchable swipe picker popup menu - Makes the swipe counter interactive when multiple swipes exist - Adds a dedicated swipe picker button to message controls * fix(ui): hide swipe picker when there are no swipes Ensures the newly added swipe picker button is only shown when a message has multiple swipes available. It explicitly hides the button for non-swipeable messages or messages with a single swipe. * feat(ui): redesign swipe picker with direct id input Replace text-based search with a numeric input for direct swipe navigation. Update popup layout with a sticky header and improved scrolling behavior. Sync input value with the currently selected swipe in the list. Refactor styling to align with chat selection components. * feat(ui): allow branching from specific swipes via picker - Enable swipe picker for historical messages to inspect alternate swipes - Add branch button to picker entries to create new chats from specific swipes - Update saveChat and createBranch to accept chat snapshots - Restrict swipe jumping to the active message only * refactor(logic): consolidate swipe sync logic and simplify helpers Update `syncSwipeToMes` to accept a target message object, enabling its use in the bookmarks module and removing the duplicate `applySwipeToSnapshot` function. Also simplify `canOpenSwipePickerForMessage` and `canJumpToSwipeForMessage` signatures by removing the redundant message parameter. * refactor(a11y): support dynamic roles via classes Introduce a managed role system in the accessibility script to handle elements that dynamically gain or lose interactive states. The mutation observer now watches for class attribute changes and automatically applies or clears roles (e.g., `role="button"`) using active selectors. Updated the swipe counter to rely on this centralized system by toggling an `.interactable` class instead of manually modifying tabindex and role attributes. Removed the redundant 'Enter' keydown handler for the swipe counter to prevent duplicate trigger events. * fix(ui): compute missing token counts in swipe picker Update renderSwipeList to asynchronously calculate token counts when missing from swipe metadata. Introduce SWIPE_SOURCE.SWIPE_PICKER to correctly identify swipes triggered from the picker and bypass generation checks. * feat(ui): enable deleting specific swipes via swipe picker - Adds a delete button to swipe picker entries, allowing removal of specific message versions. - Refactors deletion logic to handle removing non-current swipes without triggering animations and correctly updates indices. - Includes confirmation dialogs and improves input focus behavior. * refactor:Delete process inline to button click processor * feat: universal swipe inspection and picker improvements - Permit opening the swipe browser on any chat entry to review past generations. - Parallelize the retrieval of token statistics to speed up list rendering. - Format message metrics (length and tokens) into a single, concise string. - Update the `getBranchChatSnapshot` API to accept an options object. - Register swipe list items as interactable elements for keyboard control. - Apply styling to prevent text highlighting on picker entries. * fix:remove unused CSS * fix: fix disabled styling for swipe delete button Remove tooltips and prevent hover animations or glow effects when the delete button is disabled in the swipe picker. Update CSS to enforce default cursor and fixed opacity on hover for the disabled state. * remove: Unused CSS * Extract swipe-picker.js module * Revert to manual ARIA role management * Avoid scrollIntoView and scroll on open * Fix keyboard interaction in past chats menu * Fix a11y attribute * fix: call refreshSwipeButtons when deleting not selected swipe --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
255 lines
11 KiB
JavaScript
255 lines
11 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
|
|
'.swipe_picker_block', // Swipe picker entries in the swipe history popup
|
|
'.avatar-container', // Persona list blocks
|
|
'.tag .tag_remove', // Remove button in removable tags
|
|
'.bg_example', // Background elements in the background menu
|
|
'.bg_example .jg-button, .bg_example .mobile-only-menu-toggle', // 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
|
|
'.bg_tabs_list .bg_tab_button', // Background tabs
|
|
'.select_chat_block', // The blocks to select a past chat in the past chats menu
|
|
'.select_chat_block .exportRawChatButton', // Export raw chat button in the past chats menu
|
|
'.select_chat_block .exportChatButton', // Export chat button in the past chats menu
|
|
'.select_chat_block .PastChat_cross', // Delete chat button in the past chats menu
|
|
'.select_chat_block .renameChatButton', // The button to rename a past chat in the past chats menu
|
|
];
|
|
|
|
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);
|
|
}
|