mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-18 01:31:47 +00:00
Add '/' shortcut to focus search box
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
import debounce from 'debounce';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import React, {useEffect, useRef} from 'react';
|
||||
import {IntlShape, useIntl} from 'react-intl';
|
||||
import {Search, SearchResultProps} from 'semantic-ui-react';
|
||||
import {IndiInfo, JsonGedcomData, JsonIndi} from 'topola';
|
||||
import {analyticsEvent} from '../util/analytics';
|
||||
import {JsonIndi} from 'topola';
|
||||
import {formatDateOrRange} from '../util/date_util';
|
||||
import {buildSearchIndex, SearchIndex, SearchResult} from './search_index';
|
||||
import {SearchResult} from './search_index';
|
||||
import {
|
||||
registerSearchInput,
|
||||
unregisterSearchInput,
|
||||
} from './use_search_shortcut';
|
||||
|
||||
const SHORTCUT_INPUT_PROP = {
|
||||
'aria-keyshortcuts': '/',
|
||||
};
|
||||
|
||||
function getNameLine(result: SearchResult) {
|
||||
const name = [result.indi.firstName, result.indi.lastName].join(' ').trim();
|
||||
@@ -19,103 +25,81 @@ function getNameLine(result: SearchResult) {
|
||||
);
|
||||
}
|
||||
|
||||
function getDescriptionLine(indi: JsonIndi, currentIntl: IntlShape) {
|
||||
const birthDate = formatDateOrRange(indi.birth, currentIntl);
|
||||
const deathDate = formatDateOrRange(indi.death, currentIntl);
|
||||
if (!deathDate) {
|
||||
return birthDate;
|
||||
}
|
||||
return `${birthDate} – ${deathDate}`;
|
||||
}
|
||||
|
||||
function displaySearchResult(
|
||||
result: SearchResult,
|
||||
currentIntl: IntlShape,
|
||||
): SearchResultProps {
|
||||
return {
|
||||
id: result.id,
|
||||
key: result.id,
|
||||
title: getNameLine(result),
|
||||
description: getDescriptionLine(result.indi, currentIntl),
|
||||
} as SearchResultProps;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Data used for the search index. */
|
||||
data: JsonGedcomData;
|
||||
onSelection: (indiInfo: IndiInfo) => void;
|
||||
results: SearchResult[];
|
||||
value: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onResultSelect: (id: string) => void;
|
||||
hideShortcutHint?: boolean;
|
||||
}
|
||||
|
||||
/** Displays and handles the search box in the top bar. */
|
||||
export function SearchBar(props: Props) {
|
||||
const [searchResults, setSearchResults] = useState<SearchResultProps[]>([]);
|
||||
const [searchString, setSearchString] = useState('');
|
||||
const searchIndex = useRef<SearchIndex | undefined>(undefined);
|
||||
export const SearchBar = React.memo(function SearchBar(props: Props) {
|
||||
const intl = useIntl();
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function getDescriptionLine(indi: JsonIndi) {
|
||||
const birthDate = formatDateOrRange(indi.birth, intl);
|
||||
const deathDate = formatDateOrRange(indi.death, intl);
|
||||
if (!deathDate) {
|
||||
return birthDate;
|
||||
}
|
||||
return `${birthDate} – ${deathDate}`;
|
||||
}
|
||||
|
||||
/** Produces an object that is displayed in the Semantic UI Search results. */
|
||||
function displaySearchResult(result: SearchResult): SearchResultProps {
|
||||
return {
|
||||
id: result.id,
|
||||
key: result.id,
|
||||
title: getNameLine(result),
|
||||
description: getDescriptionLine(result.indi),
|
||||
} as SearchResultProps;
|
||||
}
|
||||
|
||||
/** Returns the index, building it on first use to avoid blocking the initial render. */
|
||||
function getOrBuildIndex(): SearchIndex {
|
||||
if (!searchIndex.current) {
|
||||
searchIndex.current = buildSearchIndex(props.data);
|
||||
}
|
||||
return searchIndex.current;
|
||||
}
|
||||
|
||||
/** On search input change. */
|
||||
function handleSearch(input: string | undefined) {
|
||||
if (!input) {
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const results = getOrBuildIndex()
|
||||
.search(input)
|
||||
.map((result) => displaySearchResult(result));
|
||||
setSearchResults(results);
|
||||
}
|
||||
const debouncedHandleSearch = useRef(debounce(handleSearch, 200));
|
||||
|
||||
/** On search result selected. */
|
||||
function handleResultSelect(id: string) {
|
||||
analyticsEvent('search_result_selected');
|
||||
props.onSelection({id, generation: 0});
|
||||
setSearchString('');
|
||||
}
|
||||
|
||||
/** On search string changed. */
|
||||
function onChange(value: string | undefined) {
|
||||
debouncedHandleSearch.current(value);
|
||||
setSearchString(value || '');
|
||||
}
|
||||
|
||||
// When data changes, reset the index and schedule a background rebuild so
|
||||
// the first keystroke doesn't block the UI. Falls back to a 200ms timeout
|
||||
// if requestIdleCallback is unavailable (e.g. Firefox 115, Safari 16).
|
||||
useEffect(() => {
|
||||
searchIndex.current = undefined;
|
||||
const build = () => {
|
||||
searchIndex.current = buildSearchIndex(props.data);
|
||||
};
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
const handle = requestIdleCallback(build, {timeout: 5000});
|
||||
return () => cancelIdleCallback(handle);
|
||||
const input = el.querySelector('input');
|
||||
if (input) {
|
||||
registerSearchInput(input);
|
||||
return () => unregisterSearchInput(input);
|
||||
}
|
||||
const handle = setTimeout(build, 200);
|
||||
return () => clearTimeout(handle);
|
||||
}, [props.data]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Search
|
||||
onSearchChange={(_, data) => onChange(data.value)}
|
||||
onResultSelect={(_, data) => handleResultSelect(data.result.id)}
|
||||
results={searchResults}
|
||||
noResultsMessage={intl.formatMessage({
|
||||
id: 'menu.search.no_results',
|
||||
defaultMessage: 'No results found',
|
||||
})}
|
||||
placeholder={intl.formatMessage({
|
||||
const placeholder = props.hideShortcutHint
|
||||
? intl.formatMessage({
|
||||
id: 'menu.search.placeholder',
|
||||
defaultMessage: 'Search for people',
|
||||
})}
|
||||
selectFirstResult={true}
|
||||
value={searchString}
|
||||
id="search"
|
||||
/>
|
||||
})
|
||||
: intl.formatMessage({
|
||||
id: 'menu.search.placeholder_with_shortcut',
|
||||
defaultMessage: "Search for people (press '/')",
|
||||
});
|
||||
|
||||
const transformedResults = props.results.map((r) =>
|
||||
displaySearchResult(r, intl),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="item" ref={wrapperRef}>
|
||||
<Search
|
||||
onSearchChange={(_, data) => props.onSearchChange(data.value || '')}
|
||||
onResultSelect={(_, data) => props.onResultSelect(data.result.id)}
|
||||
results={transformedResults}
|
||||
noResultsMessage={intl.formatMessage({
|
||||
id: 'menu.search.no_results',
|
||||
defaultMessage: 'No results found',
|
||||
})}
|
||||
placeholder={placeholder}
|
||||
selectFirstResult={true}
|
||||
value={props.value}
|
||||
input={SHORTCUT_INPUT_PROP}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {MenuItem, MenuType} from './menu_item';
|
||||
import {SearchBar} from './search';
|
||||
import {UploadMenu} from './upload_menu';
|
||||
import {UrlMenu} from './url_menu';
|
||||
import {useSearch} from './use_search';
|
||||
import {WikiTreeLoginMenu, WikiTreeMenu} from './wikitree_menu';
|
||||
|
||||
enum ScreenSize {
|
||||
@@ -49,6 +50,12 @@ export function TopBar(props: Props) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const {searchResults, searchString, setSearchString, handleResultSelect} =
|
||||
useSearch({
|
||||
data: props.data,
|
||||
onSelection: props.eventHandlers?.onSelection,
|
||||
});
|
||||
|
||||
function changeView(view: string) {
|
||||
const search = queryString.parse(location.search);
|
||||
if (search.view !== view) {
|
||||
@@ -155,11 +162,10 @@ export function TopBar(props: Props) {
|
||||
<Dropdown.Menu>{chartTypeItems}</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
<SearchBar
|
||||
data={props.data}
|
||||
onSelection={
|
||||
props.eventHandlers?.onSelection || (() => undefined)
|
||||
}
|
||||
{...props}
|
||||
results={searchResults}
|
||||
value={searchString}
|
||||
onSearchChange={setSearchString}
|
||||
onResultSelect={handleResultSelect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -352,9 +358,11 @@ export function TopBar(props: Props) {
|
||||
</div>
|
||||
{props.showingChart && props.data && (
|
||||
<SearchBar
|
||||
data={props.data}
|
||||
onSelection={props.eventHandlers?.onSelection || (() => undefined)}
|
||||
{...props}
|
||||
results={searchResults}
|
||||
value={searchString}
|
||||
onSearchChange={setSearchString}
|
||||
onResultSelect={handleResultSelect}
|
||||
hideShortcutHint={true}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -386,7 +394,7 @@ export function TopBar(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Menu
|
||||
as={Media}
|
||||
greaterThanOrEqual="large"
|
||||
@@ -407,6 +415,6 @@ export function TopBar(props: Props) {
|
||||
>
|
||||
{mobileMenus()}
|
||||
</Menu>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
107
src/menu/use_search.tsx
Normal file
107
src/menu/use_search.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {IndiInfo, JsonGedcomData} from 'topola';
|
||||
import {analyticsEvent} from '../util/analytics';
|
||||
import {buildSearchIndex, SearchIndex, SearchResult} from './search_index';
|
||||
import {useSearchShortcut} from './use_search_shortcut';
|
||||
|
||||
/**
|
||||
* Schedules a task to run during browser idle time, falling back to setTimeout.
|
||||
* @returns A function that cancels the scheduled task.
|
||||
*/
|
||||
function scheduleIdleTask(callback: () => void, timeout = 5000): () => void {
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
const handle = requestIdleCallback(callback, {timeout});
|
||||
return () => cancelIdleCallback(handle);
|
||||
}
|
||||
const handle = setTimeout(callback, 200);
|
||||
return () => clearTimeout(handle);
|
||||
}
|
||||
|
||||
interface UseSearchProps {
|
||||
/** Data used for the search index. */
|
||||
data?: JsonGedcomData;
|
||||
/** Callback triggered when a search result is selected. */
|
||||
onSelection?: (indiInfo: IndiInfo) => void;
|
||||
}
|
||||
|
||||
interface UseSearchResult {
|
||||
/** The list of search results matching the current query. */
|
||||
searchResults: SearchResult[];
|
||||
/** The current search query string. */
|
||||
searchString: string;
|
||||
/** Sets the search query string. */
|
||||
setSearchString: (value: string) => void;
|
||||
/** Callback triggered when a search result is selected. */
|
||||
handleResultSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates search index building, debounced querying, result selection,
|
||||
* and global keyboard shortcut registration for the TopBar.
|
||||
* @returns An object containing search results, search string state, and callbacks.
|
||||
*/
|
||||
export function useSearch({
|
||||
data,
|
||||
onSelection,
|
||||
}: UseSearchProps): UseSearchResult {
|
||||
useSearchShortcut();
|
||||
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [searchString, setSearchString] = useState('');
|
||||
const searchIndex = useRef<SearchIndex | undefined>(undefined);
|
||||
const latestDataRef = useRef(data);
|
||||
latestDataRef.current = data;
|
||||
|
||||
// Build search index once for both desktop and mobile search bars
|
||||
useEffect(() => {
|
||||
searchIndex.current = undefined;
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const currentData = data;
|
||||
const build = () => {
|
||||
if (!searchIndex.current) {
|
||||
searchIndex.current = buildSearchIndex(currentData);
|
||||
}
|
||||
};
|
||||
return scheduleIdleTask(build);
|
||||
}, [data]);
|
||||
|
||||
// Debounced search effect
|
||||
useEffect(() => {
|
||||
if (!searchString) {
|
||||
setSearchResults((prev) => (prev.length === 0 ? prev : []));
|
||||
return;
|
||||
}
|
||||
const handle = setTimeout(() => {
|
||||
const currentData = latestDataRef.current;
|
||||
if (!currentData) {
|
||||
return;
|
||||
}
|
||||
if (!searchIndex.current) {
|
||||
searchIndex.current = buildSearchIndex(currentData);
|
||||
}
|
||||
const index = searchIndex.current;
|
||||
const results = index.search(searchString);
|
||||
setSearchResults(results);
|
||||
}, 200);
|
||||
return () => clearTimeout(handle);
|
||||
}, [searchString]);
|
||||
|
||||
const handleResultSelect = useCallback(
|
||||
(id: string) => {
|
||||
analyticsEvent('search_result_selected');
|
||||
onSelection?.({id, generation: 0});
|
||||
setSearchString('');
|
||||
setSearchResults([]);
|
||||
},
|
||||
[onSelection],
|
||||
);
|
||||
|
||||
return {
|
||||
searchResults,
|
||||
searchString,
|
||||
setSearchString,
|
||||
handleResultSelect,
|
||||
};
|
||||
}
|
||||
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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
"menu.wikitree_popup": "Вписани в WikiTree",
|
||||
"menu.github": "Проект в GitHub",
|
||||
"menu.search.placeholder": "Търсене на лице",
|
||||
"menu.search.placeholder_with_shortcut": "Търсене на лице (натиснете '/')",
|
||||
"menu.search.no_results": "Няма резултати",
|
||||
"intro.title": "Topola Genealogy",
|
||||
"intro.description": "Topola Genealogy е приложение за преглед на родословни дървета, която дава възможност за разглеждане структурата на семейство.",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"menu.wikitree_popup": "Přihlášeno do WikiTree",
|
||||
"menu.github": "GitHub projekt",
|
||||
"menu.search.placeholder": "Hledej osobu",
|
||||
"menu.search.placeholder_with_shortcut": "Hledej osobu (stiskněte '/')",
|
||||
"menu.search.no_results": "Žádné výsledky",
|
||||
"intro.title": "Topola Genealogy",
|
||||
"intro.description": "Topola Genealogy vám umožňuje interaktivní prohlížení rodokmenu.",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"menu.wikitree_popup": "Bei WikiTree angemeldet",
|
||||
"menu.github": "Projekt auf der GitHub-Website",
|
||||
"menu.search.placeholder": "Person suchen",
|
||||
"menu.search.placeholder_with_shortcut": "Person suchen ('/' drücken)",
|
||||
"menu.search.no_results": "Keine Ergebnisse",
|
||||
"intro.title": "Topola Genealogie",
|
||||
"intro.description": "Mit der Topola Genealogie können Sie den Stammbaum auf interaktive Weise durchsuchen.",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"menu.wikitree_popup": "Connecté à WikiTree",
|
||||
"menu.github": "Projet sur le site Web GitHub",
|
||||
"menu.search.placeholder": "Rechercher une personne",
|
||||
"menu.search.placeholder_with_shortcut": "Rechercher une personne (appuyez sur '/')",
|
||||
"menu.search.no_results": "Aucun résultat",
|
||||
"intro.title": "Topola Généalogie",
|
||||
"intro.description": "La Topola Généalogie vous permet de parcourir l'arbre généalogique de manière interactive.",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"menu.wikitree_popup": "Accesso a WikiTree",
|
||||
"menu.github": "Progetto sul sito web GitHub",
|
||||
"menu.search.placeholder": "Cerca persona",
|
||||
"menu.search.placeholder_with_shortcut": "Cerca persona (premi '/')",
|
||||
"menu.search.no_results": "Nessun risultato",
|
||||
"intro.title": "Topola Genealogy",
|
||||
"intro.description": "Topola Genealogy ti consente di esplorare l'albero genealogico in modo interattivo.",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"menu.wikitree_popup": "Zalogowano do WikiTree",
|
||||
"menu.github": "Projekt na stronie GitHub",
|
||||
"menu.search.placeholder": "Szukaj osoby",
|
||||
"menu.search.placeholder_with_shortcut": "Szukaj osoby (naciśnij '/')",
|
||||
"menu.search.no_results": "Brak wyników",
|
||||
"intro.title": "Topola Genealogy",
|
||||
"intro.description": "Topola Genealogy pozwala przeglądać drzewo genealogiczne w interaktywny sposób.",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"menu.wikitree_popup": "Вы вошли в WikiTree",
|
||||
"menu.github": "Проект на сайте GitHub",
|
||||
"menu.search.placeholder": "Искать человека",
|
||||
"menu.search.placeholder_with_shortcut": "Искать человека (нажмите '/')",
|
||||
"menu.search.no_results": "Нет результатов",
|
||||
"intro.title": "Topola Genealogy",
|
||||
"intro.description": "Topola Genealogy позволяет просматривать семейное древо в интерактивном режиме.",
|
||||
|
||||
Reference in New Issue
Block a user