322 lines
7.9 KiB
JavaScript
322 lines
7.9 KiB
JavaScript
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
|
|
};
|
|
}
|