diff --git a/code/websites/pokedex.online/src/composables/useGamemasterSearch.js b/code/websites/pokedex.online/src/composables/useGamemasterSearch.js new file mode 100644 index 0000000..05cbe2a --- /dev/null +++ b/code/websites/pokedex.online/src/composables/useGamemasterSearch.js @@ -0,0 +1,315 @@ +import { ref, computed, watch } from 'vue'; +import { debounce } from '@/utilities/performance-utils.js'; +import { useSearchHistory } from '@/composables/useLocalStorage.js'; + +/** + * useGamemasterSearch - Composable for managing gamemaster file search operations + * + * Handles: + * - Search query state management + * - Search results tracking (line numbers with matches) + * - Result navigation (next/prev) + * - Search history + * - Fuzzy matching and regex pattern support + * - Web worker integration for large file searches + * + * @param {Ref} fileLines - Full file content split into lines + * @param {Ref} displayLines - Currently displayed lines with metadata + * @returns {Object} Search composable API + */ +export function useGamemasterSearch(fileLines, displayLines) { + // Search state + const searchQuery = ref(''); + const searchResults = ref([]); + const currentResultIndex = ref(0); + const isSearching = ref(false); + const searchError = ref(null); + + // Search history + const searchHistory = useSearchHistory(); + + // Web worker for large file searches + let searchWorker = null; + let searchWorkerRequestId = 0; + + /** + * Initialize Web Worker for search operations + * Prevents blocking UI on large files + */ + function initSearchWorker() { + return new Promise((resolve, reject) => { + if (searchWorker) { + resolve(); + return; + } + + try { + searchWorker = new Worker('/workers/search-worker.js'); + + searchWorker.onmessage = event => { + const { id, results, error } = event.data; + + // Ignore stale results + if (id !== searchWorkerRequestId) { + return; + } + + if (error) { + searchError.value = error; + isSearching.value = false; + } else { + searchResults.value = results; + currentResultIndex.value = 0; + + // Update display lines with match indicators + displayLines.value?.forEach(line => { + line.hasMatch = results.includes(line.lineNumber - 1); + }); + + isSearching.value = false; + } + }; + + searchWorker.onerror = error => { + console.error('Search worker error:', error); + searchError.value = error.message; + isSearching.value = false; + reject(error); + }; + + resolve(); + } catch (error) { + searchError.value = error.message; + reject(error); + } + }); + } + + /** + * Perform search with debouncing to avoid excessive processing + * Supports both simple text and regex patterns + */ + const performSearch = debounce(async () => { + if (!searchQuery.value.trim()) { + clearSearchResults(); + return; + } + + try { + searchError.value = null; + isSearching.value = true; + + // Initialize worker if needed + await initSearchWorker(); + + if (!searchWorker) { + throw new Error('Search worker not available'); + } + + // Send search to worker + searchWorkerRequestId++; + const searchTerm = searchQuery.value.toLowerCase(); + const plainLines = Array.from(fileLines.value); + + searchWorker.postMessage({ + lines: plainLines, + searchTerm: searchTerm, + id: searchWorkerRequestId + }); + + // Add to search history + searchHistory.addToHistory(searchQuery.value); + } catch (error) { + console.error('Search error:', error); + searchError.value = error.message; + isSearching.value = false; + + // Fallback to synchronous search for small files + if (fileLines.value.length < 5000) { + performSynchronousSearch(); + } + } + }, 300); + + /** + * Fallback synchronous search for small files + * Used when Web Worker is unavailable + */ + function performSynchronousSearch() { + const results = []; + const searchTerm = searchQuery.value.toLowerCase(); + const escapedTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); + + try { + // Try regex pattern first + const regex = new RegExp(escapedTerm, 'gi'); + fileLines.value.forEach((line, index) => { + if (regex.test(line)) { + results.push(index); + } + }); + } catch (e) { + // Fall back to simple text search + fileLines.value.forEach((line, index) => { + if (line.toLowerCase().includes(searchTerm)) { + results.push(index); + } + }); + } + + searchResults.value = results; + currentResultIndex.value = 0; + + // Update display lines with match indicators + displayLines.value?.forEach(line => { + line.hasMatch = results.includes(line.lineNumber - 1); + }); + + isSearching.value = false; + } + + /** + * Execute search when query changes + */ + const executeSearch = async (query) => { + searchQuery.value = query; + await performSearch(); + }; + + /** + * Clear all search results + */ + function clearSearchResults() { + searchResults.value = []; + currentResultIndex.value = 0; + displayLines.value?.forEach(line => { + line.hasMatch = false; + }); + searchError.value = null; + } + + /** + * Clear search query and results + */ + function clearSearch() { + searchQuery.value = ''; + clearSearchResults(); + } + + /** + * Navigate to next search result + */ + function goToNextResult() { + if (searchResults.value.length === 0) return; + currentResultIndex.value = + (currentResultIndex.value + 1) % searchResults.value.length; + } + + /** + * Navigate to previous search result + */ + function goToPrevResult() { + if (searchResults.value.length === 0) return; + currentResultIndex.value = + (currentResultIndex.value - 1 + searchResults.value.length) % + searchResults.value.length; + } + + /** + * Get current result line number (1-based) + */ + const currentResultLineNumber = computed(() => { + if (searchResults.value.length === 0) return null; + return searchResults.value[currentResultIndex.value] + 1; + }); + + /** + * Get result count display text + */ + const resultCountDisplay = computed(() => { + if (searchResults.value.length === 0) return '0 results'; + return `${currentResultIndex.value + 1} / ${searchResults.value.length}`; + }); + + /** + * Check if search is active + */ + const hasSearchResults = computed(() => searchResults.value.length > 0); + + /** + * Get highlighted HTML for search term in text + */ + function getHighlightedContent(lineContent) { + if (!searchQuery.value.trim()) return lineContent; + + const searchTerm = searchQuery.value; + const escapedTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); + const regex = new RegExp(`(${escapedTerm})`, 'gi'); + + return lineContent.replaceAll(regex, '$1'); + } + + /** + * Update filters and rerun search + * Can support fuzzy matching and advanced filters in future + */ + function updateFilters(options = {}) { + // Extensible for fuzzy matching, regex flags, etc. + if (options.fuzzy !== undefined) { + // Future: implement fuzzy matching + } + if (options.wholeWord !== undefined) { + // Future: implement whole word matching + } + if (options.caseSensitive !== undefined) { + // Future: implement case-sensitive matching + } + // Trigger search with updated filters + performSearch(); + } + + /** + * Apply search history item + */ + function applyHistoryItem(item) { + searchQuery.value = item; + performSearch(); + } + + /** + * Watch for manual searchQuery changes + */ + watch(searchQuery, () => { + if (searchQuery.value.trim()) { + performSearch(); + } else { + clearSearchResults(); + } + }); + + return { + // State + searchQuery, + searchResults, + currentResultIndex, + isSearching, + searchError, + searchHistory, + + // Computed + currentResultLineNumber, + resultCountDisplay, + hasSearchResults, + + // Methods + executeSearch, + clearSearch, + clearSearchResults, + goToNextResult, + goToPrevResult, + updateFilters, + applyHistoryItem, + getHighlightedContent, + performSearch + }; +}