🔍 Add functionality for gamemaster search in composable
This commit is contained in:
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user