🔄 Refactor GamemasterExplorer.vue to modularize functionality using composables and simplify template structure

This commit is contained in:
2026-01-29 04:04:16 +00:00
parent cac222a39b
commit f2e1725156

View File

@@ -54,529 +54,105 @@
<ul> <ul>
<li><kbd>Ctrl</kbd>+<kbd>F</kbd> - Focus search</li> <li><kbd>Ctrl</kbd>+<kbd>F</kbd> - Focus search</li>
<li><kbd>Ctrl</kbd>+<kbd>C</kbd> - Copy selected lines</li> <li><kbd>Ctrl</kbd>+<kbd>C</kbd> - Copy selected lines</li>
<li><kbd>Ctrl</kbd>+<kbd>G</kbd> - Go to next search result</li> </template>
<li>
<kbd>Shift</kbd>+<kbd>Ctrl</kbd>+<kbd>G</kbd> - Go to previous
result
</li>
<li><kbd>Escape</kbd> - Clear selection / Close dialogs</li>
</ul>
</div>
<!-- Settings Panel --> <script setup>
<div v-if="showSettings" class="settings-panel"> import { ref, computed, nextTick, watch } from 'vue';
<h3>Settings</h3> import { GamemasterClient } from '@/utilities/gamemaster-client.js';
<label> import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts.js';
<input type="checkbox" v-model="preferences.lineWrap" /> import { useUrlState } from '@/composables/useUrlState.js';
Line Wrap import { useClipboard } from '@/composables/useClipboard.js';
</label> import { useGamemasterFiles } from '@/composables/useGamemasterFiles.js';
<label> import { useGamemasterSearch } from '@/composables/useGamemasterSearch.js';
<input type="checkbox" v-model="preferences.darkMode" /> import { useLineSelection } from '@/composables/useLineSelection.js';
Dark Mode import useJsonFilter from '@/composables/useJsonFilter.js';
</label> import SearchBar from '@/components/gamemaster/SearchBar.vue';
<label> import FileSelector from '@/components/gamemaster/FileSelector.vue';
<input type="checkbox" v-model="preferences.showLineNumbers" /> import JsonViewer from '@/components/gamemaster/JsonViewer.vue';
Show Line Numbers import FilterPanel from '@/components/gamemaster/FilterPanel.vue';
</label> import ActionToolbar from '@/components/gamemaster/ActionToolbar.vue';
<label>
Performance:
<select v-model="preferences.performanceMode">
<option value="auto">Auto</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</label>
</div>
<!-- File Selector --> const client = new GamemasterClient();
<FileSelector :files-state="filesState" /> const filesState = useGamemasterFiles(client);
const {
selectedFile,
fileContent,
fileLines,
displayLines,
isLoading,
fileError,
preferences,
hasFiles,
fileTooLarge
} = filesState;
<SearchBar const searchState = useGamemasterSearch(fileLines, displayLines);
v-if="fileContent" const selectionState = useLineSelection(displayLines, fileContent, selectedFile);
:file-lines="fileLines" const filterState = useJsonFilter();
:display-lines="displayLines"
:search-state="searchState"
/>
<!-- Property Filter --> const showHelp = ref(false);
<FilterPanel const showSettings = ref(false);
v-if="fileContent" const operationProgress = ref({
:data="filterData"
:filter-state="filterState"
/>
<!-- Progress Bar (for long operations) -->
<div v-if="operationProgress.active" class="progress-bar-container">
<div
class="progress-bar"
:class="{ complete: operationProgress.complete }"
>
<div
class="progress-fill"
:style="{ width: operationProgress.percent + '%' }"
></div>
</div>
<span class="progress-text">{{ operationProgress.message }}</span>
</div>
<!-- JSON Content Viewer -->
<JsonViewer
v-if="fileContent"
:display-lines="displayLines"
:file-content="fileContent"
:selected-file="selectedFile"
:file-too-large="fileTooLarge"
:preferences="preferences"
:search-results="searchResults"
:current-result-index="currentResultIndex"
:highlight-config="highlightConfig"
:line-height="lineHeight"
:selection-state="selectionState"
/>
<!-- Action Bar -->
<ActionToolbar
v-if="fileContent"
:display-lines="displayLines"
:file-content="fileContent"
:selected-file="selectedFile"
:selection-state="selectionState"
/>
<!-- Toast Notifications -->
<div v-if="clipboard.copied.value" class="toast success">
Copied to clipboard!
</div>
<div v-if="clipboard.error.value" class="toast error">
{{ clipboard.error.value }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick, watch } from 'vue';
import { RecycleScroller } from 'vue-virtual-scroller';
import { GamemasterClient } from '@/utilities/gamemaster-client.js';
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts.js';
import { useUrlState } from '@/composables/useUrlState.js';
import { useClipboard } from '@/composables/useClipboard.js';
import {
useLocalStorage,
useSearchHistory
} from '@/composables/useLocalStorage.js';
import {
perfMonitor,
debounce,
getDevicePerformance
} from '@/utilities/performance-utils.js';
import {
extractJsonPaths,
extractJsonPathsLazy
} from '@/utilities/json-utils.js';
// Core state
const loading = ref(true);
const error = ref(null);
const status = ref({});
const selectedFile = ref('');
const fileContent = ref('');
const fileLines = ref([]);
const displayLines = ref([]);
const virtualScroller = ref(null); // Ref for the virtual scroller
// UI state
const showHelp = ref(false);
const showSettings = ref(false);
const searchInput = ref(null);
// Search state
const searchQuery = ref('');
const searchResults = ref([]);
const currentResultIndex = ref(0);
const searchHistory = useSearchHistory();
// Filter state
const filterProperty = ref('');
const filterValue = ref('');
const filterMode = ref('OR');
const jsonPaths = ref([]);
// Selection state
const selectedLines = ref(new Set());
// Progress state
const operationProgress = ref({
active: false, active: false,
percent: 0, percent: 0,
message: '', message: '',
complete: false complete: false
});
// Preferences (persisted)
const preferences = useLocalStorage('gamemaster-explorer-prefs', {
darkMode: false,
lineWrap: false,
showLineNumbers: true,
performanceMode: 'auto',
lastFile: ''
});
// Composables
const clipboard = useClipboard();
const client = new GamemasterClient();
// Web Worker for search operations
let searchWorker = null;
let searchWorkerRequestId = 0;
let searchWorkerInitPromise = null;
const initSearchWorker = () => {
if (!searchWorkerInitPromise) {
searchWorkerInitPromise = import('../workers/search.worker.js?worker')
.then(module => {
searchWorker = new module.default();
console.log('✅ Worker created successfully');
searchWorker.onmessage = handleSearchWorkerMessage;
searchWorker.onerror = error => {
console.error('❌ Worker error:', error.message);
operationProgress.value.active = false;
operationProgress.value.message = 'Search error: ' + error.message;
};
console.log('✅ Worker event handlers attached');
return searchWorker;
})
.catch(error => {
console.error('❌ Failed to import worker:', error);
searchWorker = null;
searchWorkerInitPromise = null; // Reset so we can retry
throw error;
});
}
return searchWorkerInitPromise;
};
const handleSearchWorkerMessage = event => {
const { type, id, results, percent, error: workerError } = event.data;
console.log('📨 Worker message received:', type, {
percent,
resultCount: results?.length
}); });
if (type === 'progress') { const loading = computed(() => isLoading.value);
operationProgress.value.percent = percent; const error = computed(() => fileError.value);
operationProgress.value.message = `Searching... ${Math.round(percent)}%`;
console.log('📊 Progress update:', percent + '%');
} else if (type === 'complete') {
console.log('✅ Search completed:', results.length, 'results found');
searchResults.value = results;
currentResultIndex.value = 0;
// Update displayLines with match highlighting const lineHeight = computed(() => (globalThis.innerWidth < 768 ? 24 : 20));
displayLines.value.forEach(line => (line.hasMatch = false)); const highlightConfig = computed(() => ({
results.forEach(lineIndex => {
if (displayLines.value[lineIndex]) {
displayLines.value[lineIndex].hasMatch = true;
}
});
operationProgress.value.complete = true;
setTimeout(() => {
operationProgress.value.active = false;
}, 500);
// Scroll to first result if found
if (results.length > 0) {
nextTick(() => scrollToResult());
}
} else if (type === 'error') {
console.error('❌ Search worker error:', workerError);
operationProgress.value.active = false;
}
};
// Composables
const hasFiles = computed(() => status.value.totalFiles > 0);
const fileTooLarge = computed(() => fileLines.value.length > 10000);
const lineHeight = computed(() => {
// Mobile-first: 24px on mobile, 20px on desktop
return globalThis.innerWidth < 768 ? 24 : 20;
});
const highlightConfig = computed(() => ({
theme: preferences.value.darkMode ? 'github-dark' : 'github', theme: preferences.value.darkMode ? 'github-dark' : 'github',
language: 'json' language: 'json'
})); }));
// Get unique files by type (only show one of each file type, prefer largest) const filterData = computed(() => {
const uniqueFiles = computed(() => { if (!fileContent.value) return [];
const fileMap = new Map(); try {
const parsed = JSON.parse(fileContent.value);
status.value.available?.forEach(file => { return Array.isArray(parsed) ? parsed : [];
const fileType = getFileType(file.filename); } catch {
const existing = fileMap.get(fileType); return [];
// Keep the largest file of each type
if (!existing || file.size > existing.size) {
fileMap.set(fileType, file);
} }
}); });
// Return files in consistent order useUrlState({
return Array.from(fileMap.values()).sort((a, b) => {
const order = { pokemon: 0, allForms: 1, moves: 2, raw: 3 };
const typeA = getFileType(a.filename);
const typeB = getFileType(b.filename);
return (order[typeA] ?? 999) - (order[typeB] ?? 999);
});
});
// Helper to get highlighted content for search results
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>');
}
// URL state sync
const urlStateRefs = {
file: selectedFile, file: selectedFile,
search: searchQuery, search: searchState.searchQuery,
filter: filterProperty, filter: filterState.filterProperty,
value: filterValue value: filterState.filterValue
}; });
useUrlState(urlStateRefs);
// Keyboard shortcuts const clipboard = useClipboard();
useKeyboardShortcuts({
useKeyboardShortcuts({
'ctrl+f': () => { 'ctrl+f': () => {
showHelp.value = false; showHelp.value = false;
showSettings.value = false; showSettings.value = false;
nextTick(() => searchInput.value?.focus());
}, },
'ctrl+c': copySelected, 'ctrl+c': selectionState.copySelected,
'ctrl+g': goToNextResult, 'ctrl+g': searchState.goToNextResult,
'shift+ctrl+g': goToPrevResult, 'shift+ctrl+g': searchState.goToPrevResult,
escape: () => { escape: () => {
if (showHelp.value || showSettings.value) { if (showHelp.value || showSettings.value) {
showHelp.value = false; showHelp.value = false;
showSettings.value = false; showSettings.value = false;
} else if (selectedLines.value.size > 0) { } else if (selectionState.selectedLines.value.size > 0) {
selectedLines.value.clear(); selectionState.selectedLines.value.clear();
} else if (searchQuery.value) { } else if (searchState.searchQuery.value) {
clearSearch(); searchState.clearSearch();
} }
} }
});
// Watch selectedFile changes to load the file
watch(selectedFile, newFile => {
if (newFile) {
loadFile();
}
});
// Methods
async function loadStatus() {
try {
loading.value = true;
error.value = null;
status.value = await perfMonitor('Load Status', async () => {
return await client.getStatus();
}); });
// Auto-load last file if set watch(selectedFile, () => {
if (preferences.value.lastFile && !selectedFile.value) { if (selectionState.selectedLines.value.size > 0) {
selectedFile.value = preferences.value.lastFile; selectionState.clearSelection();
await loadFile();
} }
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
}
async function loadFile() {
if (!selectedFile.value) return;
try {
loading.value = true;
error.value = null;
// Check if raw file is too large (> 50MB)
if (selectedFile.value === 'raw') {
const rawFile = status.value.available?.find(f =>
f.filename.includes('raw')
);
if (rawFile && rawFile.size > 50 * 1024 * 1024) {
error.value =
'⚠️ Raw gamemaster file is very large (' +
formatSize(rawFile.size) +
'). It may be slow to load. Try a specific file type instead (Pokemon, All Forms, or Moves).';
loading.value = false;
return;
}
}
let data;
switch (selectedFile.value) {
case 'pokemon':
data = await perfMonitor('Load Pokemon', () => client.getPokemon());
break;
case 'allForms':
data = await perfMonitor('Load All Forms', () => client.getAllForms());
break;
case 'moves':
data = await perfMonitor('Load Moves', () => client.getMoves());
break;
case 'raw':
data = await perfMonitor('Load Raw', () => client.getRaw());
break;
default:
throw new Error('Unknown file type');
}
fileContent.value = JSON.stringify(data, null, 2);
fileLines.value = fileContent.value.split('\n');
// Display limited lines initially (10K) but keep full content for searching
const linesToDisplay = fileLines.value.slice(0, 10000);
displayLines.value = linesToDisplay.map((content, index) => ({
lineNumber: index + 1,
content,
hasMatch: false
}));
// Extract JSON paths for filtering (in background)
jsonPaths.value = extractJsonPaths(data);
extractJsonPathsLazy(data, paths => {
jsonPaths.value = paths;
}); });
</script>
// Save last file preference
preferences.value.lastFile = selectedFile.value;
loading.value = false;
} catch (err) {
error.value = err.message;
loading.value = false;
}
}
function onFileChange() {
// Clear state when file selection changes
searchQuery.value = '';
searchResults.value = [];
currentResultIndex.value = 0;
filterProperty.value = '';
filterValue.value = '';
selectedLines.value.clear();
jsonPaths.value = [];
// File loading is handled by the watch() on selectedFile
}
const onSearchInput = debounce(async () => {
if (!searchQuery.value.trim()) {
searchResults.value = [];
displayLines.value.forEach(line => (line.hasMatch = false));
return;
}
// Initialize worker if needed (wait for it to load)
try {
await initSearchWorker();
} catch (error) {
console.error('❌ Failed to initialize worker:', error);
operationProgress.value.active = false;
operationProgress.value.message = 'Worker initialization failed';
return;
}
if (!searchWorker) {
console.error('❌ Worker not available');
operationProgress.value.active = false;
operationProgress.value.message = 'Search worker not available';
return;
}
// Show progress for long searches
const searchTerm = searchQuery.value.toLowerCase();
console.log('🔍 Starting worker search for:', searchTerm);
operationProgress.value = {
active: true,
percent: 0,
message: 'Searching...',
complete: false
};
// Offload search to worker to avoid blocking UI
searchWorkerRequestId++;
const requestId = searchWorkerRequestId;
console.log('📤 Posting message to worker:', {
linesCount: fileLines.value.length,
searchTerm,
requestId
});
// Convert reactive array to plain array (Web Workers can't clone Vue proxies)
const plainLines = Array.from(fileLines.value);
searchWorker.postMessage({
lines: plainLines,
searchTerm: searchTerm,
id: requestId
});
// Store current request ID to ignore stale results
searchWorkerRequestId = requestId;
// Add to search history
searchHistory.addToHistory(searchQuery.value);
}, 300);
function clearSearch() {
searchQuery.value = '';
searchResults.value = [];
displayLines.value.forEach(line => (line.hasMatch = false));
}
function goToNextResult() {
if (searchResults.value.length === 0) return;
currentResultIndex.value =
(currentResultIndex.value + 1) % searchResults.value.length;
scrollToResult();
}
function goToPrevResult() {
if (searchResults.value.length === 0) return;
currentResultIndex.value =
(currentResultIndex.value - 1 + searchResults.value.length) %
searchResults.value.length;
scrollToResult();
}
function scrollToResult() {
const lineIndex = searchResults.value[currentResultIndex.value];
if (lineIndex === undefined) return;
const lineNumber = lineIndex + 1; // Convert to 1-based line number
// If result is beyond currently displayed lines, load more lines
if (
lineIndex >= displayLines.value.length &&
lineIndex < fileLines.value.length
) {
// Expand displayLines to include the result
const newLinesToDisplay = fileLines.value.slice(
0, 0,
Math.min(lineIndex + 1000, fileLines.value.length) Math.min(lineIndex + 1000, fileLines.value.length)
); );