Files
phokus/src/components/toolbar/useToolbarSearch.ts
T
LyAhn 3242897a3b refactor(toolbar): modularize component
Break down the monolithic Toolbar component into smaller, highly cohesive modules to improve maintainability and separation of concerns.

- Extract distinct UI regions into dedicated components (`ToolbarTitle`, `ToolbarSearch`, `SortControl`, `ZoomControl`, `ToolbarFilters`, and `FilterPill`).
- Abstract complex search state, debouncing, event listeners, and autocomplete logic into a custom `useToolbarSearch` hook.
- Relocate search value formatting and sort configurations to dedicated utility files (`searchValue.ts` and `sortOptions.ts`).
- Simplify the main `Toolbar` component to act as a clean presentation orchestrator.
2026-07-04 19:23:02 +01:00

155 lines
5.0 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import {
ExploreTagEntry,
parseSearchValue,
SearchCommand,
useGalleryStore,
} from "../../store";
import { useDismissable } from "../menu";
import { commandPrefix, composeSearchValue } from "./searchValue";
export function useToolbarSearch() {
const search = useGalleryStore((state) => state.search);
const setSearch = useGalleryStore((state) => state.setSearch);
const clearSearch = useGalleryStore((state) => state.clearSearch);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const [searchCommand, setSearchCommand] = useState<SearchCommand | null>(null);
const [searchQuery, setSearchQuery] = useState(search);
const [searchPanelOpen, setSearchPanelOpen] = useState(false);
const [tagSuggestions, setTagSuggestions] = useState<ExploreTagEntry[]>([]);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const suggestDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchShellRef = useRef<HTMLDivElement>(null);
const userHasTyped = useRef(false);
const hasActiveSearch = search.trim().length > 0;
const parsedSearch = parseSearchValue(composeSearchValue(searchCommand, searchQuery));
const showTagSuggestions = searchCommand === "tag" && searchPanelOpen;
const showCommandHints = !searchCommand && searchPanelOpen;
useEffect(() => {
if (!userHasTyped.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setSearch(composeSearchValue(searchCommand, searchQuery));
}, 200);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [searchCommand, searchQuery, setSearch]);
useEffect(() => {
const parsed = parseSearchValue(search);
setSearchCommand(parsed.prefix && parsed.mode !== "filename" ? parsed.mode : null);
setSearchQuery(parsed.prefix ? parsed.query : search);
}, [search]);
useEffect(() => {
if (searchCommand !== "tag") {
setTagSuggestions([]);
return;
}
if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current);
suggestDebounceRef.current = setTimeout(async () => {
try {
const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", {
params: { query: searchQuery.trim(), folder_id: selectedFolderId ?? null, limit: 10 },
});
setTagSuggestions(Array.isArray(results) ? results : []);
} catch {
setTagSuggestions([]);
}
}, 120);
return () => {
if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current);
};
}, [searchCommand, searchQuery, selectedFolderId]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const activeElement = document.activeElement;
const searchFocused = activeElement === searchInputRef.current;
if (event.key === "Escape" && (searchFocused || hasActiveSearch)) {
event.preventDefault();
setSearchCommand(null);
setSearchQuery("");
clearSearch();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [clearSearch, hasActiveSearch]);
useDismissable(searchShellRef, () => setSearchPanelOpen(false));
const handleSearchChange = (nextValue: string) => {
userHasTyped.current = true;
if (!searchCommand) {
const parsed = parseSearchValue(nextValue);
if (parsed.prefix) {
setSearchCommand(parsed.mode === "filename" ? null : parsed.mode);
setSearchQuery(parsed.query);
return;
}
}
setSearchQuery(nextValue);
};
const handleSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Backspace" && searchQuery.length === 0 && searchCommand !== null) {
event.preventDefault();
setSearchCommand(null);
}
};
const clearSearchInput = () => {
setSearchCommand(null);
setSearchQuery("");
setTagSuggestions([]);
clearSearch();
};
const removeSearchCommand = () => {
setSearchCommand(null);
setTagSuggestions([]);
};
const chooseTagSuggestion = (tag: string) => {
userHasTyped.current = true;
setSearchQuery(tag);
setSearch(`/t ${tag}`);
setSearchPanelOpen(false);
searchInputRef.current?.blur();
};
const chooseCommandHint = (command: SearchCommand) => {
userHasTyped.current = true;
setSearchCommand(command);
searchInputRef.current?.focus();
};
return {
chooseCommandHint,
chooseTagSuggestion,
clearSearchInput,
commandPrefix,
handleSearchChange,
handleSearchKeyDown,
hasActiveSearch,
parsedSearch,
removeSearchCommand,
searchCommand,
searchInputRef,
searchPanelOpen,
searchQuery,
searchShellRef,
setSearchPanelOpen,
showCommandHints,
showTagSuggestions,
tagSuggestions,
};
}