mirror of
https://github.com/SamEyeBam/animate.git
synced 2026-02-04 09:20:25 +00:00
V1.1
Giant refactor. added layers. ui overhaul. added save/load and we now got presets
This commit is contained in:
667
docs/js/controls/ControlFactory.js
Normal file
667
docs/js/controls/ControlFactory.js
Normal file
@@ -0,0 +1,667 @@
|
||||
/**
|
||||
* ControlFactory - Creates DOM elements for different control types
|
||||
*
|
||||
* Separated from ControlManager for cleaner code organization
|
||||
* Features collapsible settings panels and inline filter configuration
|
||||
*/
|
||||
class ControlFactory {
|
||||
constructor(controlManager) {
|
||||
this.controlManager = controlManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a control based on type
|
||||
* @param {object} config - Control configuration
|
||||
* @param {object} instance - Shape instance to bind to
|
||||
* @param {HTMLElement} container - Container element
|
||||
* @returns {object} Control data { element, listener, type, config }
|
||||
*/
|
||||
create(config, instance, container) {
|
||||
switch (config.type) {
|
||||
case 'range':
|
||||
return this.createRange(config, instance, container);
|
||||
case 'color':
|
||||
return this.createColor(config, instance, container);
|
||||
case 'checkbox':
|
||||
return this.createCheckbox(config, instance, container);
|
||||
case 'dropdown':
|
||||
return this.createDropdown(config, instance, container);
|
||||
case 'button':
|
||||
return this.createButton(config, instance, container);
|
||||
case 'header':
|
||||
return this.createHeader(config, container);
|
||||
default:
|
||||
console.warn(`Unknown control type: ${config.type}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a collapsible section with a toggle arrow
|
||||
*/
|
||||
createCollapsible(title, className, defaultOpen = false) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = `collapsible-section ${className}`;
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'collapsible-header';
|
||||
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'collapsible-arrow';
|
||||
arrow.textContent = defaultOpen ? '▼' : '▶';
|
||||
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.className = 'collapsible-title';
|
||||
titleSpan.textContent = title;
|
||||
|
||||
header.appendChild(arrow);
|
||||
header.appendChild(titleSpan);
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'collapsible-content';
|
||||
content.style.display = defaultOpen ? 'block' : 'none';
|
||||
|
||||
header.addEventListener('click', () => {
|
||||
const isOpen = content.style.display !== 'none';
|
||||
content.style.display = isOpen ? 'none' : 'block';
|
||||
arrow.textContent = isOpen ? '▶' : '▼';
|
||||
});
|
||||
|
||||
wrapper.appendChild(header);
|
||||
wrapper.appendChild(content);
|
||||
|
||||
return { wrapper, content, header, arrow };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a range slider control with collapsible settings and filters
|
||||
*/
|
||||
createRange(config, instance, container) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'control-container control-range-container';
|
||||
|
||||
// Main row: label + value + slider
|
||||
const mainRow = document.createElement('div');
|
||||
mainRow.className = 'control-main-row';
|
||||
|
||||
// Label showing property name
|
||||
const label = document.createElement('span');
|
||||
label.className = 'control-label-inline';
|
||||
label.textContent = config.property;
|
||||
|
||||
// Value display (editable)
|
||||
const valueDisplay = document.createElement('span');
|
||||
valueDisplay.className = 'control-value';
|
||||
valueDisplay.id = `elText${config.property}`;
|
||||
valueDisplay.textContent = instance[config.property] ?? config.defaultValue;
|
||||
|
||||
// Range input
|
||||
const input = document.createElement('input');
|
||||
input.type = 'range';
|
||||
input.className = 'control control-range';
|
||||
input.id = `el${config.property}`;
|
||||
input.min = config.min;
|
||||
input.max = config.max;
|
||||
input.value = instance[config.property] ?? config.defaultValue;
|
||||
|
||||
// Store original config bounds for reference
|
||||
const configState = {
|
||||
min: config.min,
|
||||
max: config.max,
|
||||
originalMin: config.min,
|
||||
originalMax: config.max
|
||||
};
|
||||
|
||||
// Event listener
|
||||
const listener = (event) => {
|
||||
const newValue = parseFloat(event.target.value);
|
||||
instance[config.property] = newValue;
|
||||
valueDisplay.textContent = Math.round(newValue * 100) / 100;
|
||||
if (config.callback) {
|
||||
config.callback(instance, newValue);
|
||||
}
|
||||
};
|
||||
input.addEventListener('input', listener);
|
||||
|
||||
mainRow.appendChild(label);
|
||||
mainRow.appendChild(valueDisplay);
|
||||
wrapper.appendChild(mainRow);
|
||||
wrapper.appendChild(input);
|
||||
|
||||
// Options row with toggle buttons
|
||||
const optionsRow = document.createElement('div');
|
||||
optionsRow.className = 'control-options-row';
|
||||
|
||||
// Settings toggle button
|
||||
const settingsBtn = document.createElement('button');
|
||||
settingsBtn.className = 'control-toggle-btn';
|
||||
settingsBtn.innerHTML = '⚙';
|
||||
settingsBtn.title = 'Settings';
|
||||
|
||||
// Filters toggle button
|
||||
const filtersBtn = document.createElement('button');
|
||||
filtersBtn.className = 'control-toggle-btn';
|
||||
filtersBtn.innerHTML = '◇';
|
||||
filtersBtn.title = 'Filters';
|
||||
|
||||
optionsRow.appendChild(settingsBtn);
|
||||
optionsRow.appendChild(filtersBtn);
|
||||
wrapper.appendChild(optionsRow);
|
||||
|
||||
// Settings panel (hidden by default)
|
||||
const settingsPanel = document.createElement('div');
|
||||
settingsPanel.className = 'control-settings-panel';
|
||||
settingsPanel.style.display = 'none';
|
||||
|
||||
// Min setting
|
||||
const minRow = this.createSettingRow('Min', configState.min, (val) => {
|
||||
configState.min = val;
|
||||
input.min = val;
|
||||
});
|
||||
|
||||
// Max setting
|
||||
const maxRow = this.createSettingRow('Max', configState.max, (val) => {
|
||||
configState.max = val;
|
||||
input.max = val;
|
||||
});
|
||||
|
||||
settingsPanel.appendChild(minRow);
|
||||
settingsPanel.appendChild(maxRow);
|
||||
wrapper.appendChild(settingsPanel);
|
||||
|
||||
// Settings toggle
|
||||
settingsBtn.addEventListener('click', () => {
|
||||
const isOpen = settingsPanel.style.display !== 'none';
|
||||
settingsPanel.style.display = isOpen ? 'none' : 'block';
|
||||
settingsBtn.classList.toggle('active', !isOpen);
|
||||
});
|
||||
|
||||
// Filters panel (hidden by default)
|
||||
const filtersPanel = document.createElement('div');
|
||||
filtersPanel.className = 'control-filters-panel';
|
||||
filtersPanel.style.display = 'none';
|
||||
|
||||
// Add filter dropdown
|
||||
const addFilterRow = document.createElement('div');
|
||||
addFilterRow.className = 'add-filter-row';
|
||||
|
||||
const filterSelect = document.createElement('select');
|
||||
filterSelect.className = 'filter-type-select';
|
||||
filterSelect.innerHTML = '<option value="">+ Add Filter...</option>';
|
||||
|
||||
const filters = this.controlManager.filterManager.getAvailableFilters();
|
||||
for (const filter of filters) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = filter.id;
|
||||
opt.textContent = filter.name;
|
||||
filterSelect.appendChild(opt);
|
||||
}
|
||||
|
||||
addFilterRow.appendChild(filterSelect);
|
||||
filtersPanel.appendChild(addFilterRow);
|
||||
|
||||
// Container for active filters
|
||||
const filtersContainer = document.createElement('div');
|
||||
filtersContainer.className = 'filters-list';
|
||||
filtersPanel.appendChild(filtersContainer);
|
||||
|
||||
wrapper.appendChild(filtersPanel);
|
||||
|
||||
// Filters toggle
|
||||
filtersBtn.addEventListener('click', () => {
|
||||
const isOpen = filtersPanel.style.display !== 'none';
|
||||
filtersPanel.style.display = isOpen ? 'none' : 'block';
|
||||
filtersBtn.classList.toggle('active', !isOpen);
|
||||
});
|
||||
|
||||
container.appendChild(wrapper);
|
||||
|
||||
const controlData = {
|
||||
element: input,
|
||||
listener,
|
||||
type: 'range',
|
||||
config,
|
||||
configState,
|
||||
wrapper,
|
||||
filtersContainer,
|
||||
filters: [],
|
||||
label,
|
||||
valueDisplay,
|
||||
settingsPanel,
|
||||
filtersPanel,
|
||||
filtersBtn
|
||||
};
|
||||
|
||||
// Filter dropdown handler
|
||||
filterSelect.addEventListener('change', () => {
|
||||
if (filterSelect.value) {
|
||||
this.controlManager.addFilter(controlData, config, filterSelect.value);
|
||||
filterSelect.value = ''; // Reset dropdown
|
||||
// Update filter button to show filter count
|
||||
this.updateFilterBadge(controlData);
|
||||
}
|
||||
});
|
||||
|
||||
return controlData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a setting row with label and number input
|
||||
*/
|
||||
createSettingRow(label, value, onChange) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'setting-row';
|
||||
|
||||
const labelEl = document.createElement('span');
|
||||
labelEl.className = 'setting-label';
|
||||
labelEl.textContent = label;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = 'setting-input';
|
||||
input.value = value;
|
||||
|
||||
input.addEventListener('change', (e) => {
|
||||
onChange(parseFloat(e.target.value));
|
||||
});
|
||||
|
||||
row.appendChild(labelEl);
|
||||
row.appendChild(input);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update filter badge on button
|
||||
*/
|
||||
updateFilterBadge(controlData) {
|
||||
const count = controlData.filters.length;
|
||||
if (count > 0) {
|
||||
controlData.filtersBtn.innerHTML = `◆ ${count}`;
|
||||
controlData.filtersBtn.classList.add('has-filters');
|
||||
} else {
|
||||
controlData.filtersBtn.innerHTML = '◇';
|
||||
controlData.filtersBtn.classList.remove('has-filters');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a color picker control
|
||||
*/
|
||||
createColor(config, instance, container) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'control-container control-color-container';
|
||||
|
||||
const mainRow = document.createElement('div');
|
||||
mainRow.className = 'control-main-row';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'control-label-inline';
|
||||
label.textContent = config.property;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'color';
|
||||
input.className = 'control control-color';
|
||||
input.id = `el${config.property}`;
|
||||
input.value = instance[config.property] ?? config.defaultValue;
|
||||
|
||||
const listener = (event) => {
|
||||
const newValue = event.target.value;
|
||||
instance[config.property] = newValue;
|
||||
if (config.callback) {
|
||||
config.callback(instance, newValue);
|
||||
}
|
||||
};
|
||||
input.addEventListener('input', listener);
|
||||
|
||||
mainRow.appendChild(label);
|
||||
mainRow.appendChild(input);
|
||||
wrapper.appendChild(mainRow);
|
||||
container.appendChild(wrapper);
|
||||
|
||||
return {
|
||||
element: input,
|
||||
listener,
|
||||
type: 'color',
|
||||
config,
|
||||
wrapper,
|
||||
label
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a checkbox control
|
||||
*/
|
||||
createCheckbox(config, instance, container) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'control-container control-checkbox-container';
|
||||
|
||||
const mainRow = document.createElement('div');
|
||||
mainRow.className = 'control-main-row';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'control-label-inline';
|
||||
label.textContent = config.property;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.className = 'control control-checkbox';
|
||||
input.id = `el${config.property}`;
|
||||
input.checked = instance[config.property] ?? config.defaultValue;
|
||||
|
||||
const listener = (event) => {
|
||||
const newValue = event.target.checked;
|
||||
instance[config.property] = newValue;
|
||||
if (config.callback) {
|
||||
config.callback(instance, newValue);
|
||||
}
|
||||
};
|
||||
input.addEventListener('change', listener);
|
||||
|
||||
mainRow.appendChild(label);
|
||||
mainRow.appendChild(input);
|
||||
wrapper.appendChild(mainRow);
|
||||
container.appendChild(wrapper);
|
||||
|
||||
return {
|
||||
element: input,
|
||||
listener,
|
||||
type: 'checkbox',
|
||||
config,
|
||||
wrapper,
|
||||
label
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dropdown select control
|
||||
*/
|
||||
createDropdown(config, instance, container) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'control-container';
|
||||
|
||||
const label = document.createElement('p');
|
||||
label.className = 'control-label';
|
||||
label.id = `elText${config.property}`;
|
||||
label.textContent = `${config.property}: ${config.defaultValue}`;
|
||||
|
||||
const select = document.createElement('select');
|
||||
select.className = 'control control-dropdown';
|
||||
select.id = `el${config.property}`;
|
||||
|
||||
for (const option of config.options || []) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = option.value;
|
||||
opt.textContent = option.label;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
select.value = instance[config.property] ?? config.defaultValue;
|
||||
|
||||
const listener = (event) => {
|
||||
const newValue = event.target.value;
|
||||
instance[config.property] = newValue;
|
||||
label.textContent = `${config.property}: ${newValue}`;
|
||||
if (config.callback) {
|
||||
config.callback(instance, newValue);
|
||||
}
|
||||
};
|
||||
select.addEventListener('change', listener);
|
||||
|
||||
wrapper.appendChild(label);
|
||||
wrapper.appendChild(select);
|
||||
container.appendChild(wrapper);
|
||||
|
||||
return {
|
||||
element: select,
|
||||
listener,
|
||||
type: 'dropdown',
|
||||
config,
|
||||
wrapper,
|
||||
label
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a button control
|
||||
*/
|
||||
createButton(config, instance, container) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'control-container';
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.className = 'control control-button button-8';
|
||||
button.textContent = config.label || config.property;
|
||||
|
||||
const listener = () => {
|
||||
if (config.method && typeof instance[config.method] === 'function') {
|
||||
instance[config.method]();
|
||||
}
|
||||
if (config.callback) {
|
||||
config.callback(instance);
|
||||
}
|
||||
};
|
||||
button.addEventListener('click', listener);
|
||||
|
||||
wrapper.appendChild(button);
|
||||
container.appendChild(wrapper);
|
||||
|
||||
return {
|
||||
element: button,
|
||||
listener,
|
||||
type: 'button',
|
||||
config,
|
||||
wrapper
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a header/separator
|
||||
*/
|
||||
createHeader(config, container) {
|
||||
const header = document.createElement('p');
|
||||
header.className = 'header';
|
||||
header.id = `elHeader${(config.text || '').replace(/\s+/g, '')}`;
|
||||
header.textContent = config.text || '';
|
||||
container.appendChild(header);
|
||||
|
||||
return {
|
||||
element: header,
|
||||
listener: null,
|
||||
type: 'header',
|
||||
config,
|
||||
wrapper: header
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create filter controls UI with collapsible settings
|
||||
*/
|
||||
createFilterUI(filterInstance, filterDef, controlData, controlConfig) {
|
||||
const filterDiv = document.createElement('div');
|
||||
filterDiv.className = 'filter-item';
|
||||
|
||||
// Header row with type dropdown and remove button
|
||||
const headerRow = document.createElement('div');
|
||||
headerRow.className = 'filter-item-header';
|
||||
|
||||
// Filter type dropdown (allows changing filter type)
|
||||
const typeSelect = document.createElement('select');
|
||||
typeSelect.className = 'filter-type-dropdown';
|
||||
|
||||
const filters = this.controlManager.filterManager.getAvailableFilters();
|
||||
for (const filter of filters) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = filter.id;
|
||||
opt.textContent = filter.name;
|
||||
if (filter.id === filterInstance.type) {
|
||||
opt.selected = true;
|
||||
}
|
||||
typeSelect.appendChild(opt);
|
||||
}
|
||||
|
||||
// Settings toggle button
|
||||
const settingsToggle = document.createElement('button');
|
||||
settingsToggle.className = 'filter-settings-toggle';
|
||||
settingsToggle.innerHTML = '⚙';
|
||||
settingsToggle.title = 'Filter settings';
|
||||
|
||||
// Remove button
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'filter-remove-btn';
|
||||
removeBtn.textContent = '×';
|
||||
removeBtn.title = 'Remove filter';
|
||||
|
||||
headerRow.appendChild(typeSelect);
|
||||
headerRow.appendChild(settingsToggle);
|
||||
headerRow.appendChild(removeBtn);
|
||||
filterDiv.appendChild(headerRow);
|
||||
|
||||
// Settings panel (collapsible)
|
||||
const settingsPanel = document.createElement('div');
|
||||
settingsPanel.className = 'filter-settings-panel';
|
||||
settingsPanel.style.display = 'none';
|
||||
|
||||
// Create controls for filter parameters
|
||||
const paramInputs = {};
|
||||
this.buildFilterParams(settingsPanel, filterInstance, filterDef, controlConfig, paramInputs);
|
||||
|
||||
filterDiv.appendChild(settingsPanel);
|
||||
|
||||
// Toggle settings
|
||||
settingsToggle.addEventListener('click', () => {
|
||||
const isOpen = settingsPanel.style.display !== 'none';
|
||||
settingsPanel.style.display = isOpen ? 'none' : 'block';
|
||||
settingsToggle.classList.toggle('active', !isOpen);
|
||||
});
|
||||
|
||||
// Handle filter type change
|
||||
typeSelect.addEventListener('change', () => {
|
||||
const newType = typeSelect.value;
|
||||
const newFilterDef = this.controlManager.filterManager.getFilter(newType);
|
||||
|
||||
// Update filter instance type
|
||||
filterInstance.type = newType;
|
||||
|
||||
// Reset params to defaults for new type
|
||||
filterInstance.params = {};
|
||||
for (const ctrl of newFilterDef.controls) {
|
||||
if (ctrl.name === 'min') {
|
||||
filterInstance.params.min = controlConfig.min ?? 0;
|
||||
} else if (ctrl.name === 'max') {
|
||||
filterInstance.params.max = controlConfig.max ?? 100;
|
||||
} else {
|
||||
filterInstance.params[ctrl.name] = ctrl.defaultValue ?? 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild params UI
|
||||
settingsPanel.innerHTML = '';
|
||||
this.buildFilterParams(settingsPanel, filterInstance, newFilterDef, controlConfig, paramInputs);
|
||||
});
|
||||
|
||||
// Remove handler
|
||||
removeBtn.addEventListener('click', () => {
|
||||
this.controlManager.removeFilter(controlData, filterInstance);
|
||||
this.updateFilterBadge(controlData);
|
||||
});
|
||||
|
||||
filterInstance.element = filterDiv;
|
||||
return filterDiv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build filter parameter controls
|
||||
*/
|
||||
buildFilterParams(container, filterInstance, filterDef, controlConfig, paramInputs) {
|
||||
for (const paramDef of filterDef.controls) {
|
||||
const paramRow = document.createElement('div');
|
||||
paramRow.className = 'filter-param-row';
|
||||
|
||||
const paramLabel = document.createElement('span');
|
||||
paramLabel.className = 'filter-param-label';
|
||||
paramLabel.textContent = paramDef.label;
|
||||
|
||||
const paramValue = document.createElement('span');
|
||||
paramValue.className = 'filter-param-value';
|
||||
paramValue.textContent = filterInstance.params[paramDef.name];
|
||||
|
||||
const paramInput = document.createElement('input');
|
||||
paramInput.type = 'range';
|
||||
paramInput.className = 'filter-param-slider';
|
||||
|
||||
// Determine bounds
|
||||
if (paramDef.name === 'min' || paramDef.name === 'max') {
|
||||
// Use much wider bounds for filter min/max - can go beyond control range
|
||||
paramInput.min = (controlConfig.min ?? 0) - Math.abs(controlConfig.max - controlConfig.min);
|
||||
paramInput.max = (controlConfig.max ?? 100) + Math.abs(controlConfig.max - controlConfig.min);
|
||||
paramInput.value = filterInstance.params[paramDef.name];
|
||||
} else {
|
||||
paramInput.min = paramDef.min ?? 0.1;
|
||||
paramInput.max = paramDef.max ?? 10;
|
||||
paramInput.step = paramDef.step ?? 0.1;
|
||||
paramInput.value = filterInstance.params[paramDef.name];
|
||||
}
|
||||
|
||||
// Settings button for this parameter (to adjust its min/max)
|
||||
const paramSettingsBtn = document.createElement('button');
|
||||
paramSettingsBtn.className = 'param-settings-btn';
|
||||
paramSettingsBtn.innerHTML = '⋯';
|
||||
paramSettingsBtn.title = 'Adjust range';
|
||||
|
||||
// Mini settings panel for param bounds
|
||||
const paramBoundsPanel = document.createElement('div');
|
||||
paramBoundsPanel.className = 'param-bounds-panel';
|
||||
paramBoundsPanel.style.display = 'none';
|
||||
|
||||
const boundsRow = document.createElement('div');
|
||||
boundsRow.className = 'param-bounds-row';
|
||||
|
||||
const minInput = document.createElement('input');
|
||||
minInput.type = 'number';
|
||||
minInput.className = 'param-bound-input';
|
||||
minInput.value = paramInput.min;
|
||||
minInput.placeholder = 'min';
|
||||
|
||||
const maxInput = document.createElement('input');
|
||||
maxInput.type = 'number';
|
||||
maxInput.className = 'param-bound-input';
|
||||
maxInput.value = paramInput.max;
|
||||
maxInput.placeholder = 'max';
|
||||
|
||||
boundsRow.appendChild(minInput);
|
||||
boundsRow.appendChild(maxInput);
|
||||
paramBoundsPanel.appendChild(boundsRow);
|
||||
|
||||
minInput.addEventListener('change', () => {
|
||||
paramInput.min = parseFloat(minInput.value);
|
||||
});
|
||||
maxInput.addEventListener('change', () => {
|
||||
paramInput.max = parseFloat(maxInput.value);
|
||||
});
|
||||
|
||||
paramSettingsBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isOpen = paramBoundsPanel.style.display !== 'none';
|
||||
paramBoundsPanel.style.display = isOpen ? 'none' : 'flex';
|
||||
});
|
||||
|
||||
paramInput.addEventListener('input', (e) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
filterInstance.params[paramDef.name] = value;
|
||||
paramValue.textContent = Math.round(value * 100) / 100;
|
||||
});
|
||||
|
||||
const topRow = document.createElement('div');
|
||||
topRow.className = 'filter-param-top-row';
|
||||
topRow.appendChild(paramLabel);
|
||||
topRow.appendChild(paramValue);
|
||||
topRow.appendChild(paramSettingsBtn);
|
||||
|
||||
paramRow.appendChild(topRow);
|
||||
paramRow.appendChild(paramInput);
|
||||
paramRow.appendChild(paramBoundsPanel);
|
||||
container.appendChild(paramRow);
|
||||
|
||||
paramInputs[paramDef.name] = paramInput;
|
||||
}
|
||||
}
|
||||
}
|
||||
116
docs/js/controls/ControlManager.js
Normal file
116
docs/js/controls/ControlManager.js
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* ControlManager - Manages UI controls for shapes
|
||||
*
|
||||
* Handles creation, updates, and cleanup of control panels
|
||||
* Ready for Phase 2 multi-shape support (panels per shape)
|
||||
*/
|
||||
class ControlManager {
|
||||
constructor(containerId) {
|
||||
this.container = document.getElementById(containerId);
|
||||
this.filterManager = filterManager; // Use global FilterManager
|
||||
this.factory = new ControlFactory(this);
|
||||
|
||||
// Track all active controls (for future multi-shape, keyed by shape id)
|
||||
this.activeControls = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a filter to a control
|
||||
*/
|
||||
addFilter(controlData, controlConfig, filterType) {
|
||||
const filterInstance = this.filterManager.createFilterInstance(filterType, controlConfig);
|
||||
const filterDef = this.filterManager.getFilter(filterType);
|
||||
|
||||
if (!controlData.filters) {
|
||||
controlData.filters = [];
|
||||
}
|
||||
|
||||
// Create filter UI
|
||||
const filterUI = this.factory.createFilterUI(
|
||||
filterInstance,
|
||||
filterDef,
|
||||
controlData,
|
||||
controlConfig
|
||||
);
|
||||
|
||||
controlData.filtersContainer.appendChild(filterUI);
|
||||
controlData.filters.push(filterInstance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a filter from a control
|
||||
*/
|
||||
removeFilter(controlData, filterInstance) {
|
||||
const index = controlData.filters.indexOf(filterInstance);
|
||||
if (index > -1) {
|
||||
controlData.filters.splice(index, 1);
|
||||
if (filterInstance.element && filterInstance.element.parentNode) {
|
||||
filterInstance.element.parentNode.removeChild(filterInstance.element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a control for a shape
|
||||
* @param {object} config - Control configuration
|
||||
* @param {BaseShape} instance - Shape instance
|
||||
* @returns {object} Control data
|
||||
*/
|
||||
addControl(config, instance) {
|
||||
return this.factory.create(config, instance, this.container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a control
|
||||
* @param {object} controlData - Control data from addControl
|
||||
*/
|
||||
removeControl(controlData) {
|
||||
if (!controlData) return;
|
||||
|
||||
// Remove event listeners
|
||||
if (controlData.element && controlData.listener) {
|
||||
const eventType = controlData.type === 'checkbox' ? 'change' :
|
||||
controlData.type === 'dropdown' ? 'change' : 'input';
|
||||
controlData.element.removeEventListener(eventType, controlData.listener);
|
||||
}
|
||||
|
||||
// Remove filters
|
||||
if (controlData.filters) {
|
||||
for (const filter of controlData.filters) {
|
||||
if (filter.element && filter.element.parentNode) {
|
||||
filter.element.parentNode.removeChild(filter.element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove wrapper element
|
||||
if (controlData.wrapper && controlData.wrapper.parentNode) {
|
||||
controlData.wrapper.parentNode.removeChild(controlData.wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all controls
|
||||
*/
|
||||
clearAll() {
|
||||
while (this.container.firstChild) {
|
||||
this.container.removeChild(this.container.firstChild);
|
||||
}
|
||||
this.activeControls.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a control's displayed value programmatically
|
||||
*/
|
||||
updateControlValue(property, value) {
|
||||
const element = document.getElementById(`el${property}`);
|
||||
const label = document.getElementById(`elText${property}`);
|
||||
|
||||
if (element) {
|
||||
element.value = value;
|
||||
}
|
||||
if (label) {
|
||||
label.textContent = Math.round(value * 100) / 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
195
docs/js/controls/FilterManager.js
Normal file
195
docs/js/controls/FilterManager.js
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* FilterManager - Manages filter plugins and computes filtered values
|
||||
*
|
||||
* Filters modify control values over time (e.g., oscillate a slider)
|
||||
*/
|
||||
class FilterManager {
|
||||
constructor() {
|
||||
this.filterTypes = new Map();
|
||||
this.registerBuiltinFilters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register built-in filter types
|
||||
*/
|
||||
registerBuiltinFilters() {
|
||||
// Sine wave filter
|
||||
this.registerFilter('sin', {
|
||||
name: 'Sine Wave',
|
||||
compute: (params, elapsed) => {
|
||||
const { min, max, rate } = params;
|
||||
const halfRange = (max - min) / 2;
|
||||
return min + halfRange + Math.sin(rate * elapsed * 2) * halfRange;
|
||||
},
|
||||
controls: [
|
||||
{ name: 'min', type: 'range', label: 'Min' },
|
||||
{ name: 'max', type: 'range', label: 'Max' },
|
||||
{ name: 'rate', type: 'range', label: 'Rate', min: 0.1, max: 10, defaultValue: 1 }
|
||||
]
|
||||
});
|
||||
|
||||
// Triangle wave filter
|
||||
this.registerFilter('triangle', {
|
||||
name: 'Triangle Wave',
|
||||
compute: (params, elapsed) => {
|
||||
const { min, max, rate } = params;
|
||||
const period = (2 * Math.PI) / rate;
|
||||
const t = (elapsed % period) / period;
|
||||
const triangleValue = t < 0.5 ? t * 2 : 2 - t * 2;
|
||||
return min + (max - min) * triangleValue;
|
||||
},
|
||||
controls: [
|
||||
{ name: 'min', type: 'range', label: 'Min' },
|
||||
{ name: 'max', type: 'range', label: 'Max' },
|
||||
{ name: 'rate', type: 'range', label: 'Rate', min: 0.1, max: 10, defaultValue: 1 }
|
||||
]
|
||||
});
|
||||
|
||||
// Sawtooth wave filter
|
||||
this.registerFilter('sawtooth', {
|
||||
name: 'Sawtooth Wave',
|
||||
compute: (params, elapsed) => {
|
||||
const { min, max, rate } = params;
|
||||
const period = (2 * Math.PI) / rate;
|
||||
const t = (elapsed % period) / period;
|
||||
return min + (max - min) * t;
|
||||
},
|
||||
controls: [
|
||||
{ name: 'min', type: 'range', label: 'Min' },
|
||||
{ name: 'max', type: 'range', label: 'Max' },
|
||||
{ name: 'rate', type: 'range', label: 'Rate', min: 0.1, max: 10, defaultValue: 1 }
|
||||
]
|
||||
});
|
||||
|
||||
// Square wave filter
|
||||
this.registerFilter('square', {
|
||||
name: 'Square Wave',
|
||||
compute: (params, elapsed) => {
|
||||
const { min, max, rate } = params;
|
||||
const sinValue = Math.sin(rate * elapsed * 2);
|
||||
return sinValue >= 0 ? max : min;
|
||||
},
|
||||
controls: [
|
||||
{ name: 'min', type: 'range', label: 'Min' },
|
||||
{ name: 'max', type: 'range', label: 'Max' },
|
||||
{ name: 'rate', type: 'range', label: 'Rate', min: 0.1, max: 10, defaultValue: 1 }
|
||||
]
|
||||
});
|
||||
|
||||
// Random/noise filter
|
||||
this.registerFilter('noise', {
|
||||
name: 'Noise',
|
||||
compute: (params, elapsed) => {
|
||||
const { min, max, smoothness } = params;
|
||||
// Simple smooth random using sin of elapsed with random-ish multiplier
|
||||
const noise = Math.sin(elapsed * 17.3) * Math.cos(elapsed * 31.7) * 0.5 + 0.5;
|
||||
return min + (max - min) * noise;
|
||||
},
|
||||
controls: [
|
||||
{ name: 'min', type: 'range', label: 'Min' },
|
||||
{ name: 'max', type: 'range', label: 'Max' },
|
||||
{ name: 'smoothness', type: 'range', label: 'Smoothness', min: 1, max: 100, defaultValue: 50 }
|
||||
]
|
||||
});
|
||||
|
||||
// Linear interpolation (ping-pong)
|
||||
this.registerFilter('linear', {
|
||||
name: 'Linear (Ping-Pong)',
|
||||
compute: (params, elapsed) => {
|
||||
const { min, max, duration } = params;
|
||||
const cycleTime = elapsed % (duration * 2);
|
||||
const t = cycleTime < duration
|
||||
? cycleTime / duration
|
||||
: 1 - (cycleTime - duration) / duration;
|
||||
return min + (max - min) * t;
|
||||
},
|
||||
controls: [
|
||||
{ name: 'min', type: 'range', label: 'Min' },
|
||||
{ name: 'max', type: 'range', label: 'Max' },
|
||||
{ name: 'duration', type: 'range', label: 'Duration (s)', min: 0.5, max: 30, defaultValue: 5 }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom filter type
|
||||
* @param {string} id - Unique filter identifier
|
||||
* @param {object} filterDef - Filter definition with name, compute, controls
|
||||
*/
|
||||
registerFilter(id, filterDef) {
|
||||
this.filterTypes.set(id, filterDef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available filter types
|
||||
* @returns {Array} Array of {id, name} objects
|
||||
*/
|
||||
getAvailableFilters() {
|
||||
const filters = [];
|
||||
for (const [id, def] of this.filterTypes) {
|
||||
filters.push({ id, name: def.name });
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filter definition by id
|
||||
* @param {string} id - Filter type id
|
||||
* @returns {object|null} Filter definition
|
||||
*/
|
||||
getFilter(id) {
|
||||
return this.filterTypes.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the combined value from multiple filters
|
||||
* @param {Array} filters - Array of active filter instances
|
||||
* @param {number} elapsed - Elapsed time in seconds
|
||||
* @returns {number|null} Computed value or null if no filters
|
||||
*/
|
||||
computeFilteredValue(filters, elapsed) {
|
||||
if (!filters || filters.length === 0) return null;
|
||||
|
||||
let totalValue = 0;
|
||||
for (const filter of filters) {
|
||||
const filterDef = this.getFilter(filter.type);
|
||||
if (filterDef) {
|
||||
totalValue += filterDef.compute(filter.params, elapsed);
|
||||
}
|
||||
}
|
||||
return totalValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filter instance with default params
|
||||
* @param {string} type - Filter type id
|
||||
* @param {object} controlConfig - The control's config (for min/max defaults)
|
||||
* @returns {object} Filter instance
|
||||
*/
|
||||
createFilterInstance(type, controlConfig) {
|
||||
const filterDef = this.getFilter(type);
|
||||
if (!filterDef) {
|
||||
throw new Error(`Unknown filter type: ${type}`);
|
||||
}
|
||||
|
||||
const params = {};
|
||||
for (const ctrl of filterDef.controls) {
|
||||
if (ctrl.name === 'min') {
|
||||
params.min = controlConfig.min ?? 0;
|
||||
} else if (ctrl.name === 'max') {
|
||||
params.max = controlConfig.max ?? 100;
|
||||
} else {
|
||||
params[ctrl.name] = ctrl.defaultValue ?? 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
params,
|
||||
element: null // Will hold DOM reference for cleanup
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton
|
||||
const filterManager = new FilterManager();
|
||||
771
docs/js/controls/SceneUI.js
Normal file
771
docs/js/controls/SceneUI.js
Normal file
@@ -0,0 +1,771 @@
|
||||
/**
|
||||
* 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 = `
|
||||
<h3>Scene Layers</h3>
|
||||
<div class="scene-header-buttons">
|
||||
<button class="scene-btn" id="add-shape-btn" title="Add Shape">+ Add</button>
|
||||
<button class="scene-btn" id="save-scene-btn" title="Save Scene">💾 Save</button>
|
||||
<button class="scene-btn" id="load-scene-btn" title="Load Scene">📂 Load</button>
|
||||
<button class="scene-btn" id="presets-btn" title="Presets">⭐ Presets</button>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="presets-content">
|
||||
<h3>Presets</h3>
|
||||
<div class="presets-actions">
|
||||
<button class="preset-save-current-btn">💾 Save Current as Preset</button>
|
||||
</div>
|
||||
<div class="presets-list" id="presets-list">
|
||||
<p class="presets-empty">No presets saved yet</p>
|
||||
</div>
|
||||
<button class="button presets-cancel">Close</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 += '<div class="presets-section-header">Built-in Presets</div>';
|
||||
html += builtInPresets.map((preset, index) => `
|
||||
<div class="preset-item preset-builtin" data-index="${index}" data-builtin="true">
|
||||
<div class="preset-info">
|
||||
<span class="preset-name">${preset.name}</span>
|
||||
<span class="preset-description">${preset.description || `${preset.data.layers.length} layer(s)`}</span>
|
||||
</div>
|
||||
<div class="preset-actions">
|
||||
<button class="preset-load-btn" data-index="${index}" data-builtin="true">Load</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// User presets section
|
||||
html += '<div class="presets-section-header">My Presets</div>';
|
||||
if (userPresets.length === 0) {
|
||||
html += '<p class="presets-empty">No saved presets yet</p>';
|
||||
} else {
|
||||
html += userPresets.map((preset, index) => `
|
||||
<div class="preset-item" data-index="${index}">
|
||||
<div class="preset-info">
|
||||
<span class="preset-name">${preset.name}</span>
|
||||
<span class="preset-layers">${preset.data.layers.length} layer(s)</span>
|
||||
</div>
|
||||
<div class="preset-actions">
|
||||
<button class="preset-load-btn" data-index="${index}">Load</button>
|
||||
<button class="preset-delete-btn" data-index="${index}">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
`).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 =>
|
||||
`<button class="shape-picker-btn" data-shape="${name}">${name}</button>`
|
||||
).join('');
|
||||
|
||||
const builtInPresets = this.getBuiltInPresets();
|
||||
const builtInHtml = builtInPresets.map((preset, i) =>
|
||||
`<button class="shape-picker-btn preset-btn" data-preset-index="${i}" data-builtin="true">
|
||||
<span class="preset-btn-name">${preset.name}</span>
|
||||
<span class="preset-btn-info">${preset.data.layers.length} layer(s)</span>
|
||||
</button>`
|
||||
).join('');
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="shape-picker-container">
|
||||
<h3>Add Layer</h3>
|
||||
<div class="shape-picker-tabs">
|
||||
<button class="shape-picker-tab active" data-tab="shapes">Shapes</button>
|
||||
<button class="shape-picker-tab" data-tab="presets">Presets</button>
|
||||
</div>
|
||||
<div class="shape-picker-content">
|
||||
<div class="shape-picker-panel active" id="panel-shapes">
|
||||
<div class="shape-picker-buttons">
|
||||
${shapesHtml}
|
||||
</div>
|
||||
</div>
|
||||
<div class="shape-picker-panel" id="panel-presets">
|
||||
<div class="shape-picker-section">
|
||||
<div class="shape-picker-section-title">Built-in</div>
|
||||
<div class="shape-picker-buttons">
|
||||
${builtInHtml}
|
||||
</div>
|
||||
</div>
|
||||
<div class="shape-picker-section">
|
||||
<div class="shape-picker-section-title">My Presets</div>
|
||||
<div class="shape-picker-buttons" id="picker-user-presets">
|
||||
<p class="presets-empty">No saved presets</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="shape-picker-cancel">Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = '<p class="presets-empty">No saved presets</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = userPresets.map((preset, i) =>
|
||||
`<button class="shape-picker-btn preset-btn" data-preset-index="${i}" data-builtin="false">
|
||||
<span class="preset-btn-name">${preset.name}</span>
|
||||
<span class="preset-btn-info">${preset.data.layers.length} layer(s)</span>
|
||||
</button>`
|
||||
).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 = `
|
||||
<span class="layer-collapse-btn" data-layer="${layer.id}">▼</span>
|
||||
<span class="layer-name">${layer.name}</span>
|
||||
<div class="layer-controls">
|
||||
<button class="layer-btn layer-visibility" data-layer="${layer.id}" title="Toggle visibility">👁</button>
|
||||
<button class="layer-btn layer-pause" data-layer="${layer.id}" title="Pause/Play">⏸</button>
|
||||
<button class="layer-btn layer-up" data-layer="${layer.id}" title="Move up">↑</button>
|
||||
<button class="layer-btn layer-down" data-layer="${layer.id}" title="Move down">↓</button>
|
||||
<button class="layer-btn layer-remove" data-layer="${layer.id}" title="Remove">✕</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user