mirror of
https://github.com/olehomelchenko/astrolabe-nvc.git
synced 2025-12-21 21:22:23 +00:00
chore: reorganize file structure
This commit is contained in:
778
src/js/app.js
Normal file
778
src/js/app.js
Normal file
@@ -0,0 +1,778 @@
|
||||
// Application initialization and event handlers
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Initialize user settings
|
||||
initSettings();
|
||||
|
||||
// Apply saved theme immediately on page load
|
||||
const theme = getSetting('ui.theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
|
||||
// Initialize snippet storage and render list
|
||||
initializeSnippetsStorage();
|
||||
|
||||
// Initialize sort controls
|
||||
initializeSortControls();
|
||||
|
||||
// Initialize search controls
|
||||
initializeSearchControls();
|
||||
|
||||
renderSnippetList();
|
||||
|
||||
// Update storage monitor
|
||||
updateStorageMonitor();
|
||||
|
||||
// Auto-select first snippet on page load (only if no hash in URL)
|
||||
if (!window.location.hash) {
|
||||
const firstSnippet = SnippetStorage.listSnippets()[0];
|
||||
if (firstSnippet) {
|
||||
selectSnippet(firstSnippet.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Load saved layout
|
||||
loadLayoutFromStorage();
|
||||
|
||||
// Initialize resize functionality
|
||||
initializeResize();
|
||||
|
||||
// Initialize keyboard shortcuts
|
||||
initializeKeyboardShortcuts();
|
||||
|
||||
// Initialize Monaco Editor
|
||||
require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.47.0/min/vs' } });
|
||||
require(['vs/editor/editor.main'], async function () {
|
||||
// Fetch Vega-Lite schema for validation
|
||||
let vegaLiteSchema;
|
||||
try {
|
||||
const response = await fetch('https://vega.github.io/schema/vega-lite/v5.json');
|
||||
vegaLiteSchema = await response.json();
|
||||
} catch (error) {
|
||||
vegaLiteSchema = null;
|
||||
}
|
||||
|
||||
// Configure JSON language with schema
|
||||
if (vegaLiteSchema) {
|
||||
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
|
||||
validate: true,
|
||||
schemas: [{
|
||||
uri: "https://vega.github.io/schema/vega-lite/v5.json",
|
||||
fileMatch: ["*"],
|
||||
schema: vegaLiteSchema
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
// Load Vega libraries before creating editor
|
||||
await loadVegaLibraries();
|
||||
|
||||
// Get user settings for editor configuration
|
||||
const editorSettings = getSetting('editor') || {
|
||||
fontSize: 12,
|
||||
theme: 'vs-light',
|
||||
minimap: false,
|
||||
wordWrap: 'on',
|
||||
lineNumbers: 'on',
|
||||
tabSize: 2
|
||||
};
|
||||
|
||||
// Create the editor
|
||||
editor = monaco.editor.create(document.getElementById('monaco-editor'), {
|
||||
value: JSON.stringify(sampleSpec, null, 2),
|
||||
language: 'json',
|
||||
theme: editorSettings.theme,
|
||||
fontSize: editorSettings.fontSize,
|
||||
minimap: { enabled: editorSettings.minimap },
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
wordWrap: editorSettings.wordWrap,
|
||||
lineNumbers: editorSettings.lineNumbers,
|
||||
tabSize: editorSettings.tabSize,
|
||||
formatOnPaste: true,
|
||||
formatOnType: true
|
||||
});
|
||||
|
||||
// Register custom keyboard shortcuts in Monaco
|
||||
registerMonacoKeyboardShortcuts();
|
||||
|
||||
// Add debounced auto-render on editor change
|
||||
editor.onDidChangeModelContent(() => {
|
||||
debouncedRender();
|
||||
debouncedAutoSave();
|
||||
});
|
||||
|
||||
// Initial render
|
||||
renderVisualization();
|
||||
|
||||
// Initialize auto-save functionality
|
||||
initializeAutoSave();
|
||||
|
||||
// Initialize URL state management AFTER editor is ready
|
||||
initializeURLStateManagement();
|
||||
});
|
||||
|
||||
// Toggle panel buttons
|
||||
document.querySelectorAll('.toggle-btn').forEach(button => {
|
||||
button.addEventListener('click', function () {
|
||||
const panelId = this.id.replace('toggle-', '');
|
||||
togglePanel(panelId);
|
||||
});
|
||||
});
|
||||
|
||||
// Header links
|
||||
const importLink = document.getElementById('import-link');
|
||||
const exportLink = document.getElementById('export-link');
|
||||
const helpLink = document.getElementById('help-link');
|
||||
const importFileInput = document.getElementById('import-file-input');
|
||||
|
||||
if (importLink && importFileInput) {
|
||||
importLink.addEventListener('click', function () {
|
||||
importFileInput.click();
|
||||
});
|
||||
|
||||
importFileInput.addEventListener('change', function () {
|
||||
importSnippets(this);
|
||||
});
|
||||
}
|
||||
|
||||
if (exportLink) {
|
||||
exportLink.addEventListener('click', function () {
|
||||
exportSnippets();
|
||||
});
|
||||
}
|
||||
|
||||
if (helpLink) {
|
||||
helpLink.addEventListener('click', function () {
|
||||
openHelpModal();
|
||||
});
|
||||
}
|
||||
|
||||
const donateLink = document.getElementById('donate-link');
|
||||
if (donateLink) {
|
||||
donateLink.addEventListener('click', function () {
|
||||
openDonateModal();
|
||||
});
|
||||
}
|
||||
|
||||
// Settings Modal
|
||||
const settingsLink = document.getElementById('settings-link');
|
||||
const settingsModal = document.getElementById('settings-modal');
|
||||
const settingsModalClose = document.getElementById('settings-modal-close');
|
||||
const settingsApplyBtn = document.getElementById('settings-apply-btn');
|
||||
const settingsResetBtn = document.getElementById('settings-reset-btn');
|
||||
const settingsCancelBtn = document.getElementById('settings-cancel-btn');
|
||||
|
||||
if (settingsLink) {
|
||||
settingsLink.addEventListener('click', function () {
|
||||
openSettingsModal();
|
||||
});
|
||||
}
|
||||
|
||||
if (settingsModalClose) {
|
||||
settingsModalClose.addEventListener('click', closeSettingsModal);
|
||||
}
|
||||
|
||||
if (settingsCancelBtn) {
|
||||
settingsCancelBtn.addEventListener('click', closeSettingsModal);
|
||||
}
|
||||
|
||||
if (settingsApplyBtn) {
|
||||
settingsApplyBtn.addEventListener('click', applySettings);
|
||||
}
|
||||
|
||||
if (settingsResetBtn) {
|
||||
settingsResetBtn.addEventListener('click', function() {
|
||||
if (confirm('Reset all settings to defaults? This cannot be undone.')) {
|
||||
resetSettings();
|
||||
loadSettingsIntoUI();
|
||||
Toast.show('Settings reset to defaults', 'success');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Close on overlay click
|
||||
if (settingsModal) {
|
||||
settingsModal.addEventListener('click', function (e) {
|
||||
if (e.target === settingsModal) {
|
||||
closeSettingsModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Settings UI interactions
|
||||
const fontSizeSlider = document.getElementById('setting-font-size');
|
||||
const fontSizeValue = document.getElementById('setting-font-size-value');
|
||||
if (fontSizeSlider && fontSizeValue) {
|
||||
fontSizeSlider.addEventListener('input', function() {
|
||||
fontSizeValue.textContent = this.value + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
const renderDebounceSlider = document.getElementById('setting-render-debounce');
|
||||
const renderDebounceValue = document.getElementById('setting-render-debounce-value');
|
||||
if (renderDebounceSlider && renderDebounceValue) {
|
||||
renderDebounceSlider.addEventListener('input', function() {
|
||||
renderDebounceValue.textContent = this.value + 'ms';
|
||||
});
|
||||
}
|
||||
|
||||
const dateFormatSelect = document.getElementById('setting-date-format');
|
||||
const customDateFormatItem = document.getElementById('custom-date-format-item');
|
||||
if (dateFormatSelect && customDateFormatItem) {
|
||||
dateFormatSelect.addEventListener('change', function() {
|
||||
if (this.value === 'custom') {
|
||||
customDateFormatItem.style.display = 'block';
|
||||
} else {
|
||||
customDateFormatItem.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Dataset Manager
|
||||
const datasetsLink = document.getElementById('datasets-link');
|
||||
const toggleDatasetsBtn = document.getElementById('toggle-datasets');
|
||||
const datasetModal = document.getElementById('dataset-modal');
|
||||
const datasetModalClose = document.getElementById('dataset-modal-close');
|
||||
const newDatasetBtn = document.getElementById('new-dataset-btn');
|
||||
const cancelDatasetBtn = document.getElementById('cancel-dataset-btn');
|
||||
const saveDatasetBtn = document.getElementById('save-dataset-btn');
|
||||
const deleteDatasetBtn = document.getElementById('delete-dataset-btn');
|
||||
const copyReferenceBtn = document.getElementById('copy-reference-btn');
|
||||
|
||||
// Open dataset manager
|
||||
if (datasetsLink) {
|
||||
datasetsLink.addEventListener('click', openDatasetManager);
|
||||
}
|
||||
if (toggleDatasetsBtn) {
|
||||
toggleDatasetsBtn.addEventListener('click', openDatasetManager);
|
||||
}
|
||||
|
||||
// Close dataset manager
|
||||
if (datasetModalClose) {
|
||||
datasetModalClose.addEventListener('click', closeDatasetManager);
|
||||
}
|
||||
|
||||
// Close on overlay click
|
||||
if (datasetModal) {
|
||||
datasetModal.addEventListener('click', function (e) {
|
||||
if (e.target === datasetModal) {
|
||||
closeDatasetManager();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// New dataset button
|
||||
if (newDatasetBtn) {
|
||||
newDatasetBtn.addEventListener('click', showNewDatasetForm);
|
||||
}
|
||||
|
||||
// Import dataset button and file input
|
||||
const importDatasetBtn = document.getElementById('import-dataset-btn');
|
||||
const importDatasetFile = document.getElementById('import-dataset-file');
|
||||
if (importDatasetBtn && importDatasetFile) {
|
||||
importDatasetBtn.addEventListener('click', function () {
|
||||
importDatasetFile.click();
|
||||
});
|
||||
|
||||
importDatasetFile.addEventListener('change', function () {
|
||||
importDatasetFromFile(this);
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel dataset button
|
||||
if (cancelDatasetBtn) {
|
||||
cancelDatasetBtn.addEventListener('click', hideNewDatasetForm);
|
||||
}
|
||||
|
||||
// Save dataset button
|
||||
if (saveDatasetBtn) {
|
||||
saveDatasetBtn.addEventListener('click', saveNewDataset);
|
||||
}
|
||||
|
||||
// Delete dataset button
|
||||
if (deleteDatasetBtn) {
|
||||
deleteDatasetBtn.addEventListener('click', deleteCurrentDataset);
|
||||
}
|
||||
|
||||
// Copy reference button
|
||||
if (copyReferenceBtn) {
|
||||
copyReferenceBtn.addEventListener('click', copyDatasetReference);
|
||||
}
|
||||
|
||||
// Refresh metadata button
|
||||
const refreshMetadataBtn = document.getElementById('refresh-metadata-btn');
|
||||
if (refreshMetadataBtn) {
|
||||
refreshMetadataBtn.addEventListener('click', refreshDatasetMetadata);
|
||||
}
|
||||
|
||||
// New snippet from dataset button
|
||||
const newSnippetBtn = document.getElementById('new-snippet-btn');
|
||||
if (newSnippetBtn) {
|
||||
newSnippetBtn.addEventListener('click', createNewSnippetFromDataset);
|
||||
}
|
||||
|
||||
// Export dataset button
|
||||
const exportDatasetBtn = document.getElementById('export-dataset-btn');
|
||||
if (exportDatasetBtn) {
|
||||
exportDatasetBtn.addEventListener('click', exportCurrentDataset);
|
||||
}
|
||||
|
||||
// Preview toggle buttons
|
||||
const previewRawBtn = document.getElementById('preview-raw-btn');
|
||||
const previewTableBtn = document.getElementById('preview-table-btn');
|
||||
if (previewRawBtn) {
|
||||
previewRawBtn.addEventListener('click', function() {
|
||||
if (window.currentDatasetData) {
|
||||
showRawPreview(window.currentDatasetData);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (previewTableBtn) {
|
||||
previewTableBtn.addEventListener('click', function() {
|
||||
if (window.currentDatasetData) {
|
||||
showTablePreview(window.currentDatasetData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Help Modal
|
||||
const helpModal = document.getElementById('help-modal');
|
||||
const helpModalClose = document.getElementById('help-modal-close');
|
||||
|
||||
if (helpModalClose) {
|
||||
helpModalClose.addEventListener('click', closeHelpModal);
|
||||
}
|
||||
|
||||
// Close on overlay click
|
||||
if (helpModal) {
|
||||
helpModal.addEventListener('click', function (e) {
|
||||
if (e.target === helpModal) {
|
||||
closeHelpModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Donate Modal
|
||||
const donateModal = document.getElementById('donate-modal');
|
||||
const donateModalClose = document.getElementById('donate-modal-close');
|
||||
|
||||
if (donateModalClose) {
|
||||
donateModalClose.addEventListener('click', closeDonateModal);
|
||||
}
|
||||
|
||||
// Close on overlay click
|
||||
if (donateModal) {
|
||||
donateModal.addEventListener('click', function (e) {
|
||||
if (e.target === donateModal) {
|
||||
closeDonateModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// View mode toggle buttons
|
||||
document.getElementById('view-draft').addEventListener('click', () => {
|
||||
switchViewMode('draft');
|
||||
});
|
||||
|
||||
document.getElementById('view-published').addEventListener('click', () => {
|
||||
switchViewMode('published');
|
||||
});
|
||||
|
||||
// Publish and Revert buttons
|
||||
document.getElementById('publish-btn').addEventListener('click', publishDraft);
|
||||
document.getElementById('revert-btn').addEventListener('click', revertDraft);
|
||||
|
||||
// Extract to Dataset button
|
||||
const extractBtn = document.getElementById('extract-btn');
|
||||
if (extractBtn) {
|
||||
extractBtn.addEventListener('click', showExtractModal);
|
||||
}
|
||||
|
||||
// Extract modal buttons
|
||||
const extractModalClose = document.getElementById('extract-modal-close');
|
||||
const extractCancelBtn = document.getElementById('extract-cancel-btn');
|
||||
const extractCreateBtn = document.getElementById('extract-create-btn');
|
||||
const extractModal = document.getElementById('extract-modal');
|
||||
|
||||
if (extractModalClose) {
|
||||
extractModalClose.addEventListener('click', hideExtractModal);
|
||||
}
|
||||
|
||||
if (extractCancelBtn) {
|
||||
extractCancelBtn.addEventListener('click', hideExtractModal);
|
||||
}
|
||||
|
||||
if (extractCreateBtn) {
|
||||
extractCreateBtn.addEventListener('click', extractToDataset);
|
||||
}
|
||||
|
||||
// Close modal on overlay click
|
||||
if (extractModal) {
|
||||
extractModal.addEventListener('click', function (e) {
|
||||
if (e.target === extractModal) {
|
||||
hideExtractModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle URL hash changes (browser back/forward)
|
||||
function handleURLStateChange() {
|
||||
const state = URLState.parse();
|
||||
|
||||
if (state.view === 'datasets') {
|
||||
// Open dataset modal
|
||||
openDatasetManager(false); // Don't update URL
|
||||
|
||||
if (state.datasetId === 'new') {
|
||||
// Show new dataset form
|
||||
showNewDatasetForm(false);
|
||||
} else if (state.datasetId) {
|
||||
// Extract numeric ID from "dataset-123456"
|
||||
const numericId = parseFloat(state.datasetId.replace('dataset-', ''));
|
||||
selectDataset(numericId, false);
|
||||
}
|
||||
} else if (state.snippetId) {
|
||||
// Close dataset modal if open
|
||||
const modal = document.getElementById('dataset-modal');
|
||||
if (modal && modal.style.display === 'flex') {
|
||||
closeDatasetManager(false);
|
||||
}
|
||||
|
||||
// Select snippet
|
||||
const numericId = parseFloat(state.snippetId.replace('snippet-', ''));
|
||||
selectSnippet(numericId, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize URL state management
|
||||
function initializeURLStateManagement() {
|
||||
// Handle hashchange event for back/forward navigation
|
||||
window.addEventListener('hashchange', handleURLStateChange);
|
||||
|
||||
// Check if there's a hash in the URL on page load
|
||||
if (window.location.hash) {
|
||||
handleURLStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcut action handlers (shared between Monaco and document)
|
||||
const KeyboardActions = {
|
||||
createNewSnippet: function() {
|
||||
createNewSnippet();
|
||||
},
|
||||
|
||||
toggleDatasetManager: function() {
|
||||
const modal = document.getElementById('dataset-modal');
|
||||
if (modal && modal.style.display === 'flex') {
|
||||
closeDatasetManager();
|
||||
} else {
|
||||
openDatasetManager();
|
||||
}
|
||||
},
|
||||
|
||||
publishDraft: function() {
|
||||
if (currentViewMode === 'draft' && window.currentSnippetId) {
|
||||
publishDraft();
|
||||
}
|
||||
},
|
||||
|
||||
toggleSettings: function() {
|
||||
const modal = document.getElementById('settings-modal');
|
||||
if (modal && modal.style.display === 'flex') {
|
||||
closeSettingsModal();
|
||||
} else {
|
||||
openSettingsModal();
|
||||
}
|
||||
},
|
||||
|
||||
closeAnyModal: function() {
|
||||
const helpModal = document.getElementById('help-modal');
|
||||
const datasetModal = document.getElementById('dataset-modal');
|
||||
const extractModal = document.getElementById('extract-modal');
|
||||
const donateModal = document.getElementById('donate-modal');
|
||||
const settingsModal = document.getElementById('settings-modal');
|
||||
|
||||
if (helpModal && helpModal.style.display === 'flex') {
|
||||
closeHelpModal();
|
||||
return true;
|
||||
}
|
||||
if (datasetModal && datasetModal.style.display === 'flex') {
|
||||
closeDatasetManager();
|
||||
return true;
|
||||
}
|
||||
if (extractModal && extractModal.style.display === 'flex') {
|
||||
hideExtractModal();
|
||||
return true;
|
||||
}
|
||||
if (donateModal && donateModal.style.display === 'flex') {
|
||||
closeDonateModal();
|
||||
return true;
|
||||
}
|
||||
if (settingsModal && settingsModal.style.display === 'flex') {
|
||||
closeSettingsModal();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcuts handler (document-level)
|
||||
function initializeKeyboardShortcuts() {
|
||||
document.addEventListener('keydown', function(e) {
|
||||
// Escape: Close any open modal
|
||||
if (e.key === 'Escape') {
|
||||
if (KeyboardActions.closeAnyModal()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Detect modifier key: Cmd on Mac, Ctrl on Windows/Linux
|
||||
const modifierKey = e.metaKey || e.ctrlKey;
|
||||
|
||||
// Cmd/Ctrl+Shift+N: Create new snippet
|
||||
if (modifierKey && e.shiftKey && e.key.toLowerCase() === 'n') {
|
||||
e.preventDefault();
|
||||
KeyboardActions.createNewSnippet();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+K: Toggle dataset manager
|
||||
if (modifierKey && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
KeyboardActions.toggleDatasetManager();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+,: Toggle settings
|
||||
if (modifierKey && e.key === ',') {
|
||||
e.preventDefault();
|
||||
KeyboardActions.toggleSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+S: Publish draft
|
||||
if (modifierKey && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
KeyboardActions.publishDraft();
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Register keyboard shortcuts in Monaco Editor
|
||||
function registerMonacoKeyboardShortcuts() {
|
||||
if (!editor) return;
|
||||
|
||||
// Cmd/Ctrl+Shift+N: Create new snippet
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyN,
|
||||
KeyboardActions.createNewSnippet);
|
||||
|
||||
// Cmd/Ctrl+K: Toggle dataset manager
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK,
|
||||
KeyboardActions.toggleDatasetManager);
|
||||
|
||||
// Cmd/Ctrl+S: Publish draft
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
|
||||
KeyboardActions.publishDraft);
|
||||
}
|
||||
|
||||
// Help modal functions
|
||||
function openHelpModal() {
|
||||
const modal = document.getElementById('help-modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
// Track event
|
||||
Analytics.track('modal-help', 'Open Help modal');
|
||||
}
|
||||
}
|
||||
|
||||
function closeHelpModal() {
|
||||
const modal = document.getElementById('help-modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Donate modal functions
|
||||
function openDonateModal() {
|
||||
const modal = document.getElementById('donate-modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
// Track event
|
||||
Analytics.track('modal-donate', 'Open Donate modal');
|
||||
}
|
||||
}
|
||||
|
||||
function closeDonateModal() {
|
||||
const modal = document.getElementById('donate-modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Settings modal functions
|
||||
function openSettingsModal() {
|
||||
loadSettingsIntoUI();
|
||||
const modal = document.getElementById('settings-modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
// Track event
|
||||
Analytics.track('modal-settings', 'Open Settings modal');
|
||||
}
|
||||
}
|
||||
|
||||
function closeSettingsModal() {
|
||||
const modal = document.getElementById('settings-modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function loadSettingsIntoUI() {
|
||||
const settings = getSettings();
|
||||
|
||||
// Appearance settings
|
||||
const uiThemeSelect = document.getElementById('setting-ui-theme');
|
||||
if (uiThemeSelect) {
|
||||
uiThemeSelect.value = settings.ui.theme;
|
||||
}
|
||||
|
||||
// Editor settings
|
||||
const fontSizeSlider = document.getElementById('setting-font-size');
|
||||
const fontSizeValue = document.getElementById('setting-font-size-value');
|
||||
if (fontSizeSlider && fontSizeValue) {
|
||||
fontSizeSlider.value = settings.editor.fontSize;
|
||||
fontSizeValue.textContent = settings.editor.fontSize + 'px';
|
||||
}
|
||||
|
||||
const editorThemeSelect = document.getElementById('setting-editor-theme');
|
||||
if (editorThemeSelect) {
|
||||
editorThemeSelect.value = settings.editor.theme;
|
||||
}
|
||||
|
||||
const tabSizeSelect = document.getElementById('setting-tab-size');
|
||||
if (tabSizeSelect) {
|
||||
tabSizeSelect.value = settings.editor.tabSize;
|
||||
}
|
||||
|
||||
const minimapCheckbox = document.getElementById('setting-minimap');
|
||||
if (minimapCheckbox) {
|
||||
minimapCheckbox.checked = settings.editor.minimap;
|
||||
}
|
||||
|
||||
const wordWrapCheckbox = document.getElementById('setting-word-wrap');
|
||||
if (wordWrapCheckbox) {
|
||||
wordWrapCheckbox.checked = settings.editor.wordWrap === 'on';
|
||||
}
|
||||
|
||||
const lineNumbersCheckbox = document.getElementById('setting-line-numbers');
|
||||
if (lineNumbersCheckbox) {
|
||||
lineNumbersCheckbox.checked = settings.editor.lineNumbers === 'on';
|
||||
}
|
||||
|
||||
// Performance settings
|
||||
const renderDebounceSlider = document.getElementById('setting-render-debounce');
|
||||
const renderDebounceValue = document.getElementById('setting-render-debounce-value');
|
||||
if (renderDebounceSlider && renderDebounceValue) {
|
||||
renderDebounceSlider.value = settings.performance.renderDebounce;
|
||||
renderDebounceValue.textContent = settings.performance.renderDebounce + 'ms';
|
||||
}
|
||||
|
||||
// Formatting settings
|
||||
const dateFormatSelect = document.getElementById('setting-date-format');
|
||||
const customDateFormatItem = document.getElementById('custom-date-format-item');
|
||||
if (dateFormatSelect) {
|
||||
dateFormatSelect.value = settings.formatting.dateFormat;
|
||||
if (customDateFormatItem) {
|
||||
customDateFormatItem.style.display = settings.formatting.dateFormat === 'custom' ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const customDateFormatInput = document.getElementById('setting-custom-date-format');
|
||||
if (customDateFormatInput) {
|
||||
customDateFormatInput.value = settings.formatting.customDateFormat;
|
||||
}
|
||||
}
|
||||
|
||||
function applySettings() {
|
||||
// Collect values from UI
|
||||
const newSettings = {
|
||||
'ui.theme': document.getElementById('setting-ui-theme').value,
|
||||
'editor.fontSize': parseInt(document.getElementById('setting-font-size').value),
|
||||
'editor.theme': document.getElementById('setting-editor-theme').value,
|
||||
'editor.tabSize': parseInt(document.getElementById('setting-tab-size').value),
|
||||
'editor.minimap': document.getElementById('setting-minimap').checked,
|
||||
'editor.wordWrap': document.getElementById('setting-word-wrap').checked ? 'on' : 'off',
|
||||
'editor.lineNumbers': document.getElementById('setting-line-numbers').checked ? 'on' : 'off',
|
||||
'performance.renderDebounce': parseInt(document.getElementById('setting-render-debounce').value),
|
||||
'formatting.dateFormat': document.getElementById('setting-date-format').value,
|
||||
'formatting.customDateFormat': document.getElementById('setting-custom-date-format').value
|
||||
};
|
||||
|
||||
// Validate settings
|
||||
let hasErrors = false;
|
||||
for (const [path, value] of Object.entries(newSettings)) {
|
||||
const errors = validateSetting(path, value);
|
||||
if (errors.length > 0) {
|
||||
Toast.show(errors.join(', '), 'error');
|
||||
hasErrors = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save settings
|
||||
if (updateSettings(newSettings)) {
|
||||
// Apply theme to document
|
||||
const uiTheme = newSettings['ui.theme'];
|
||||
document.documentElement.setAttribute('data-theme', uiTheme);
|
||||
|
||||
// Sync editor theme with UI theme
|
||||
const editorTheme = uiTheme === 'experimental' ? 'vs-dark' : 'vs-light';
|
||||
newSettings['editor.theme'] = editorTheme;
|
||||
|
||||
// Apply editor settings immediately
|
||||
if (editor) {
|
||||
editor.updateOptions({
|
||||
fontSize: newSettings['editor.fontSize'],
|
||||
theme: editorTheme,
|
||||
tabSize: newSettings['editor.tabSize'],
|
||||
minimap: { enabled: newSettings['editor.minimap'] },
|
||||
wordWrap: newSettings['editor.wordWrap'],
|
||||
lineNumbers: newSettings['editor.lineNumbers']
|
||||
});
|
||||
}
|
||||
|
||||
// Update the editor theme in settings
|
||||
updateSetting('editor.theme', editorTheme);
|
||||
|
||||
// Update debounced render function
|
||||
if (typeof updateRenderDebounce === 'function') {
|
||||
updateRenderDebounce(newSettings['performance.renderDebounce']);
|
||||
}
|
||||
|
||||
// Re-render snippet list to reflect date format changes
|
||||
renderSnippetList();
|
||||
|
||||
// Update metadata display if a snippet is selected
|
||||
if (window.currentSnippetId) {
|
||||
const snippet = SnippetStorage.getSnippet(window.currentSnippetId);
|
||||
if (snippet) {
|
||||
document.getElementById('snippet-created').textContent = formatDate(snippet.created, true);
|
||||
document.getElementById('snippet-modified').textContent = formatDate(snippet.modified, true);
|
||||
}
|
||||
}
|
||||
|
||||
Toast.show('Settings applied successfully', 'success');
|
||||
closeSettingsModal();
|
||||
|
||||
// Track event
|
||||
Analytics.track('settings-apply', 'Applied settings');
|
||||
} else {
|
||||
Toast.show('Failed to save settings', 'error');
|
||||
}
|
||||
}
|
||||
257
src/js/config.js
Normal file
257
src/js/config.js
Normal file
@@ -0,0 +1,257 @@
|
||||
// Global variables and configuration
|
||||
let editor; // Global editor instance
|
||||
let renderTimeout; // For debouncing
|
||||
let currentViewMode = 'draft'; // Track current view mode: 'draft' or 'published'
|
||||
|
||||
// Panel resizing variables
|
||||
let isResizing = false;
|
||||
let currentHandle = null;
|
||||
let startX = 0;
|
||||
let startWidths = [];
|
||||
|
||||
// Panel memory for toggle functionality
|
||||
let panelMemory = {
|
||||
snippetWidth: '25%',
|
||||
editorWidth: '50%',
|
||||
previewWidth: '25%'
|
||||
};
|
||||
|
||||
// URL State Management
|
||||
const URLState = {
|
||||
// Parse current hash into state object
|
||||
parse() {
|
||||
const hash = window.location.hash.slice(1); // Remove '#'
|
||||
if (!hash) return { view: 'snippets', snippetId: null, datasetId: null };
|
||||
|
||||
const parts = hash.split('/');
|
||||
|
||||
// #snippet-123456
|
||||
if (hash.startsWith('snippet-')) {
|
||||
return { view: 'snippets', snippetId: hash, datasetId: null };
|
||||
}
|
||||
|
||||
// #datasets
|
||||
if (parts[0] === 'datasets') {
|
||||
if (parts.length === 1) {
|
||||
return { view: 'datasets', snippetId: null, datasetId: null };
|
||||
}
|
||||
// #datasets/new
|
||||
if (parts[1] === 'new') {
|
||||
return { view: 'datasets', snippetId: null, datasetId: 'new' };
|
||||
}
|
||||
// #datasets/dataset-123456
|
||||
if (parts[1].startsWith('dataset-')) {
|
||||
return { view: 'datasets', snippetId: null, datasetId: parts[1] };
|
||||
}
|
||||
}
|
||||
|
||||
return { view: 'snippets', snippetId: null, datasetId: null };
|
||||
},
|
||||
|
||||
// Update URL hash without triggering hashchange
|
||||
update(state, replaceState = false) {
|
||||
let hash = '';
|
||||
|
||||
if (state.view === 'datasets') {
|
||||
if (state.datasetId === 'new') {
|
||||
hash = '#datasets/new';
|
||||
} else if (state.datasetId) {
|
||||
// Add 'dataset-' prefix if not already present
|
||||
const datasetId = typeof state.datasetId === 'string' && state.datasetId.startsWith('dataset-')
|
||||
? state.datasetId
|
||||
: `dataset-${state.datasetId}`;
|
||||
hash = `#datasets/${datasetId}`;
|
||||
} else {
|
||||
hash = '#datasets';
|
||||
}
|
||||
} else if (state.snippetId) {
|
||||
// Add 'snippet-' prefix if not already present
|
||||
const snippetId = typeof state.snippetId === 'string' && state.snippetId.startsWith('snippet-')
|
||||
? state.snippetId
|
||||
: `snippet-${state.snippetId}`;
|
||||
hash = `#${snippetId}`;
|
||||
}
|
||||
|
||||
if (replaceState) {
|
||||
window.history.replaceState(null, '', hash || '#');
|
||||
} else {
|
||||
window.location.hash = hash;
|
||||
}
|
||||
},
|
||||
|
||||
// Clear hash
|
||||
clear() {
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
}
|
||||
};
|
||||
|
||||
// Settings storage
|
||||
const AppSettings = {
|
||||
STORAGE_KEY: 'astrolabe-settings',
|
||||
|
||||
// Default settings
|
||||
defaults: {
|
||||
sortBy: 'modified',
|
||||
sortOrder: 'desc'
|
||||
},
|
||||
|
||||
// Load settings from localStorage
|
||||
load() {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.STORAGE_KEY);
|
||||
return stored ? { ...this.defaults, ...JSON.parse(stored) } : this.defaults;
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
return this.defaults;
|
||||
}
|
||||
},
|
||||
|
||||
// Save settings to localStorage
|
||||
save(settings) {
|
||||
try {
|
||||
const currentSettings = this.load();
|
||||
const updatedSettings = { ...currentSettings, ...settings };
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(updatedSettings));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// Get specific setting
|
||||
get(key) {
|
||||
const settings = this.load();
|
||||
return settings[key];
|
||||
},
|
||||
|
||||
// Set specific setting
|
||||
set(key, value) {
|
||||
const update = {};
|
||||
update[key] = value;
|
||||
return this.save(update);
|
||||
}
|
||||
};
|
||||
|
||||
// Toast Notification System
|
||||
const Toast = {
|
||||
// Auto-dismiss duration in milliseconds
|
||||
DURATION: 4000,
|
||||
|
||||
// Toast counter for unique IDs
|
||||
toastCounter: 0,
|
||||
|
||||
// Show toast notification
|
||||
show(message, type = 'info') {
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
|
||||
// Create toast element
|
||||
const toast = document.createElement('div');
|
||||
const toastId = `toast-${++this.toastCounter}`;
|
||||
toast.id = toastId;
|
||||
toast.className = `toast toast-${type}`;
|
||||
|
||||
// Toast icon based on type
|
||||
const icons = {
|
||||
error: '❌',
|
||||
success: '✓',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️'
|
||||
};
|
||||
|
||||
toast.innerHTML = `
|
||||
<span class="toast-icon">${icons[type] || icons.info}</span>
|
||||
<span class="toast-message">${message}</span>
|
||||
<button class="toast-close" onclick="Toast.dismiss('${toastId}')">×</button>
|
||||
`;
|
||||
|
||||
// Add to container
|
||||
container.appendChild(toast);
|
||||
|
||||
// Trigger animation
|
||||
setTimeout(() => toast.classList.add('toast-show'), 10);
|
||||
|
||||
// Auto-dismiss
|
||||
setTimeout(() => this.dismiss(toastId), this.DURATION);
|
||||
},
|
||||
|
||||
// Dismiss specific toast
|
||||
dismiss(toastId) {
|
||||
const toast = document.getElementById(toastId);
|
||||
if (!toast) return;
|
||||
|
||||
toast.classList.remove('toast-show');
|
||||
toast.classList.add('toast-hide');
|
||||
|
||||
// Remove from DOM after animation
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
|
||||
// Convenience methods
|
||||
error(message) {
|
||||
this.show(message, 'error');
|
||||
},
|
||||
|
||||
success(message) {
|
||||
this.show(message, 'success');
|
||||
},
|
||||
|
||||
warning(message) {
|
||||
this.show(message, 'warning');
|
||||
},
|
||||
|
||||
info(message) {
|
||||
this.show(message, 'info');
|
||||
}
|
||||
};
|
||||
|
||||
// Analytics utility: Track events with GoatCounter
|
||||
const Analytics = {
|
||||
track(eventName, title) {
|
||||
// Only track if GoatCounter is loaded
|
||||
if (window.goatcounter && window.goatcounter.count) {
|
||||
window.goatcounter.count({
|
||||
path: eventName,
|
||||
title: title || eventName,
|
||||
event: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Shared utility: Format bytes for display
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === null || bytes === undefined) return 'N/A';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
// Sample Vega-Lite specification
|
||||
const sampleSpec = {
|
||||
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
|
||||
"description": "A simple bar chart with embedded data.",
|
||||
"data": {
|
||||
"values": [
|
||||
{ "category": "A", "value": 28 },
|
||||
{ "category": "B", "value": 55 },
|
||||
{ "category": "C", "value": 43 },
|
||||
{ "category": "D", "value": 91 },
|
||||
{ "category": "E", "value": 81 },
|
||||
{ "category": "F", "value": 53 },
|
||||
{ "category": "G", "value": 19 },
|
||||
{ "category": "H", "value": 87 }
|
||||
]
|
||||
},
|
||||
"mark": "bar",
|
||||
"encoding": {
|
||||
"x": { "field": "category", "type": "nominal", "axis": { "labelAngle": 0 } },
|
||||
"y": { "field": "value", "type": "quantitative" }
|
||||
}
|
||||
};
|
||||
|
||||
1418
src/js/dataset-manager.js
Normal file
1418
src/js/dataset-manager.js
Normal file
File diff suppressed because it is too large
Load Diff
156
src/js/editor.js
Normal file
156
src/js/editor.js
Normal file
@@ -0,0 +1,156 @@
|
||||
// Resolve dataset references in a spec
|
||||
async function resolveDatasetReferences(spec) {
|
||||
// If spec has data.name, look it up
|
||||
if (spec.data && spec.data.name && typeof spec.data.name === 'string') {
|
||||
const datasetName = spec.data.name;
|
||||
const dataset = await DatasetStorage.getDatasetByName(datasetName);
|
||||
|
||||
if (dataset) {
|
||||
// Replace data reference with actual data in the format Vega-Lite expects
|
||||
if (dataset.source === 'url') {
|
||||
// For URL sources, pass the URL and format
|
||||
spec.data = {
|
||||
url: dataset.data,
|
||||
format: { type: dataset.format }
|
||||
};
|
||||
} else {
|
||||
// For inline sources
|
||||
if (dataset.format === 'json') {
|
||||
spec.data = { values: dataset.data };
|
||||
} else if (dataset.format === 'csv') {
|
||||
spec.data = {
|
||||
values: dataset.data,
|
||||
format: { type: 'csv' }
|
||||
};
|
||||
} else if (dataset.format === 'tsv') {
|
||||
spec.data = {
|
||||
values: dataset.data,
|
||||
format: { type: 'tsv' }
|
||||
};
|
||||
} else if (dataset.format === 'topojson') {
|
||||
spec.data = {
|
||||
values: dataset.data,
|
||||
format: { type: 'topojson' }
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Dataset "${datasetName}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively resolve in layers (for layered specs)
|
||||
if (spec.layer && Array.isArray(spec.layer)) {
|
||||
for (let layer of spec.layer) {
|
||||
await resolveDatasetReferences(layer);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively resolve in concat/hconcat/vconcat
|
||||
if (spec.concat && Array.isArray(spec.concat)) {
|
||||
for (let view of spec.concat) {
|
||||
await resolveDatasetReferences(view);
|
||||
}
|
||||
}
|
||||
if (spec.hconcat && Array.isArray(spec.hconcat)) {
|
||||
for (let view of spec.hconcat) {
|
||||
await resolveDatasetReferences(view);
|
||||
}
|
||||
}
|
||||
if (spec.vconcat && Array.isArray(spec.vconcat)) {
|
||||
for (let view of spec.vconcat) {
|
||||
await resolveDatasetReferences(view);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively resolve in facet
|
||||
if (spec.spec) {
|
||||
await resolveDatasetReferences(spec.spec);
|
||||
}
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
// Render function that takes spec from editor
|
||||
async function renderVisualization() {
|
||||
const previewContainer = document.getElementById('vega-preview');
|
||||
|
||||
try {
|
||||
// Get current content from editor
|
||||
const specText = editor.getValue();
|
||||
let spec = JSON.parse(specText);
|
||||
|
||||
// Resolve dataset references
|
||||
spec = await resolveDatasetReferences(spec);
|
||||
|
||||
// Render with Vega-Embed (use global variable)
|
||||
await window.vegaEmbed('#vega-preview', spec, {
|
||||
actions: false, // Hide action menu for cleaner look
|
||||
renderer: 'svg' // Use SVG for better quality
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Handle rendering errors gracefully
|
||||
previewContainer.innerHTML = `
|
||||
<div style="padding: 20px; color: #d32f2f; font-size: 12px; font-family: monospace;">
|
||||
<strong>Rendering Error:</strong><br>
|
||||
${error.message}
|
||||
<br><br>
|
||||
<em>Check your JSON syntax and Vega-Lite specification.</em>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced render function
|
||||
function debouncedRender() {
|
||||
// Don't debounce if we're programmatically updating - render immediately
|
||||
if (window.isUpdatingEditor) {
|
||||
renderVisualization();
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(renderTimeout);
|
||||
const debounceTime = getSetting('performance.renderDebounce') || 1500;
|
||||
renderTimeout = setTimeout(renderVisualization, debounceTime);
|
||||
}
|
||||
|
||||
// Update render debounce setting (called when settings are changed)
|
||||
function updateRenderDebounce(newDebounce) {
|
||||
// The next render will automatically use the new debounce time
|
||||
// No need to do anything special here
|
||||
}
|
||||
|
||||
// Load Vega libraries dynamically with UMD builds
|
||||
function loadVegaLibraries() {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Temporarily disable AMD define to avoid conflicts
|
||||
const originalDefine = window.define;
|
||||
window.define = undefined;
|
||||
|
||||
// Load Vega
|
||||
const vegaScript = document.createElement('script');
|
||||
vegaScript.src = 'https://unpkg.com/vega@5/build/vega.min.js';
|
||||
vegaScript.onload = () => {
|
||||
// Load Vega-Lite
|
||||
const vegaLiteScript = document.createElement('script');
|
||||
vegaLiteScript.src = 'https://unpkg.com/vega-lite@5/build/vega-lite.min.js';
|
||||
vegaLiteScript.onload = () => {
|
||||
// Load Vega-Embed
|
||||
const vegaEmbedScript = document.createElement('script');
|
||||
vegaEmbedScript.src = 'https://unpkg.com/vega-embed@6/build/vega-embed.min.js';
|
||||
vegaEmbedScript.onload = () => {
|
||||
// Restore AMD define
|
||||
window.define = originalDefine;
|
||||
resolve();
|
||||
};
|
||||
vegaEmbedScript.onerror = reject;
|
||||
document.head.appendChild(vegaEmbedScript);
|
||||
};
|
||||
vegaLiteScript.onerror = reject;
|
||||
document.head.appendChild(vegaLiteScript);
|
||||
};
|
||||
vegaScript.onerror = reject;
|
||||
document.head.appendChild(vegaScript);
|
||||
});
|
||||
}
|
||||
199
src/js/panel-manager.js
Normal file
199
src/js/panel-manager.js
Normal file
@@ -0,0 +1,199 @@
|
||||
// Panel toggle and expansion functions
|
||||
function updatePanelMemory() {
|
||||
const snippetPanel = document.getElementById('snippet-panel');
|
||||
const editorPanel = document.getElementById('editor-panel');
|
||||
const previewPanel = document.getElementById('preview-panel');
|
||||
|
||||
// Only update memory for visible panels
|
||||
if (snippetPanel.style.display !== 'none') {
|
||||
panelMemory.snippetWidth = snippetPanel.style.width || '25%';
|
||||
}
|
||||
if (editorPanel.style.display !== 'none') {
|
||||
panelMemory.editorWidth = editorPanel.style.width || '50%';
|
||||
}
|
||||
if (previewPanel.style.display !== 'none') {
|
||||
panelMemory.previewWidth = previewPanel.style.width || '25%';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function togglePanel(panelId) {
|
||||
const panel = document.getElementById(panelId);
|
||||
const button = document.getElementById(`toggle-${panelId}`);
|
||||
|
||||
if (!panel || !button) return;
|
||||
|
||||
if (panel.style.display === 'none') {
|
||||
// Show panel
|
||||
panel.style.display = 'flex';
|
||||
button.classList.add('active');
|
||||
|
||||
// Restore from memory and redistribute
|
||||
redistributePanelWidths();
|
||||
} else {
|
||||
// Hide panel - DON'T update memory, just hide
|
||||
panel.style.display = 'none';
|
||||
button.classList.remove('active');
|
||||
|
||||
// Redistribute remaining panels
|
||||
redistributePanelWidths();
|
||||
}
|
||||
|
||||
saveLayoutToStorage();
|
||||
}
|
||||
|
||||
function redistributePanelWidths() {
|
||||
|
||||
const snippetPanel = document.getElementById('snippet-panel');
|
||||
const editorPanel = document.getElementById('editor-panel');
|
||||
const previewPanel = document.getElementById('preview-panel');
|
||||
|
||||
const panels = [
|
||||
{ element: snippetPanel, id: 'snippet', memoryKey: 'snippetWidth' },
|
||||
{ element: editorPanel, id: 'editor', memoryKey: 'editorWidth' },
|
||||
{ element: previewPanel, id: 'preview', memoryKey: 'previewWidth' }
|
||||
];
|
||||
|
||||
const visiblePanels = panels.filter(panel => panel.element.style.display !== 'none');
|
||||
|
||||
if (visiblePanels.length === 0) return;
|
||||
|
||||
// Get total desired width from memory
|
||||
let totalMemoryWidth = 0;
|
||||
visiblePanels.forEach(panel => {
|
||||
const width = parseFloat(panelMemory[panel.memoryKey]);
|
||||
totalMemoryWidth += width;
|
||||
});
|
||||
|
||||
// Redistribute proportionally to fill 100%
|
||||
visiblePanels.forEach(panel => {
|
||||
const memoryWidth = parseFloat(panelMemory[panel.memoryKey]);
|
||||
const newWidth = (memoryWidth / totalMemoryWidth) * 100;
|
||||
panel.element.style.width = `${newWidth}%`;
|
||||
});
|
||||
}
|
||||
|
||||
function saveLayoutToStorage() {
|
||||
const snippetPanel = document.getElementById('snippet-panel');
|
||||
const editorPanel = document.getElementById('editor-panel');
|
||||
const previewPanel = document.getElementById('preview-panel');
|
||||
|
||||
// DON'T update memory here - it's already updated during manual resize
|
||||
|
||||
const layout = {
|
||||
snippetWidth: snippetPanel.style.width || '25%',
|
||||
editorWidth: editorPanel.style.width || '50%',
|
||||
previewWidth: previewPanel.style.width || '25%',
|
||||
snippetVisible: snippetPanel.style.display !== 'none',
|
||||
editorVisible: editorPanel.style.display !== 'none',
|
||||
previewVisible: previewPanel.style.display !== 'none',
|
||||
memory: panelMemory
|
||||
};
|
||||
|
||||
localStorage.setItem('astrolabe-layout', JSON.stringify(layout));
|
||||
}
|
||||
|
||||
function loadLayoutFromStorage() {
|
||||
try {
|
||||
const saved = localStorage.getItem('astrolabe-layout');
|
||||
if (saved) {
|
||||
const layout = JSON.parse(saved);
|
||||
|
||||
// Restore memory if available
|
||||
if (layout.memory) {
|
||||
panelMemory = layout.memory;
|
||||
}
|
||||
|
||||
// Restore panel visibility
|
||||
const snippetPanel = document.getElementById('snippet-panel');
|
||||
const editorPanel = document.getElementById('editor-panel');
|
||||
const previewPanel = document.getElementById('preview-panel');
|
||||
|
||||
snippetPanel.style.display = layout.snippetVisible !== false ? 'flex' : 'none';
|
||||
editorPanel.style.display = layout.editorVisible !== false ? 'flex' : 'none';
|
||||
previewPanel.style.display = layout.previewVisible !== false ? 'flex' : 'none';
|
||||
|
||||
// Update toggle button states
|
||||
document.getElementById('toggle-snippet-panel').classList.toggle('active', layout.snippetVisible !== false);
|
||||
document.getElementById('toggle-editor-panel').classList.toggle('active', layout.editorVisible !== false);
|
||||
document.getElementById('toggle-preview-panel').classList.toggle('active', layout.previewVisible !== false);
|
||||
|
||||
// Restore widths and redistribute
|
||||
snippetPanel.style.width = layout.snippetWidth;
|
||||
editorPanel.style.width = layout.editorWidth;
|
||||
previewPanel.style.width = layout.previewWidth;
|
||||
|
||||
redistributePanelWidths();
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors, use default layout
|
||||
}
|
||||
}
|
||||
function initializeResize() {
|
||||
const handles = document.querySelectorAll('.resize-handle');
|
||||
const panels = [
|
||||
document.getElementById('snippet-panel'),
|
||||
document.getElementById('editor-panel'),
|
||||
document.getElementById('preview-panel')
|
||||
];
|
||||
|
||||
handles.forEach((handle, index) => {
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
isResizing = true;
|
||||
currentHandle = index;
|
||||
startX = e.clientX;
|
||||
startWidths = panels.map(panel => panel.getBoundingClientRect().width);
|
||||
|
||||
handle.classList.add('dragging');
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
e.preventDefault();
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const deltaX = e.clientX - startX;
|
||||
const containerWidth = document.querySelector('.main-panels').getBoundingClientRect().width;
|
||||
|
||||
if (currentHandle === 0) {
|
||||
// Resizing between snippet and editor panels
|
||||
const minWidth = 200;
|
||||
const newSnippetWidth = Math.max(minWidth, startWidths[0] + deltaX);
|
||||
const newEditorWidth = Math.max(minWidth, startWidths[1] - deltaX);
|
||||
|
||||
if (newSnippetWidth >= minWidth && newEditorWidth >= minWidth) {
|
||||
panels[0].style.width = `${(newSnippetWidth / containerWidth) * 100}%`;
|
||||
panels[1].style.width = `${(newEditorWidth / containerWidth) * 100}%`;
|
||||
}
|
||||
} else if (currentHandle === 1) {
|
||||
// Resizing between editor and preview panels
|
||||
const minWidth = 200;
|
||||
const newEditorWidth = Math.max(minWidth, startWidths[1] + deltaX);
|
||||
const newPreviewWidth = Math.max(minWidth, startWidths[2] - deltaX);
|
||||
|
||||
if (newEditorWidth >= minWidth && newPreviewWidth >= minWidth) {
|
||||
panels[1].style.width = `${(newEditorWidth / containerWidth) * 100}%`;
|
||||
panels[2].style.width = `${(newPreviewWidth / containerWidth) * 100}%`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (isResizing) {
|
||||
isResizing = false;
|
||||
currentHandle = null;
|
||||
|
||||
document.querySelectorAll('.resize-handle').forEach(h => h.classList.remove('dragging'));
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
// Update memory ONLY after manual resize
|
||||
updatePanelMemory();
|
||||
|
||||
saveLayoutToStorage();
|
||||
}
|
||||
});
|
||||
}
|
||||
1331
src/js/snippet-manager.js
Normal file
1331
src/js/snippet-manager.js
Normal file
File diff suppressed because it is too large
Load Diff
299
src/js/user-settings.js
Normal file
299
src/js/user-settings.js
Normal file
@@ -0,0 +1,299 @@
|
||||
// user-settings.js - User preferences and configuration management
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'astrolabe-user-settings';
|
||||
|
||||
// Default settings configuration
|
||||
const DEFAULT_SETTINGS = {
|
||||
version: 1,
|
||||
|
||||
editor: {
|
||||
fontSize: 12, // 10-18px
|
||||
theme: 'vs-light', // 'vs-light' | 'vs-dark' | 'hc-black'
|
||||
minimap: false, // true | false
|
||||
wordWrap: 'on', // 'on' | 'off'
|
||||
lineNumbers: 'on', // 'on' | 'off'
|
||||
tabSize: 2 // 2-8 spaces
|
||||
},
|
||||
|
||||
performance: {
|
||||
renderDebounce: 1500 // 300-3000ms - delay before visualization renders
|
||||
},
|
||||
|
||||
ui: {
|
||||
theme: 'light' // 'light' | 'experimental'
|
||||
},
|
||||
|
||||
formatting: {
|
||||
dateFormat: 'smart', // 'smart' | 'locale' | 'iso' | 'custom'
|
||||
customDateFormat: 'yyyy-MM-dd HH:mm' // Used when dateFormat === 'custom'
|
||||
}
|
||||
};
|
||||
|
||||
// Current user settings (loaded from localStorage or defaults)
|
||||
let userSettings = null;
|
||||
|
||||
// Initialize settings - load from localStorage or use defaults
|
||||
function initSettings() {
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
// Merge with defaults to handle version upgrades
|
||||
userSettings = mergeWithDefaults(parsed);
|
||||
} else {
|
||||
userSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading user settings:', error);
|
||||
userSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||
}
|
||||
return userSettings;
|
||||
}
|
||||
|
||||
// Merge stored settings with defaults (handles new settings in updates)
|
||||
function mergeWithDefaults(stored) {
|
||||
const merged = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||
|
||||
// Merge each section
|
||||
for (const section in stored) {
|
||||
if (merged[section] && typeof merged[section] === 'object') {
|
||||
Object.assign(merged[section], stored[section]);
|
||||
} else {
|
||||
merged[section] = stored[section];
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Save settings to localStorage
|
||||
function saveSettings() {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(userSettings));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error saving user settings:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all current settings
|
||||
function getSettings() {
|
||||
if (!userSettings) {
|
||||
initSettings();
|
||||
}
|
||||
return userSettings;
|
||||
}
|
||||
|
||||
// Get a specific setting by path (e.g., 'editor.fontSize')
|
||||
function getSetting(path) {
|
||||
const settings = getSettings();
|
||||
const parts = path.split('.');
|
||||
let value = settings;
|
||||
|
||||
for (const part of parts) {
|
||||
if (value && typeof value === 'object' && part in value) {
|
||||
value = value[part];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// Update a specific setting by path
|
||||
function updateSetting(path, value) {
|
||||
const settings = getSettings();
|
||||
const parts = path.split('.');
|
||||
let target = settings;
|
||||
|
||||
// Navigate to the parent object
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
if (!target[part] || typeof target[part] !== 'object') {
|
||||
target[part] = {};
|
||||
}
|
||||
target = target[part];
|
||||
}
|
||||
|
||||
// Set the value
|
||||
const lastPart = parts[parts.length - 1];
|
||||
target[lastPart] = value;
|
||||
|
||||
return saveSettings();
|
||||
}
|
||||
|
||||
// Update multiple settings at once
|
||||
function updateSettings(updates) {
|
||||
const settings = getSettings();
|
||||
|
||||
for (const path in updates) {
|
||||
const parts = path.split('.');
|
||||
let target = settings;
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
if (!target[part] || typeof target[part] !== 'object') {
|
||||
target[part] = {};
|
||||
}
|
||||
target = target[part];
|
||||
}
|
||||
|
||||
const lastPart = parts[parts.length - 1];
|
||||
target[lastPart] = updates[path];
|
||||
}
|
||||
|
||||
return saveSettings();
|
||||
}
|
||||
|
||||
// Reset all settings to defaults
|
||||
function resetSettings() {
|
||||
userSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||
return saveSettings();
|
||||
}
|
||||
|
||||
// Export settings as JSON
|
||||
function exportSettings() {
|
||||
return JSON.stringify(getSettings(), null, 2);
|
||||
}
|
||||
|
||||
// Import settings from JSON string
|
||||
function importSettings(jsonString) {
|
||||
try {
|
||||
const imported = JSON.parse(jsonString);
|
||||
userSettings = mergeWithDefaults(imported);
|
||||
return saveSettings();
|
||||
} catch (error) {
|
||||
console.error('Error importing settings:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Format date according to user settings
|
||||
function formatDate(isoString, useFullFormat = false) {
|
||||
const date = new Date(isoString);
|
||||
const format = getSetting('formatting.dateFormat');
|
||||
|
||||
if (!useFullFormat && format === 'smart') {
|
||||
// Smart format: relative for recent dates
|
||||
const diffMs = new Date() - date;
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
||||
if (diffDays === 0) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
if (format === 'locale') {
|
||||
return useFullFormat
|
||||
? date.toLocaleString([], {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
: date.toLocaleDateString();
|
||||
}
|
||||
|
||||
if (format === 'iso') {
|
||||
return useFullFormat
|
||||
? date.toISOString()
|
||||
: date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
if (format === 'custom') {
|
||||
const customFormat = getSetting('formatting.customDateFormat');
|
||||
return formatCustomDate(date, customFormat);
|
||||
}
|
||||
|
||||
// Fallback to locale
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// Format date using custom format tokens
|
||||
function formatCustomDate(date, format) {
|
||||
const pad = (n, width = 2) => String(n).padStart(width, '0');
|
||||
|
||||
const tokens = {
|
||||
'yyyy': date.getFullYear(),
|
||||
'yy': String(date.getFullYear()).slice(-2),
|
||||
'MM': pad(date.getMonth() + 1),
|
||||
'M': date.getMonth() + 1,
|
||||
'dd': pad(date.getDate()),
|
||||
'd': date.getDate(),
|
||||
'HH': pad(date.getHours()),
|
||||
'H': date.getHours(),
|
||||
'hh': pad(date.getHours() % 12 || 12),
|
||||
'h': date.getHours() % 12 || 12,
|
||||
'mm': pad(date.getMinutes()),
|
||||
'm': date.getMinutes(),
|
||||
'ss': pad(date.getSeconds()),
|
||||
's': date.getSeconds(),
|
||||
'a': date.getHours() < 12 ? 'am' : 'pm',
|
||||
'A': date.getHours() < 12 ? 'AM' : 'PM'
|
||||
};
|
||||
|
||||
let result = format;
|
||||
// Sort by length descending to replace longer tokens first
|
||||
const sortedTokens = Object.keys(tokens).sort((a, b) => b.length - a.length);
|
||||
for (const token of sortedTokens) {
|
||||
result = result.replace(new RegExp(token, 'g'), tokens[token]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Validate setting value
|
||||
function validateSetting(path, value) {
|
||||
const errors = [];
|
||||
|
||||
if (path === 'editor.fontSize') {
|
||||
if (typeof value !== 'number' || value < 10 || value > 18) {
|
||||
errors.push('Font size must be between 10 and 18');
|
||||
}
|
||||
}
|
||||
|
||||
if (path === 'editor.theme') {
|
||||
const validThemes = ['vs-light', 'vs-dark', 'hc-black'];
|
||||
if (!validThemes.includes(value)) {
|
||||
errors.push('Invalid theme value');
|
||||
}
|
||||
}
|
||||
|
||||
if (path === 'editor.tabSize') {
|
||||
if (typeof value !== 'number' || value < 2 || value > 8) {
|
||||
errors.push('Tab size must be between 2 and 8');
|
||||
}
|
||||
}
|
||||
|
||||
if (path === 'performance.renderDebounce') {
|
||||
if (typeof value !== 'number' || value < 300 || value > 3000) {
|
||||
errors.push('Render debounce must be between 300 and 3000ms');
|
||||
}
|
||||
}
|
||||
|
||||
if (path === 'formatting.dateFormat') {
|
||||
const validFormats = ['smart', 'locale', 'iso', 'custom'];
|
||||
if (!validFormats.includes(value)) {
|
||||
errors.push('Invalid date format value');
|
||||
}
|
||||
}
|
||||
|
||||
if (path === 'ui.theme') {
|
||||
const validThemes = ['light', 'experimental'];
|
||||
if (!validThemes.includes(value)) {
|
||||
errors.push('Invalid theme value');
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
Reference in New Issue
Block a user