3242897a3b
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.
155 lines
5.0 KiB
TypeScript
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,
|
|
};
|
|
}
|