/** * SceneUI - Manages the UI for multi-shape scenes * * Creates collapsible panels for each shape with: * - Layer controls (visibility, pause, reorder, remove) * - Shape-specific controls * - Add new shape interface */ class SceneUI { constructor(scene, containerId) { this.scene = scene; this.container = document.getElementById(containerId); this.layerPanels = new Map(); // layerId -> { panel, controlManager } this.createSceneControls(); } /** * Create the main scene control area (add shape button, etc.) */ createSceneControls() { // Scene header const header = document.createElement('div'); header.className = 'scene-header'; header.innerHTML = `

Scene Layers

`; this.container.insertBefore(header, this.container.firstChild); // Shape picker modal (hidden by default) this.createShapePicker(); // Presets modal this.createPresetsModal(); // Hidden file input for loading const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.id = 'scene-file-input'; fileInput.accept = '.json'; fileInput.style.display = 'none'; document.body.appendChild(fileInput); // Add shape button handler document.getElementById('add-shape-btn').addEventListener('click', () => { this.showShapePicker(); }); // Save button handler document.getElementById('save-scene-btn').addEventListener('click', () => { this.saveScene(); }); // Load button handler document.getElementById('load-scene-btn').addEventListener('click', () => { document.getElementById('scene-file-input').click(); }); fileInput.addEventListener('change', (e) => { if (e.target.files.length > 0) { this.loadScene(e.target.files[0]); e.target.value = ''; // Reset for same file } }); // Presets button handler document.getElementById('presets-btn').addEventListener('click', () => { this.showPresetsModal(); }); } /** * Save current scene to file */ saveScene() { const data = this.scene.exportToJSON(); const json = JSON.stringify(data, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `scene-${Date.now()}.json`; a.click(); URL.revokeObjectURL(url); } /** * Load scene from file */ loadScene(file) { const reader = new FileReader(); reader.onload = (e) => { try { const data = JSON.parse(e.target.result); this.scene.importFromJSON(data, this); } catch (err) { alert('Failed to load scene: ' + err.message); } }; reader.readAsText(file); } /** * Create presets modal */ createPresetsModal() { const modal = document.createElement('div'); modal.className = 'presets-modal'; modal.id = 'presets-modal'; modal.style.display = 'none'; modal.innerHTML = `

Presets

No presets saved yet

`; document.body.appendChild(modal); // Load and display presets this.refreshPresetsList(); // Event handlers modal.querySelector('.presets-cancel').addEventListener('click', () => { this.hidePresetsModal(); }); modal.querySelector('.preset-save-current-btn').addEventListener('click', () => { this.saveCurrentAsPreset(); }); // Click outside to close modal.addEventListener('click', (e) => { if (e.target === modal) { this.hidePresetsModal(); } }); } showPresetsModal() { this.refreshPresetsList(); document.getElementById('presets-modal').style.display = 'flex'; } hidePresetsModal() { document.getElementById('presets-modal').style.display = 'none'; } /** * Get user presets from localStorage */ getPresets() { try { return JSON.parse(localStorage.getItem('animation-presets') || '[]'); } catch { return []; } } /** * Get built-in presets */ getBuiltInPresets() { return typeof BUILT_IN_PRESETS !== 'undefined' ? BUILT_IN_PRESETS : []; } /** * Save presets to localStorage */ savePresets(presets) { localStorage.setItem('animation-presets', JSON.stringify(presets)); } /** * Save current scene as a preset */ saveCurrentAsPreset() { const name = prompt('Enter preset name:'); if (!name) return; const presets = this.getPresets(); const data = this.scene.exportToJSON(); presets.push({ name: name, date: new Date().toISOString(), data: data }); this.savePresets(presets); this.refreshPresetsList(); } /** * Load a preset (built-in or user) */ loadPreset(index, isBuiltIn = false) { const presets = isBuiltIn ? this.getBuiltInPresets() : this.getPresets(); if (index >= 0 && index < presets.length) { this.scene.importFromJSON(presets[index].data, this); this.hidePresetsModal(); } } /** * Delete a user preset by index */ deletePreset(index) { const presets = this.getPresets(); if (index >= 0 && index < presets.length) { if (confirm(`Delete preset "${presets[index].name}"?`)) { presets.splice(index, 1); this.savePresets(presets); this.refreshPresetsList(); } } } /** * Refresh the presets list UI */ refreshPresetsList() { const container = document.getElementById('presets-list'); if (!container) return; const builtInPresets = this.getBuiltInPresets(); const userPresets = this.getPresets(); let html = ''; // Built-in presets section if (builtInPresets.length > 0) { html += '
Built-in Presets
'; html += builtInPresets.map((preset, index) => `
${preset.name} ${preset.description || `${preset.data.layers.length} layer(s)`}
`).join(''); } // User presets section html += '
My Presets
'; if (userPresets.length === 0) { html += '

No saved presets yet

'; } else { html += userPresets.map((preset, index) => `
${preset.name} ${preset.data.layers.length} layer(s)
`).join(''); } container.innerHTML = html; // Add event listeners container.querySelectorAll('.preset-load-btn').forEach(btn => { btn.addEventListener('click', () => { const isBuiltIn = btn.dataset.builtin === 'true'; this.loadPreset(parseInt(btn.dataset.index), isBuiltIn); }); }); container.querySelectorAll('.preset-delete-btn').forEach(btn => { btn.addEventListener('click', () => { this.deletePreset(parseInt(btn.dataset.index)); }); }); } /** * Create the shape picker modal with tabs */ createShapePicker() { const modal = document.createElement('div'); modal.className = 'shape-picker-modal'; modal.id = 'shape-picker-modal'; modal.style.display = 'none'; const shapes = shapeRegistry.getAvailableNames(); const shapesHtml = shapes.map(name => `` ).join(''); const builtInPresets = this.getBuiltInPresets(); const builtInHtml = builtInPresets.map((preset, i) => `` ).join(''); modal.innerHTML = `

Add Layer

${shapesHtml}
Built-in
${builtInHtml}
My Presets

No saved presets

`; document.body.appendChild(modal); // Tab switching modal.querySelectorAll('.shape-picker-tab').forEach(tab => { tab.addEventListener('click', () => { modal.querySelectorAll('.shape-picker-tab').forEach(t => t.classList.remove('active')); modal.querySelectorAll('.shape-picker-panel').forEach(p => p.classList.remove('active')); tab.classList.add('active'); document.getElementById(`panel-${tab.dataset.tab}`).classList.add('active'); }); }); // Cancel button modal.querySelector('.shape-picker-cancel').addEventListener('click', () => { this.hideShapePicker(); }); // Shape buttons modal.querySelectorAll('.shape-picker-btn[data-shape]').forEach(btn => { btn.addEventListener('click', () => { this.addShapeToScene(btn.dataset.shape); this.hideShapePicker(); }); }); // Preset buttons (add as layers, don't replace) modal.querySelectorAll('.preset-btn[data-builtin="true"]').forEach(btn => { btn.addEventListener('click', () => { this.addPresetAsLayers(parseInt(btn.dataset.presetIndex), true); this.hideShapePicker(); }); }); // Click outside to close modal.addEventListener('click', (e) => { if (e.target === modal) { this.hideShapePicker(); } }); } /** * Refresh user presets in the picker */ refreshPickerUserPresets() { const container = document.getElementById('picker-user-presets'); if (!container) return; const userPresets = this.getPresets(); if (userPresets.length === 0) { container.innerHTML = '

No saved presets

'; return; } container.innerHTML = userPresets.map((preset, i) => `` ).join(''); container.querySelectorAll('.preset-btn').forEach(btn => { btn.addEventListener('click', () => { this.addPresetAsLayers(parseInt(btn.dataset.presetIndex), false); this.hideShapePicker(); }); }); } /** * Add preset layers to scene (without clearing existing) */ addPresetAsLayers(index, isBuiltIn) { const presets = isBuiltIn ? this.getBuiltInPresets() : this.getPresets(); if (index < 0 || index >= presets.length) return; const preset = presets[index]; for (const layerData of preset.data.layers) { try { if (!shapeRegistry.get(layerData.name)) { console.warn(`Shape "${layerData.name}" not found, skipping`); continue; } const layerId = this.scene.addShape(layerData.name, layerData.values); const layer = this.scene.getLayer(layerId); if (!layer || !layer.shape) continue; layer.visible = layerData.visible !== false; layer.paused = layerData.paused || false; layer.collapsed = layerData.collapsed !== false; // Create panel with filters and bounds this.createLayerPanel(layer, layerData.filters, layerData.controlBounds); } catch (e) { console.warn(`Failed to add layer "${layerData.name}":`, e.message); } } } showShapePicker() { this.refreshPickerUserPresets(); document.getElementById('shape-picker-modal').style.display = 'flex'; } hideShapePicker() { document.getElementById('shape-picker-modal').style.display = 'none'; } /** * Add a shape to the scene and create its UI panel * @param {string} shapeName */ addShapeToScene(shapeName) { const layerId = this.scene.addShape(shapeName); const layer = this.scene.getLayer(layerId); // Create the layer panel UI first (it will contain the controls) this.createLayerPanel(layer); } /** * Create a collapsible panel for a layer * @param {object|number} layerOrId - Layer object or layer ID * @param {object} savedFilters - Optional saved filter settings to restore * @param {object} savedBounds - Optional saved control bounds to restore */ createLayerPanel(layerOrId, savedFilters = null, savedBounds = null) { // Handle both layer object and layer ID const layer = typeof layerOrId === 'number' ? this.scene.getLayer(layerOrId) : layerOrId; if (!layer || !layer.shape) { console.warn('Cannot create panel: layer or shape is undefined'); return; } const panel = document.createElement('div'); panel.className = 'layer-panel'; panel.id = `layer-panel-${layer.id}`; // Layer header with controls const header = document.createElement('div'); header.className = 'layer-header'; header.innerHTML = ` ${layer.name}
`; // Layer content (shape controls container) const content = document.createElement('div'); content.className = 'layer-content'; content.id = `layer-content-${layer.id}`; panel.appendChild(header); panel.appendChild(content); // Add to layers container const shapesContainer = document.getElementById('layers-container'); if (shapesContainer) { shapesContainer.appendChild(panel); } else { this.container.appendChild(panel); } // Create a control manager specifically for this layer's content const layerControlManager = new ControlManager(content.id); // Initialize the shape's controls into the layer content layer.shape.initializeControls(layerControlManager); // Restore saved control bounds if provided if (savedBounds) { this.applyBoundsToShape(layer.shape, savedBounds); } // Restore saved filters if provided if (savedFilters) { this.applyFiltersToShape(layer.shape, layerControlManager, savedFilters); } // Store both panel and control manager this.layerPanels.set(layer.id, { panel, controlManager: layerControlManager }); // Attach event handlers this.attachLayerHandlers(layer.id, panel); // Apply collapsed state if (layer.collapsed) { this.updateLayerUI(layer.id); } } /** * Apply saved filters to a shape's controls */ applyFiltersToShape(shape, controlManager, savedFilters) { if (!savedFilters || !shape.controls) return; for (const control of shape.controls) { // Property is stored in control.config.property const property = control.config?.property; if (!property || !savedFilters[property]) continue; const filterDataArray = savedFilters[property]; const controlConfig = control.config || {}; for (const filterData of filterDataArray) { // Create filter instance with saved params const filterInstance = { type: filterData.type, params: { ...filterData.params } }; const filterDef = controlManager.filterManager.getFilter(filterData.type); if (!filterDef) continue; // Create filter UI const filterUI = controlManager.factory.createFilterUI( filterInstance, filterDef, control, controlConfig ); if (control.filtersContainer) { control.filtersContainer.appendChild(filterUI); } if (!control.filters) { control.filters = []; } control.filters.push(filterInstance); // Update filter badge controlManager.factory.updateFilterBadge(control); } } } /** * Apply saved control bounds to a shape's controls */ applyBoundsToShape(shape, savedBounds) { if (!savedBounds || !shape.controls) return; for (const control of shape.controls) { const property = control.config?.property; if (!property || !savedBounds[property]) continue; const bounds = savedBounds[property]; // Update configState if (control.configState) { control.configState.min = bounds.min; control.configState.max = bounds.max; } // Update the input element's min/max if (control.element) { control.element.min = bounds.min; control.element.max = bounds.max; } // Update the settings panel inputs if they exist if (control.settingsPanel) { const inputs = control.settingsPanel.querySelectorAll('.setting-input'); if (inputs[0]) inputs[0].value = bounds.min; if (inputs[1]) inputs[1].value = bounds.max; } } } /** * Attach event handlers for layer controls */ attachLayerHandlers(layerId, panel) { // Collapse toggle panel.querySelector('.layer-collapse-btn').addEventListener('click', () => { this.toggleCollapse(layerId); }); // Visibility toggle panel.querySelector('.layer-visibility').addEventListener('click', () => { this.scene.toggleVisibility(layerId); this.updateLayerUI(layerId); }); // Pause toggle panel.querySelector('.layer-pause').addEventListener('click', () => { this.scene.togglePause(layerId); this.updateLayerUI(layerId); }); // Move up panel.querySelector('.layer-up').addEventListener('click', () => { this.scene.moveUp(layerId); this.reorderPanels(); }); // Move down panel.querySelector('.layer-down').addEventListener('click', () => { this.scene.moveDown(layerId); this.reorderPanels(); }); // Remove panel.querySelector('.layer-remove').addEventListener('click', () => { this.removeLayer(layerId); }); } /** * Toggle collapse state for a layer panel */ toggleCollapse(layerId) { const layer = this.scene.getLayer(layerId); if (!layer) return; layer.collapsed = !layer.collapsed; this.updateLayerUI(layerId); } /** * Update layer UI to reflect current state */ updateLayerUI(layerId) { const layer = this.scene.getLayer(layerId); const layerData = this.layerPanels.get(layerId); if (!layer || !layerData) return; const panel = layerData.panel; // Update collapse const collapseBtn = panel.querySelector('.layer-collapse-btn'); const content = panel.querySelector('.layer-content'); if (layer.collapsed) { collapseBtn.textContent = '▶'; content.style.display = 'none'; } else { collapseBtn.textContent = '▼'; content.style.display = 'block'; } // Update visibility button const visBtn = panel.querySelector('.layer-visibility'); visBtn.style.opacity = layer.visible ? '1' : '0.4'; // Update pause button const pauseBtn = panel.querySelector('.layer-pause'); pauseBtn.textContent = layer.paused ? '▶' : '⏸'; } /** * Reorder panels to match scene layer order */ reorderPanels() { const container = document.getElementById('layers-container') || this.container; for (const layer of this.scene.getLayers()) { const layerData = this.layerPanels.get(layer.id); if (layerData && layerData.panel) { container.appendChild(layerData.panel); // Moves to end, in order } } } /** * Remove a layer and its panel */ removeLayer(layerId) { const layerData = this.layerPanels.get(layerId); // Clean up control manager if (layerData && layerData.controlManager) { layerData.controlManager.clearAll(); } // Remove from scene this.scene.removeShape(layerId); // Remove panel from DOM if (layerData && layerData.panel && layerData.panel.parentNode) { layerData.panel.parentNode.removeChild(layerData.panel); } this.layerPanels.delete(layerId); } /** * Clear all layer panels (for import) */ clearAllPanels() { for (const [layerId, layerData] of this.layerPanels) { if (layerData.controlManager) { layerData.controlManager.clearAll(); } if (layerData.panel && layerData.panel.parentNode) { layerData.panel.parentNode.removeChild(layerData.panel); } } this.layerPanels.clear(); } /** * Clear all layer panels and scene */ clearAll() { this.clearAllPanels(); this.scene.clear(); } }