🔍 Add functionality for gamemaster search in composable

This commit is contained in:
2026-01-29 03:18:13 +00:00
parent 7e3e2191fa
commit 777bcae010

View File

@@ -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<string>} fileLines - Full file content split into lines
* @param {Ref<Array>} 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, '<mark>$1</mark>');
}
/**
* 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
};
}