ritual.sh/static/js/button-generator/ui-builder.js
2026-01-09 17:51:53 +00:00

572 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* UI Builder - Dynamically generates control UI from effect definitions
*/
export class UIBuilder {
constructor(containerElement) {
this.container = containerElement;
this.controlGroups = new Map(); // category -> { element, controls }
this.tooltip = null;
this.tooltipTimeout = null;
this.setupTooltip();
}
/**
* Create and setup the tooltip element
*/
setupTooltip() {
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
this.createTooltipElement();
});
} else {
this.createTooltipElement();
}
}
createTooltipElement() {
this.tooltip = document.createElement('div');
this.tooltip.className = 'control-tooltip';
this.tooltip.style.cssText = `
position: fixed;
background: linear-gradient(135deg, rgba(0, 120, 200, 0.98) 0%, rgba(0, 100, 180, 0.98) 100%);
color: #fff;
padding: 0.5rem 0.75rem;
border-radius: 4px;
font-size: 0.8rem;
pointer-events: none;
z-index: 10000;
max-width: 250px;
box-shadow: 0 0 20px rgba(0, 150, 255, 0.5), 0 4px 12px rgba(0, 0, 0, 0.4);
border: 1px solid rgba(0, 150, 255, 0.6);
opacity: 0;
transition: opacity 0.15s ease;
line-height: 1.4;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
`;
document.body.appendChild(this.tooltip);
}
/**
* Show tooltip for an element
*/
showTooltip(element, text) {
if (!text || !this.tooltip) return;
clearTimeout(this.tooltipTimeout);
this.tooltip.textContent = text;
this.tooltip.style.opacity = '1';
// Position tooltip above the element
const rect = element.getBoundingClientRect();
// Set initial position to measure
this.tooltip.style.left = '0px';
this.tooltip.style.top = '0px';
this.tooltip.style.visibility = 'hidden';
this.tooltip.style.display = 'block';
const tooltipRect = this.tooltip.getBoundingClientRect();
this.tooltip.style.visibility = 'visible';
let left = rect.left + rect.width / 2 - tooltipRect.width / 2;
let top = rect.top - tooltipRect.height - 10;
// Keep tooltip on screen
const padding = 10;
if (left < padding) left = padding;
if (left + tooltipRect.width > window.innerWidth - padding) {
left = window.innerWidth - tooltipRect.width - padding;
}
if (top < padding) {
top = rect.bottom + 10;
}
this.tooltip.style.left = `${left}px`;
this.tooltip.style.top = `${top}px`;
}
/**
* Hide tooltip
*/
hideTooltip() {
if (!this.tooltip) return;
clearTimeout(this.tooltipTimeout);
this.tooltipTimeout = setTimeout(() => {
this.tooltip.style.opacity = '0';
}, 100);
}
/**
* Add tooltip handlers to an element
*/
addTooltipHandlers(element, description) {
if (!description) return;
element.addEventListener('mouseenter', () => {
this.showTooltip(element, description);
});
element.addEventListener('mouseleave', () => {
this.hideTooltip();
});
element.addEventListener('mousemove', () => {
// Update position on mouse move for better following
if (this.tooltip && this.tooltip.style.opacity === '1') {
this.showTooltip(element, description);
}
});
}
/**
* Build the entire UI from registered effects
* @param {Array<ButtonEffect>} effects - All registered effects
*/
buildUI(effects) {
// Clear existing content
this.container.innerHTML = '';
this.controlGroups.clear();
// Group effects by category
const categorized = this.categorizeEffects(effects);
// Create control groups for each category
for (const [category, categoryEffects] of categorized) {
this.createControlGroup(category, categoryEffects);
}
}
/**
* Categorize effects by their category property
* @param {Array<ButtonEffect>} effects
* @returns {Map<string, Array<ButtonEffect>>}
*/
categorizeEffects(effects) {
const categories = new Map();
effects.forEach(effect => {
const category = effect.category || 'Other';
if (!categories.has(category)) {
categories.set(category, []);
}
categories.get(category).push(effect);
});
// Sort categories in a logical order
const orderedCategories = new Map();
const categoryOrder = [
'Text Line 1',
'Text Line 2',
'Background',
'Background Animations',
'Border',
'Visual Effects',
'General Effects',
'Special Effects'
];
categoryOrder.forEach(cat => {
if (categories.has(cat)) {
orderedCategories.set(cat, categories.get(cat));
}
});
// Add any remaining categories
categories.forEach((effects, cat) => {
if (!orderedCategories.has(cat)) {
orderedCategories.set(cat, effects);
}
});
return orderedCategories;
}
/**
* Create a collapsible control group
* @param {string} category - Category name
* @param {Array<ButtonEffect>} effects - Effects in this category
*/
createControlGroup(category, effects) {
const groupDiv = document.createElement('div');
groupDiv.className = 'control-group';
// Create header
const header = document.createElement('h3');
header.className = 'control-group-header';
header.innerHTML = `
<span>${category}</span>
<span class="toggle-icon"></span>
`;
// Create content container
const content = document.createElement('div');
content.className = 'control-group-content';
// Add controls for each effect in this category
effects.forEach(effect => {
this.addEffectControls(content, effect);
});
// Add click handler for collapsing
header.addEventListener('click', () => {
groupDiv.classList.toggle('collapsed');
});
groupDiv.appendChild(header);
groupDiv.appendChild(content);
this.container.appendChild(groupDiv);
this.controlGroups.set(category, { element: groupDiv, effects });
}
/**
* Add controls for a single effect
* @param {HTMLElement} container - Container to add controls to
* @param {ButtonEffect} effect - Effect to create controls for
*/
addEffectControls(container, effect) {
effect.controls.forEach(control => {
const controlEl = this.createControl(control);
if (controlEl) {
container.appendChild(controlEl);
}
});
}
/**
* Create a single control element
* @param {Object} controlDef - Control definition from effect
* @returns {HTMLElement}
*/
createControl(controlDef) {
const { id, type, label, defaultValue, min, max, step, options, showWhen, description } = controlDef;
switch (type) {
case 'checkbox':
return this.createCheckbox(id, label, defaultValue, showWhen, description);
case 'range':
return this.createRange(id, label, defaultValue, min, max, step, description, showWhen);
case 'color':
return this.createColor(id, label, defaultValue, showWhen, description);
case 'select':
return this.createSelect(id, label, defaultValue, options, showWhen, description);
case 'text':
return this.createTextInput(id, label, defaultValue, showWhen, description);
case 'file':
return this.createFileInput(id, label, defaultValue, showWhen, description, controlDef.accept);
default:
console.warn(`Unknown control type: ${type}`);
return null;
}
}
/**
* Create a checkbox control
*/
createCheckbox(id, label, defaultValue, showWhen, description) {
const wrapper = document.createElement('label');
wrapper.className = 'checkbox-label';
const input = document.createElement('input');
input.type = 'checkbox';
input.id = id;
input.checked = defaultValue || false;
const span = document.createElement('span');
span.textContent = label;
wrapper.appendChild(input);
wrapper.appendChild(span);
if (showWhen) {
wrapper.style.display = 'none';
wrapper.dataset.showWhen = showWhen;
}
// Add tooltip handlers to the label wrapper
this.addTooltipHandlers(wrapper, description);
return wrapper;
}
/**
* Create a range slider control
*/
createRange(id, label, defaultValue, min, max, step, description, showWhen) {
const container = document.createElement('div');
const labelEl = document.createElement('label');
labelEl.htmlFor = id;
labelEl.innerHTML = `${label}: <span id="${id}-value">${defaultValue}</span>`;
const input = document.createElement('input');
input.type = 'range';
input.id = id;
input.min = min !== undefined ? min : 0;
input.max = max !== undefined ? max : 100;
input.value = defaultValue !== undefined ? defaultValue : 50;
if (step !== undefined) {
input.step = step;
}
// Update value display on input
input.addEventListener('input', () => {
const valueDisplay = document.getElementById(`${id}-value`);
if (valueDisplay) {
valueDisplay.textContent = input.value;
}
});
container.appendChild(labelEl);
container.appendChild(input);
if (showWhen) {
container.style.display = 'none';
container.dataset.showWhen = showWhen;
}
// Add tooltip handlers to the label
this.addTooltipHandlers(labelEl, description);
return container;
}
/**
* Create a color picker control
*/
createColor(id, label, defaultValue, showWhen, description) {
const container = document.createElement('div');
const labelEl = document.createElement('label');
labelEl.htmlFor = id;
labelEl.textContent = label;
const input = document.createElement('input');
input.type = 'color';
input.id = id;
input.value = defaultValue || '#ffffff';
container.appendChild(labelEl);
container.appendChild(input);
if (showWhen) {
container.style.display = 'none';
container.dataset.showWhen = showWhen;
}
// Add tooltip handlers to the label
this.addTooltipHandlers(labelEl, description);
return container;
}
/**
* Create a select dropdown control
*/
createSelect(id, label, defaultValue, options, showWhen, description) {
const container = document.createElement('div');
const labelEl = document.createElement('label');
labelEl.htmlFor = id;
labelEl.textContent = label;
const select = document.createElement('select');
select.id = id;
options.forEach(opt => {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
if (opt.value === defaultValue) {
option.selected = true;
}
select.appendChild(option);
});
container.appendChild(labelEl);
container.appendChild(select);
if (showWhen) {
container.style.display = 'none';
container.dataset.showWhen = showWhen;
}
// Add tooltip handlers to the label
this.addTooltipHandlers(labelEl, description);
return container;
}
/**
* Create a text input control
*/
createTextInput(id, label, defaultValue, showWhen, description) {
const container = document.createElement('div');
const labelEl = document.createElement('label');
labelEl.htmlFor = id;
labelEl.textContent = label;
const input = document.createElement('input');
input.type = 'text';
input.id = id;
input.value = defaultValue || '';
// Only set maxLength for text inputs that aren't URLs
if (id !== 'bg-image-url') {
input.maxLength = 20;
}
container.appendChild(labelEl);
container.appendChild(input);
if (showWhen) {
container.style.display = 'none';
container.dataset.showWhen = showWhen;
}
// Add tooltip handlers to the label
this.addTooltipHandlers(labelEl, description);
return container;
}
/**
* Create a file input control
*/
createFileInput(id, label, defaultValue, showWhen, description, accept) {
const container = document.createElement('div');
const labelEl = document.createElement('label');
labelEl.htmlFor = id;
labelEl.textContent = label;
const input = document.createElement('input');
input.type = 'file';
input.id = id;
if (accept) {
input.accept = accept;
}
// Store the file data on the input element
input.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
// Create a blob URL for the file
const blobUrl = URL.createObjectURL(file);
// Store file metadata on the input element
input.dataset.fileName = file.name;
input.dataset.blobUrl = blobUrl;
input.dataset.fileSize = file.size;
input.dataset.fileType = file.type;
} else {
// Clear the data if no file is selected
delete input.dataset.fileName;
delete input.dataset.blobUrl;
delete input.dataset.fileSize;
delete input.dataset.fileType;
}
});
container.appendChild(labelEl);
container.appendChild(input);
if (showWhen) {
container.style.display = 'none';
container.dataset.showWhen = showWhen;
}
// Add tooltip handlers to the label
this.addTooltipHandlers(labelEl, description);
return container;
}
/**
* Setup conditional visibility for controls
* Should be called after all controls are created
*/
setupConditionalVisibility() {
// Find all controls with showWhen attribute
const conditionalControls = this.container.querySelectorAll('[data-show-when]');
conditionalControls.forEach(control => {
const triggerControlId = control.dataset.showWhen;
const triggerControl = document.getElementById(triggerControlId);
if (triggerControl) {
const updateVisibility = () => {
if (triggerControl.type === 'checkbox') {
control.style.display = triggerControl.checked ? 'block' : 'none';
} else if (triggerControl.tagName === 'SELECT') {
// Get the control ID to determine what value to check for
const controlId = control.querySelector('input, select')?.id;
// For background controls
if (triggerControlId === 'bg-type') {
if (controlId === 'bg-color') {
control.style.display = triggerControl.value === 'solid' ? 'block' : 'none';
} else if (controlId && (controlId.startsWith('gradient-') || controlId === 'gradient-angle')) {
control.style.display = triggerControl.value === 'gradient' ? 'block' : 'none';
} else if (controlId && (controlId.startsWith('texture-') || controlId === 'texture-type' || controlId === 'texture-scale')) {
control.style.display = triggerControl.value === 'texture' ? 'block' : 'none';
} else if (controlId && (controlId.startsWith('emoji-') || controlId === 'emoji-text')) {
control.style.display = triggerControl.value === 'emoji-wallpaper' ? 'block' : 'none';
} else if (controlId && (controlId.startsWith('bg-image-') || controlId === 'bg-image-file' || controlId === 'bg-image-fit' || controlId === 'bg-image-opacity')) {
control.style.display = triggerControl.value === 'external-image' ? 'block' : 'none';
}
}
// For image fit controls (zoom and position only show when manual mode)
else if (triggerControlId === 'bg-image-fit') {
if (controlId && (controlId === 'bg-image-zoom' || controlId === 'bg-image-offset-x' || controlId === 'bg-image-offset-y')) {
control.style.display = triggerControl.value === 'manual' ? 'block' : 'none';
}
}
// For text color controls
else if (triggerControlId === 'text-color-type') {
if (controlId === 'text-color') {
control.style.display = triggerControl.value === 'solid' ? 'block' : 'none';
} else if (controlId && controlId.startsWith('text-gradient-')) {
control.style.display = triggerControl.value === 'gradient' ? 'block' : 'none';
}
} else if (triggerControlId === 'text2-color-type') {
if (controlId === 'text2-color') {
control.style.display = triggerControl.value === 'solid' ? 'block' : 'none';
} else if (controlId && controlId.startsWith('text2-gradient-')) {
control.style.display = triggerControl.value === 'gradient' ? 'block' : 'none';
}
}
// For border style controls
else if (triggerControlId === 'border-style') {
if (controlId === 'border-rainbow-speed') {
control.style.display = triggerControl.value === 'rainbow' ? 'block' : 'none';
} else if (controlId === 'border-march-speed') {
control.style.display = triggerControl.value === 'marching-ants' ? 'block' : 'none';
}
} else {
// Default: show when any value is selected
control.style.display = triggerControl.value ? 'block' : 'none';
}
}
};
// Initial visibility
updateVisibility();
// Update on change
triggerControl.addEventListener('change', updateVisibility);
triggerControl.addEventListener('input', updateVisibility);
}
});
}
}