mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-10-27 08:04:08 +00:00
Add '/' shortcut to focus search box
This commit is contained in:
146
src/menu/use_search_shortcut.ts
Normal file
146
src/menu/use_search_shortcut.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import {useEffect} from 'react';
|
||||
|
||||
const registeredSearchInputs = new Set<HTMLInputElement>();
|
||||
|
||||
/** Registers a search input DOM element to receive global '/' shortcut focus. */
|
||||
export function registerSearchInput(input: HTMLInputElement) {
|
||||
registeredSearchInputs.add(input);
|
||||
}
|
||||
|
||||
/** Unregisters a search input DOM element. */
|
||||
export function unregisterSearchInput(input: HTMLInputElement) {
|
||||
registeredSearchInputs.delete(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an element is visible without short-circuiting dead code
|
||||
* or failing on position: fixed elements.
|
||||
*/
|
||||
function isElementVisible(el: HTMLElement): boolean {
|
||||
if (typeof el.checkVisibility === 'function') {
|
||||
return el.checkVisibility();
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is currently typing inside a text-editable element or form control.
|
||||
* Traverses all nodes in the composed path (supporting Shadow DOM) to ensure
|
||||
* nested child elements inside rich text editors or ARIA textfields do not
|
||||
* trigger false negatives. Protects all inputs, textareas, selects, and buttons.
|
||||
*/
|
||||
function isTextEditable(event: KeyboardEvent): boolean {
|
||||
const path =
|
||||
typeof event.composedPath === 'function'
|
||||
? event.composedPath()
|
||||
: [event.target];
|
||||
|
||||
for (const node of path) {
|
||||
if (!node || !(node instanceof Element)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node instanceof HTMLElement && node.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const tagName = node.tagName.toUpperCase();
|
||||
if (
|
||||
tagName === 'INPUT' ||
|
||||
tagName === 'TEXTAREA' ||
|
||||
tagName === 'SELECT' ||
|
||||
tagName === 'BUTTON'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const role = node.getAttribute('role');
|
||||
if (
|
||||
role === 'textbox' ||
|
||||
role === 'searchbox' ||
|
||||
role === 'spinbutton' ||
|
||||
role === 'combobox'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any open modal dialog is visible in the main document or Shadow DOM.
|
||||
*/
|
||||
function isModalActive(event: KeyboardEvent): boolean {
|
||||
const roots = new Set<Document | ShadowRoot>([document]);
|
||||
const targetRoot = (event.target as Element | null)?.getRootNode();
|
||||
if (
|
||||
targetRoot &&
|
||||
(targetRoot instanceof Document || targetRoot instanceof ShadowRoot)
|
||||
) {
|
||||
roots.add(targetRoot);
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
const modals = root.querySelectorAll(
|
||||
'dialog[open], .ui.modal.visible.active',
|
||||
);
|
||||
|
||||
for (const modal of Array.from(modals)) {
|
||||
if (!(modal instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
if (modal.getAttribute('aria-hidden') === 'true') {
|
||||
continue;
|
||||
}
|
||||
if (isElementVisible(modal)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that registers a centralized global '/' keyboard shortcut to focus the visible search input.
|
||||
*/
|
||||
export function useSearchShortcut() {
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key !== '/') {
|
||||
return;
|
||||
}
|
||||
if (event.repeat || event.isComposing || event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
// Block metaKey (Cmd). Block Ctrl or Alt unless both are pressed (AltGr).
|
||||
if (
|
||||
event.metaKey ||
|
||||
(event.ctrlKey && !event.altKey) ||
|
||||
(!event.ctrlKey && event.altKey)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isModalActive(event) || isTextEditable(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const input of registeredSearchInputs) {
|
||||
if (isElementVisible(input)) {
|
||||
event.preventDefault();
|
||||
input.focus({preventScroll: true});
|
||||
input.select();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
Reference in New Issue
Block a user