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 }; }