🔄 Refactor GamemasterExplorer.vue to modularize functionality using composables and simplify template structure
This commit is contained in:
@@ -54,529 +54,105 @@
|
||||
<ul>
|
||||
<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>G</kbd> - Go to next search result</li>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<!-- Settings Panel -->
|
||||
<div v-if="showSettings" class="settings-panel">
|
||||
<h3>Settings</h3>
|
||||
<label>
|
||||
<input type="checkbox" v-model="preferences.lineWrap" />
|
||||
Line Wrap
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="preferences.darkMode" />
|
||||
Dark Mode
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="preferences.showLineNumbers" />
|
||||
Show Line Numbers
|
||||
</label>
|
||||
<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>
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, watch } from 'vue';
|
||||
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 { useGamemasterFiles } from '@/composables/useGamemasterFiles.js';
|
||||
import { useGamemasterSearch } from '@/composables/useGamemasterSearch.js';
|
||||
import { useLineSelection } from '@/composables/useLineSelection.js';
|
||||
import useJsonFilter from '@/composables/useJsonFilter.js';
|
||||
import SearchBar from '@/components/gamemaster/SearchBar.vue';
|
||||
import FileSelector from '@/components/gamemaster/FileSelector.vue';
|
||||
import JsonViewer from '@/components/gamemaster/JsonViewer.vue';
|
||||
import FilterPanel from '@/components/gamemaster/FilterPanel.vue';
|
||||
import ActionToolbar from '@/components/gamemaster/ActionToolbar.vue';
|
||||
|
||||
<!-- File Selector -->
|
||||
<FileSelector :files-state="filesState" />
|
||||
const client = new GamemasterClient();
|
||||
const filesState = useGamemasterFiles(client);
|
||||
const {
|
||||
selectedFile,
|
||||
fileContent,
|
||||
fileLines,
|
||||
displayLines,
|
||||
isLoading,
|
||||
fileError,
|
||||
preferences,
|
||||
hasFiles,
|
||||
fileTooLarge
|
||||
} = filesState;
|
||||
|
||||
<SearchBar
|
||||
v-if="fileContent"
|
||||
:file-lines="fileLines"
|
||||
:display-lines="displayLines"
|
||||
:search-state="searchState"
|
||||
/>
|
||||
const searchState = useGamemasterSearch(fileLines, displayLines);
|
||||
const selectionState = useLineSelection(displayLines, fileContent, selectedFile);
|
||||
const filterState = useJsonFilter();
|
||||
|
||||
<!-- Property Filter -->
|
||||
<FilterPanel
|
||||
v-if="fileContent"
|
||||
: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({
|
||||
const showHelp = ref(false);
|
||||
const showSettings = ref(false);
|
||||
const operationProgress = ref({
|
||||
active: false,
|
||||
percent: 0,
|
||||
message: '',
|
||||
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') {
|
||||
operationProgress.value.percent = percent;
|
||||
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;
|
||||
const loading = computed(() => isLoading.value);
|
||||
const error = computed(() => fileError.value);
|
||||
|
||||
// Update displayLines with match highlighting
|
||||
displayLines.value.forEach(line => (line.hasMatch = false));
|
||||
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(() => ({
|
||||
const lineHeight = computed(() => (globalThis.innerWidth < 768 ? 24 : 20));
|
||||
const highlightConfig = computed(() => ({
|
||||
theme: preferences.value.darkMode ? 'github-dark' : 'github',
|
||||
language: 'json'
|
||||
}));
|
||||
}));
|
||||
|
||||
// Get unique files by type (only show one of each file type, prefer largest)
|
||||
const uniqueFiles = computed(() => {
|
||||
const fileMap = new Map();
|
||||
|
||||
status.value.available?.forEach(file => {
|
||||
const fileType = getFileType(file.filename);
|
||||
const existing = fileMap.get(fileType);
|
||||
|
||||
// Keep the largest file of each type
|
||||
if (!existing || file.size > existing.size) {
|
||||
fileMap.set(fileType, file);
|
||||
const filterData = computed(() => {
|
||||
if (!fileContent.value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(fileContent.value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Return files in consistent order
|
||||
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 = {
|
||||
useUrlState({
|
||||
file: selectedFile,
|
||||
search: searchQuery,
|
||||
filter: filterProperty,
|
||||
value: filterValue
|
||||
};
|
||||
useUrlState(urlStateRefs);
|
||||
search: searchState.searchQuery,
|
||||
filter: filterState.filterProperty,
|
||||
value: filterState.filterValue
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
const clipboard = useClipboard();
|
||||
|
||||
useKeyboardShortcuts({
|
||||
'ctrl+f': () => {
|
||||
showHelp.value = false;
|
||||
showSettings.value = false;
|
||||
nextTick(() => searchInput.value?.focus());
|
||||
},
|
||||
'ctrl+c': copySelected,
|
||||
'ctrl+g': goToNextResult,
|
||||
'shift+ctrl+g': goToPrevResult,
|
||||
'ctrl+c': selectionState.copySelected,
|
||||
'ctrl+g': searchState.goToNextResult,
|
||||
'shift+ctrl+g': searchState.goToPrevResult,
|
||||
escape: () => {
|
||||
if (showHelp.value || showSettings.value) {
|
||||
showHelp.value = false;
|
||||
showSettings.value = false;
|
||||
} else if (selectedLines.value.size > 0) {
|
||||
selectedLines.value.clear();
|
||||
} else if (searchQuery.value) {
|
||||
clearSearch();
|
||||
} else if (selectionState.selectedLines.value.size > 0) {
|
||||
selectionState.selectedLines.value.clear();
|
||||
} else if (searchState.searchQuery.value) {
|
||||
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
|
||||
if (preferences.value.lastFile && !selectedFile.value) {
|
||||
selectedFile.value = preferences.value.lastFile;
|
||||
await loadFile();
|
||||
watch(selectedFile, () => {
|
||||
if (selectionState.selectedLines.value.size > 0) {
|
||||
selectionState.clearSelection();
|
||||
}
|
||||
} 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;
|
||||
});
|
||||
|
||||
// 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(
|
||||
</script>
|
||||
0,
|
||||
Math.min(lineIndex + 1000, fileLines.value.length)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user