Loads of button generator additions

This commit is contained in:
Dan 2026-01-13 12:34:41 +00:00
parent 98b4edf47d
commit e7754141bf
12 changed files with 904 additions and 126 deletions

View file

@ -174,6 +174,14 @@ export class ButtonGenerator {
values[id] = element.value;
}
}
// For range-dual controls, also check for -start and -end suffixed elements
const startElement = document.getElementById(`${id}-start`);
const endElement = document.getElementById(`${id}-end`);
if (startElement && endElement) {
values[`${id}-start`] = parseFloat(startElement.value);
values[`${id}-end`] = parseFloat(endElement.value);
}
});
return values;

View file

@ -245,12 +245,17 @@ export class ExternalImageBackgroundEffect extends ButtonEffect {
apply(context, controlValues, animState, renderData) {
const file = controlValues['bg-image-file'];
const bgColor = controlValues['bg-color'] || '#FFFFFF';
const fitMode = controlValues['bg-image-fit'] || 'cover';
const opacity = controlValues['bg-image-opacity'] ?? 1;
const zoom = controlValues['bg-image-zoom'] ?? 100;
const offsetX = controlValues['bg-image-offset-x'] ?? 50;
const offsetY = controlValues['bg-image-offset-y'] ?? 50;
// Draw background color first (always, for all states)
context.fillStyle = bgColor;
context.fillRect(0, 0, renderData.width, renderData.height);
// If no file selected, fill with a placeholder color
if (!file || !file.blobUrl) {
context.fillStyle = '#cccccc';

View file

@ -35,7 +35,7 @@ export class SolidBackgroundEffect extends ButtonEffect {
label: "Background Color",
defaultValue: "#4a90e2",
showWhen: "bg-type",
description: "Solid background color",
description: "Background color (also used behind images)",
},
];
}

View file

@ -0,0 +1,88 @@
import { ButtonEffect } from '../effect-base.js';
export class FlashTextEffect extends ButtonEffect {
constructor(textLineNumber = 1) {
const suffix = textLineNumber === 1 ? '' : '2';
super({
id: `text-flash${suffix}`,
name: `Flash Text ${textLineNumber}`,
type: textLineNumber === 1 ? 'text' : 'text2',
category: textLineNumber === 1 ? 'Text Line 1' : 'Text Line 2',
renderOrder: 1, // Execute very early, before all other text effects
textLineNumber: textLineNumber
});
this.textLineNumber = textLineNumber;
}
defineControls() {
const textLineNumber = this.textLineNumber || this.config?.textLineNumber || 1;
const suffix = textLineNumber === 1 ? '' : '2';
return [
{
id: `animate-text-flash${suffix}`,
type: 'checkbox',
label: 'Flash Visibility',
defaultValue: false
},
{
id: `flash-range${suffix}`,
type: 'range-dual',
label: 'Visible Frame Range',
defaultValueStart: textLineNumber === 1 ? 0 : 20,
defaultValueEnd: textLineNumber === 1 ? 19 : 39,
min: 0,
max: 39,
step: 1,
showWhen: `animate-text-flash${suffix}`,
description: 'Frame range where text is visible (0-39 frames total)'
}
];
}
isEnabled(controlValues) {
const suffix = this.textLineNumber === 1 ? '' : '2';
return controlValues[`animate-text-flash${suffix}`] === true;
}
apply(context, controlValues, animState, renderData) {
if (!animState) return; // Flash requires animation
const suffix = this.textLineNumber === 1 ? '' : '2';
const startFrame = controlValues[`flash-range${suffix}-start`] || 0;
const endFrame = controlValues[`flash-range${suffix}-end`] || 39;
// Check if current frame is within visible range
const isVisible = this.isFrameVisible(animState.frame, startFrame, endFrame);
// Store visibility state in renderData so other text effects can check it
const visibilityKey = `textFlashVisible${suffix}`;
renderData[visibilityKey] = isVisible;
// Also set globalAlpha (even though it gets restored, it helps during this effect's lifecycle)
if (!isVisible) {
context.globalAlpha = 0;
}
}
/**
* Check if frame is within visible range
* @param {number} frame - Current frame number (0-39)
* @param {number} startFrame - Start of visible range
* @param {number} endFrame - End of visible range
* @returns {boolean} - True if frame is in visible range
*/
isFrameVisible(frame, startFrame, endFrame) {
// Ensure start is less than or equal to end
const start = Math.min(startFrame, endFrame);
const end = Math.max(startFrame, endFrame);
return frame >= start && frame <= end;
}
}
// Auto-register effects for both text lines
export function register(generator) {
generator.registerEffect(new FlashTextEffect(1));
generator.registerEffect(new FlashTextEffect(2));
}

View file

@ -52,6 +52,18 @@ export class RainbowTextEffect extends ButtonEffect {
const suffix = this.textLineNumber === 1 ? '' : '2';
// Check if ticker is active - if so, ticker handles rendering
const tickerActive = controlValues[`animate-text-ticker${suffix}`];
if (tickerActive) {
return;
}
// Check flash visibility - if flash is active and text is invisible, don't render
const flashActive = controlValues[`animate-text-flash${suffix}`];
if (flashActive && renderData[`textFlashVisible${suffix}`] === false) {
return;
}
// Get text configuration
const text = controlValues[`button-text${suffix}`] || '';
if (!text || text.trim() === '') return;

View file

@ -61,6 +61,19 @@ export class SpinTextEffect extends ButtonEffect {
apply(context, controlValues, animState, renderData) {
const suffix = this.textLineNumber === 1 ? "" : "2";
// Check if ticker is active - if so, ticker handles rendering
const tickerActive = controlValues[`animate-text-ticker${suffix}`];
if (tickerActive) {
return;
}
// Check flash visibility - if flash is active and text is invisible, don't render
const flashActive = controlValues[`animate-text-flash${suffix}`];
if (flashActive && renderData[`textFlashVisible${suffix}`] === false) {
return;
}
const text = controlValues[`button-text${suffix}`] || "";
if (!text || text.trim() === '') return;

View file

@ -98,6 +98,12 @@ export class TextShadowEffect extends ButtonEffect {
apply(context, controlValues, animState, renderData) {
const suffix = this.textLineNumber === 1 ? "" : "2";
// Check flash visibility - if flash is active and text is invisible, don't render
const flashActive = controlValues[`animate-text-flash${suffix}`];
if (flashActive && renderData[`textFlashVisible${suffix}`] === false) {
return;
}
const text = controlValues[`button-text${suffix}`] || "";
if (!text) return;

View file

@ -150,13 +150,20 @@ export class StandardTextEffect extends ButtonEffect {
const waveActive = controlValues[`animate-text-wave${suffix}`];
const rainbowActive = controlValues[`animate-text-rainbow${suffix}`];
const spinActive = controlValues[`animate-text-spin${suffix}`];
const tickerActive = controlValues[`animate-text-ticker${suffix}`];
return text && text.trim() !== "" && !waveActive && !rainbowActive && !spinActive;
return text && text.trim() !== "" && !waveActive && !rainbowActive && !spinActive && !tickerActive;
}
apply(context, controlValues, animState, renderData) {
const suffix = this.textLineNumber === 1 ? "" : "2";
// Check flash visibility - if flash is active and text is invisible, don't render
const flashActive = controlValues[`animate-text-flash${suffix}`];
if (flashActive && renderData[`textFlashVisible${suffix}`] === false) {
return;
}
const text = controlValues[`button-text${suffix}`];
if (!text) return;

View file

@ -0,0 +1,312 @@
import { ButtonEffect } from '../effect-base.js';
/**
* Ticker text animation effect
* Makes text scroll across the button in various directions with seamless looping
*/
export class TickerTextEffect extends ButtonEffect {
constructor(textLineNumber = 1) {
const suffix = textLineNumber === 1 ? '' : '2';
super({
id: `text-ticker${suffix}`,
name: `Ticker Text ${textLineNumber}`,
type: textLineNumber === 1 ? 'text' : 'text2',
category: textLineNumber === 1 ? 'Text Line 1' : 'Text Line 2',
renderOrder: 12, // After wave(10) and spin(8), before shadow(19) and standard(20)
textLineNumber: textLineNumber
});
this.textLineNumber = textLineNumber;
}
defineControls() {
const textLineNumber = this.textLineNumber || this.config?.textLineNumber || 1;
const suffix = textLineNumber === 1 ? '' : '2';
return [
{
id: `animate-text-ticker${suffix}`,
type: 'checkbox',
label: 'Ticker Scroll',
defaultValue: false
},
{
id: `ticker-direction${suffix}`,
type: 'select',
label: 'Scroll Direction',
defaultValue: 'left',
options: [
{ value: 'left', label: 'Right to Left' },
{ value: 'right', label: 'Left to Right' },
{ value: 'up', label: 'Down to Up' },
{ value: 'down', label: 'Up to Down' }
],
showWhen: `animate-text-ticker${suffix}`,
description: 'Direction of text scrolling'
}
];
}
isEnabled(controlValues) {
const suffix = this.textLineNumber === 1 ? '' : '2';
return controlValues[`animate-text-ticker${suffix}`] === true;
}
apply(context, controlValues, animState, renderData) {
if (!animState) return; // Ticker requires animation
const suffix = this.textLineNumber === 1 ? '' : '2';
// Check flash visibility - if flash is active and text is invisible, don't render
const flashActive = controlValues[`animate-text-flash${suffix}`];
if (flashActive && renderData[`textFlashVisible${suffix}`] === false) {
return;
}
// Get text configuration
const text = controlValues[`button-text${suffix}`] || '';
if (!text || text.trim() === '') return;
const fontSize = controlValues[`font-size${suffix}`] || 12;
const fontWeight = controlValues[`font-bold${suffix}`] ? 'bold' : 'normal';
const fontStyle = controlValues[`font-italic${suffix}`] ? 'italic' : 'normal';
const fontFamily = controlValues[`font-family${suffix}`] || 'Arial';
const baseX = (controlValues[`text${suffix}-x`] / 100) * renderData.width;
const baseY = (controlValues[`text${suffix}-y`] / 100) * renderData.height;
const direction = controlValues[`ticker-direction${suffix}`] || 'left';
// Set font
context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`;
context.textAlign = 'center';
context.textBaseline = 'middle';
// Check if other effects are active
const waveActive = controlValues[`animate-text-wave${suffix}`];
const spinActive = controlValues[`animate-text-spin${suffix}`];
const rainbowActive = controlValues[`animate-text-rainbow${suffix}`];
// Split text into grapheme clusters (handles emojis properly)
const chars = this.splitGraphemes(text);
// Measure total width
const totalWidth = context.measureText(text).width;
// Calculate scroll parameters - SIMPLIFIED
const horizontal = direction === 'left' || direction === 'right';
const gapSize = 50; // Gap between text repetitions
// For ticker to work: text must scroll across full screen width PLUS its own width PLUS gap
// This ensures text fully enters, crosses, and exits with proper spacing
const copySpacing = horizontal
? (renderData.width + totalWidth + gapSize)
: (renderData.height + fontSize * 2 + gapSize);
// For a seamless loop, offset scrolls through one full copy spacing in 40 frames
// At frame 0: offset = 0
// At frame 39: offset approaches copySpacing (ready to wrap to next copy)
const offset = animState.progress * copySpacing;
// Apply direction
const scrollOffset = {
x: direction === 'left' ? -offset : (direction === 'right' ? offset : 0),
y: direction === 'up' ? -offset : (direction === 'down' ? offset : 0)
};
// Calculate how many copies we need to fill the screen
const numCopies = horizontal
? Math.ceil(renderData.width / copySpacing) + 3
: Math.ceil(renderData.height / copySpacing) + 3;
// Get colors
const colors = this.getTextColors(context, controlValues, text, baseX, baseY, fontSize, animState, rainbowActive);
// Set ticker active flag so other effects can skip rendering if needed
renderData[`tickerActive${suffix}`] = true;
// Render scrolling text
this.renderScrollingText(
context, chars, scrollOffset, numCopies,
totalWidth, fontSize, copySpacing, horizontal, direction,
{ wave: waveActive, spin: spinActive, rainbow: rainbowActive },
controlValues, animState, renderData, colors
);
}
/**
* Split text into grapheme clusters (emoji-safe)
*/
splitGraphemes(text) {
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
return Array.from(segmenter.segment(text), s => s.segment);
} else {
// Fallback: spread operator handles basic emoji
return [...text];
}
}
/**
* Render scrolling text with multiple copies for seamless looping
*/
renderScrollingText(
context, chars, scrollOffset, numCopies,
totalWidth, fontSize, copySpacing, horizontal, direction,
effects, controlValues, animState, renderData, colors
) {
const suffix = this.textLineNumber === 1 ? '' : '2';
// Get wave parameters if active
let waveAmplitude, waveSpeed;
if (effects.wave) {
waveAmplitude = controlValues[`wave-amplitude${suffix}`] || 3;
waveSpeed = controlValues[`wave-speed${suffix}`] || 1;
}
// Get spin parameters if active
let spinSpeed, spinStagger;
if (effects.spin) {
spinSpeed = controlValues[`spin-speed${suffix}`] || 1;
spinStagger = controlValues[`spin-stagger${suffix}`] || 0.3;
}
// Get base positioning from controls
const baseX = (controlValues[`text${suffix}-x`] / 100) * renderData.width;
const baseY = (controlValues[`text${suffix}-y`] / 100) * renderData.height;
// Loop through copies - render multiple instances for seamless wrap
for (let copy = 0; copy < numCopies; copy++) {
// Each copy is spaced by copySpacing (which includes text width + gap)
const copyOffsetX = horizontal ? copy * copySpacing : 0;
const copyOffsetY = !horizontal ? copy * copySpacing : 0;
// Calculate starting position based on direction
// The key: text should be fully OFF screen before appearing on the other side
let startX, startY;
if (direction === 'left') {
// Right to left: Position so after scrolling copySpacing left, text fully exits
// copySpacing = totalWidth + gap
// Start with left edge at: copySpacing (so right edge is at copySpacing + totalWidth)
// After scrolling copySpacing left: right edge is at totalWidth (still need to exit more!)
// Actually: start at renderData.width so left edge begins at right screen edge
startX = renderData.width;
} else if (direction === 'right') {
// Left to right: Start with RIGHT edge of text at left edge of screen
// Text scrolls right, exits when LEFT edge reaches right edge of screen
startX = -totalWidth;
} else if (direction === 'up') {
// Down to up: Start off-screen below
startX = baseX - totalWidth / 2; // Center the text horizontally
startY = renderData.height;
} else { // down
// Up to down: Start off-screen above
startX = baseX - totalWidth / 2; // Center the text horizontally
startY = -fontSize * 2;
}
// Calculate current position with scroll offset and copy offset
let currentX = startX + scrollOffset.x + copyOffsetX;
let currentY = horizontal ? baseY : (startY + scrollOffset.y + copyOffsetY);
// Render each character
for (let i = 0; i < chars.length; i++) {
const char = chars[i];
const charWidth = context.measureText(char).width;
const charCenterX = currentX + charWidth / 2;
let charY = currentY;
// Apply wave effect if active
let waveY = 0;
if (effects.wave) {
const phase = animState.getPhase(waveSpeed);
const charOffset = i / chars.length;
waveY = Math.sin(phase + charOffset * Math.PI * 2) * waveAmplitude;
}
// Apply spin effect if active
if (effects.spin) {
const phase = animState.getPhase(spinSpeed);
const rotation = (phase + i * spinStagger * Math.PI * 2) % (Math.PI * 2);
context.save();
context.translate(charCenterX, charY + waveY);
context.rotate(rotation);
// Draw outline if enabled
if (controlValues[`text${suffix}-outline`]) {
context.strokeStyle = colors.strokeStyle;
context.lineWidth = 2;
context.strokeText(char, 0, 0);
}
// Draw character
context.fillStyle = colors.fillStyle;
context.fillText(char, 0, 0);
context.restore();
} else {
// No spin - draw normally with wave offset
const finalY = charY + waveY;
// Draw outline if enabled
if (controlValues[`text${suffix}-outline`]) {
context.strokeStyle = colors.strokeStyle;
context.lineWidth = 2;
context.strokeText(char, charCenterX, finalY);
}
// Draw character
context.fillStyle = colors.fillStyle;
context.fillText(char, charCenterX, finalY);
}
currentX += charWidth;
}
}
}
/**
* Get text colors (solid, gradient, or rainbow)
*/
getTextColors(context, controlValues, text, x, y, fontSize, animState, rainbowActive) {
const suffix = this.textLineNumber === 1 ? '' : '2';
let fillStyle, strokeStyle;
// Check if rainbow text is also enabled
if (animState && rainbowActive) {
const speed = controlValues[`text-rainbow-speed${suffix}`] || 1;
const hue = (animState.progress * speed * 360) % 360;
fillStyle = `hsl(${hue}, 80%, 60%)`;
strokeStyle = `hsl(${hue}, 80%, 30%)`;
} else {
const colorType = controlValues[`text${suffix}-color-type`] || 'solid';
if (colorType === 'solid') {
fillStyle = controlValues[`text${suffix}-color`] || '#ffffff';
strokeStyle = controlValues[`outline${suffix}-color`] || '#000000';
} else {
// Gradient
const angle = (controlValues[`text${suffix}-gradient-angle`] || 0) * (Math.PI / 180);
const textWidth = context.measureText(text).width;
const x1 = x - textWidth / 2 + (Math.cos(angle) * textWidth) / 2;
const y1 = y - fontSize / 2 + (Math.sin(angle) * fontSize) / 2;
const x2 = x + textWidth / 2 - (Math.cos(angle) * textWidth) / 2;
const y2 = y + fontSize / 2 - (Math.sin(angle) * fontSize) / 2;
const gradient = context.createLinearGradient(x1, y1, x2, y2);
gradient.addColorStop(0, controlValues[`text${suffix}-gradient-color1`] || '#ffffff');
gradient.addColorStop(1, controlValues[`text${suffix}-gradient-color2`] || '#00ffff');
fillStyle = gradient;
strokeStyle = controlValues[`outline${suffix}-color`] || '#000000';
}
}
return { fillStyle, strokeStyle };
}
}
// Auto-register effect
export function register(generator) {
generator.registerEffect(new TickerTextEffect(1));
generator.registerEffect(new TickerTextEffect(2));
}

View file

@ -63,6 +63,18 @@ export class WaveTextEffect extends ButtonEffect {
const suffix = this.textLineNumber === 1 ? '' : '2';
// Check if ticker is active - if so, ticker handles rendering
const tickerActive = controlValues[`animate-text-ticker${suffix}`];
if (tickerActive) {
return;
}
// Check flash visibility - if flash is active and text is invisible, don't render
const flashActive = controlValues[`animate-text-flash${suffix}`];
if (flashActive && renderData[`textFlashVisible${suffix}`] === false) {
return;
}
// Get text configuration
const text = controlValues[`button-text${suffix}`] || '';
if (!text || text.trim() === '') return;

View file

@ -25,7 +25,9 @@ import * as standardText from "./effects/text-standard.js";
import * as textShadow from "./effects/text-shadow.js";
import * as waveText from "./effects/wave-text.js";
import * as rainbowText from "./effects/rainbow-text.js";
import * as flashText from "./effects/flash-text.js";
import * as spinText from "./effects/spin-text.js";
import * as tickerText from "./effects/ticker-text.js";
import * as glitch from "./effects/glitch.js";
import * as pulse from "./effects/pulse.js";
import * as shimmer from "./effects/shimmer.js";
@ -101,7 +103,9 @@ async function setupApp() {
textShadow.register(generator);
waveText.register(generator);
rainbowText.register(generator);
flashText.register(generator);
spinText.register(generator);
tickerText.register(generator);
glitch.register(generator);
pulse.register(generator);
shimmer.register(generator);

View file

@ -16,8 +16,8 @@ export class UIBuilder {
*/
setupTooltip() {
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
this.createTooltipElement();
});
} else {
@ -26,8 +26,8 @@ export class UIBuilder {
}
createTooltipElement() {
this.tooltip = document.createElement('div');
this.tooltip.className = 'control-tooltip';
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%);
@ -57,20 +57,20 @@ export class UIBuilder {
clearTimeout(this.tooltipTimeout);
this.tooltip.textContent = text;
this.tooltip.style.opacity = '1';
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';
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';
this.tooltip.style.visibility = "visible";
let left = rect.left + rect.width / 2 - tooltipRect.width / 2;
let top = rect.top - tooltipRect.height - 10;
@ -96,7 +96,7 @@ export class UIBuilder {
if (!this.tooltip) return;
clearTimeout(this.tooltipTimeout);
this.tooltipTimeout = setTimeout(() => {
this.tooltip.style.opacity = '0';
this.tooltip.style.opacity = "0";
}, 100);
}
@ -106,17 +106,17 @@ export class UIBuilder {
addTooltipHandlers(element, description) {
if (!description) return;
element.addEventListener('mouseenter', () => {
element.addEventListener("mouseenter", () => {
this.showTooltip(element, description);
});
element.addEventListener('mouseleave', () => {
element.addEventListener("mouseleave", () => {
this.hideTooltip();
});
element.addEventListener('mousemove', () => {
element.addEventListener("mousemove", () => {
// Update position on mouse move for better following
if (this.tooltip && this.tooltip.style.opacity === '1') {
if (this.tooltip && this.tooltip.style.opacity === "1") {
this.showTooltip(element, description);
}
});
@ -128,7 +128,7 @@ export class UIBuilder {
*/
buildUI(effects) {
// Clear existing content
this.container.innerHTML = '';
this.container.innerHTML = "";
this.controlGroups.clear();
// Group effects by category
@ -148,8 +148,8 @@ export class UIBuilder {
categorizeEffects(effects) {
const categories = new Map();
effects.forEach(effect => {
const category = effect.category || 'Other';
effects.forEach((effect) => {
const category = effect.category || "Other";
if (!categories.has(category)) {
categories.set(category, []);
}
@ -159,17 +159,17 @@ export class UIBuilder {
// 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'
"Text Line 1",
"Text Line 2",
"Background",
"Background Animations",
"Border",
"Visual Effects",
"General Effects",
"Special Effects",
];
categoryOrder.forEach(cat => {
categoryOrder.forEach((cat) => {
if (categories.has(cat)) {
orderedCategories.set(cat, categories.get(cat));
}
@ -191,29 +191,29 @@ export class UIBuilder {
* @param {Array<ButtonEffect>} effects - Effects in this category
*/
createControlGroup(category, effects) {
const groupDiv = document.createElement('div');
groupDiv.className = 'control-group';
const groupDiv = document.createElement("div");
groupDiv.className = "control-group";
// Create header
const header = document.createElement('h3');
header.className = 'control-group-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';
const content = document.createElement("div");
content.className = "control-group-content";
// Add controls for each effect in this category
effects.forEach(effect => {
effects.forEach((effect) => {
this.addEffectControls(content, effect);
});
// Add click handler for collapsing
header.addEventListener('click', () => {
groupDiv.classList.toggle('collapsed');
header.addEventListener("click", () => {
groupDiv.classList.toggle("collapsed");
});
groupDiv.appendChild(header);
@ -229,7 +229,7 @@ export class UIBuilder {
* @param {ButtonEffect} effect - Effect to create controls for
*/
addEffectControls(container, effect) {
effect.controls.forEach(control => {
effect.controls.forEach((control) => {
const controlEl = this.createControl(control);
if (controlEl) {
container.appendChild(controlEl);
@ -243,26 +243,85 @@ export class UIBuilder {
* @returns {HTMLElement}
*/
createControl(controlDef) {
const { id, type, label, defaultValue, min, max, step, options, showWhen, description } = 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 "checkbox":
return this.createCheckbox(
id,
label,
defaultValue,
showWhen,
description,
);
case 'range':
return this.createRange(id, label, defaultValue, min, max, step, description, showWhen);
case "range":
return this.createRange(
id,
label,
defaultValue,
min,
max,
step,
description,
showWhen,
);
case 'color':
case "range-dual":
return this.createRangeDual(
id,
label,
controlDef.defaultValueStart,
controlDef.defaultValueEnd,
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 "select":
return this.createSelect(
id,
label,
defaultValue,
options,
showWhen,
description,
);
case 'text':
return this.createTextInput(id, label, defaultValue, showWhen, description);
case "text":
return this.createTextInput(
id,
label,
defaultValue,
showWhen,
description,
);
case 'file':
return this.createFileInput(id, label, defaultValue, showWhen, description, controlDef.accept);
case "file":
return this.createFileInput(
id,
label,
defaultValue,
showWhen,
description,
controlDef.accept,
);
default:
console.warn(`Unknown control type: ${type}`);
@ -274,22 +333,22 @@ export class UIBuilder {
* Create a checkbox control
*/
createCheckbox(id, label, defaultValue, showWhen, description) {
const wrapper = document.createElement('label');
wrapper.className = 'checkbox-label';
const wrapper = document.createElement("label");
wrapper.className = "checkbox-label";
const input = document.createElement('input');
input.type = 'checkbox';
const input = document.createElement("input");
input.type = "checkbox";
input.id = id;
input.checked = defaultValue || false;
const span = document.createElement('span');
const span = document.createElement("span");
span.textContent = label;
wrapper.appendChild(input);
wrapper.appendChild(span);
if (showWhen) {
wrapper.style.display = 'none';
wrapper.style.display = "none";
wrapper.dataset.showWhen = showWhen;
}
@ -303,14 +362,14 @@ export class UIBuilder {
* Create a range slider control
*/
createRange(id, label, defaultValue, min, max, step, description, showWhen) {
const container = document.createElement('div');
const container = document.createElement("div");
const labelEl = document.createElement('label');
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';
const input = document.createElement("input");
input.type = "range";
input.id = id;
input.min = min !== undefined ? min : 0;
input.max = max !== undefined ? max : 100;
@ -320,7 +379,7 @@ export class UIBuilder {
}
// Update value display on input
input.addEventListener('input', () => {
input.addEventListener("input", () => {
const valueDisplay = document.getElementById(`${id}-value`);
if (valueDisplay) {
valueDisplay.textContent = input.value;
@ -331,7 +390,7 @@ export class UIBuilder {
container.appendChild(input);
if (showWhen) {
container.style.display = 'none';
container.style.display = "none";
container.dataset.showWhen = showWhen;
}
@ -341,26 +400,239 @@ export class UIBuilder {
return container;
}
/**
* Create a dual-range slider control (two handles on one track)
*/
createRangeDual(
id,
label,
defaultValueStart,
defaultValueEnd,
min,
max,
step,
description,
showWhen,
) {
const container = document.createElement("div");
container.className = "range-dual-container";
const labelEl = document.createElement("label");
labelEl.innerHTML = `${label}: <span id="${id}-value">${defaultValueStart}-${defaultValueEnd}</span>`;
// Create wrapper for the dual range slider
const sliderWrapper = document.createElement("div");
sliderWrapper.className = "range-dual-wrapper";
sliderWrapper.style.cssText = `
position: relative;
height: 30px;
margin: 10px 0;
`;
// Create the track
const track = document.createElement("div");
track.className = "range-dual-track";
track.style.cssText = `
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 100%;
height: 4px;
background: #444;
border-radius: 2px;
z-index: 1;
`;
// Create the filled range indicator
const range = document.createElement("div");
range.className = "range-dual-range";
range.id = `${id}-range`;
range.style.cssText = `
position: absolute;
top: 50%;
transform: translateY(-50%);
height: 4px;
background: linear-gradient(90deg, #ff9966, #00bcf2); // Change this to orange
border-radius: 2px;
pointer-events: none;
z-index: 10;
`;
// Create start handle input
const inputStart = document.createElement("input");
inputStart.type = "range";
inputStart.id = `${id}-start`;
inputStart.className = "range-dual-input";
inputStart.min = min !== undefined ? min : 0;
inputStart.max = max !== undefined ? max : 100;
inputStart.value =
defaultValueStart !== undefined ? defaultValueStart : min || 0;
if (step !== undefined) {
inputStart.step = step;
}
inputStart.style.cssText = `
position: absolute;
width: 100%;
top: 50%;
transform: translateY(-50%);
-webkit-appearance: none;
appearance: none;
background: transparent;
pointer-events: none;
margin: 0;
z-index: 3;
`;
// Create end handle input
const inputEnd = document.createElement("input");
inputEnd.type = "range";
inputEnd.id = `${id}-end`;
inputEnd.className = "range-dual-input";
inputEnd.min = min !== undefined ? min : 0;
inputEnd.max = max !== undefined ? max : 100;
inputEnd.value =
defaultValueEnd !== undefined ? defaultValueEnd : max || 100;
if (step !== undefined) {
inputEnd.step = step;
}
inputEnd.style.cssText = `
position: absolute;
width: 100%;
top: 50%;
transform: translateY(-50%);
-webkit-appearance: none;
appearance: none;
background: transparent;
pointer-events: none;
margin: 0;
z-index: 4;
`;
// Add CSS for the range inputs
const style = document.createElement("style");
style.textContent = `
.range-dual-input::-webkit-slider-runnable-track {
background: transparent;
border: none;
height: 4px;
}
.range-dual-input::-moz-range-track {
background: transparent;
border: none;
height: 4px;
}
.range-dual-input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #0078d4;
cursor: pointer;
pointer-events: all;
border: 2px solid #fff;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
position: relative;
z-index: 100;
}
.range-dual-input::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #0078d4;
cursor: pointer;
pointer-events: all;
border: 2px solid #fff;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
position: relative;
z-index: 100;
}
.range-dual-input::-webkit-slider-thumb:hover {
background: #00bcf2;
}
.range-dual-input::-moz-range-thumb:hover {
background: #00bcf2;
}
`;
if (!document.getElementById("range-dual-styles")) {
style.id = "range-dual-styles";
document.head.appendChild(style);
}
// Update function
const updateRange = () => {
let startVal = parseInt(inputStart.value);
let endVal = parseInt(inputEnd.value);
// Ensure start is never greater than end
if (startVal > endVal) {
const temp = startVal;
startVal = endVal;
endVal = temp;
inputStart.value = startVal;
inputEnd.value = endVal;
}
const minVal = parseInt(inputStart.min);
const maxVal = parseInt(inputStart.max);
const startPercent = ((startVal - minVal) / (maxVal - minVal)) * 100;
const endPercent = ((endVal - minVal) / (maxVal - minVal)) * 100;
range.style.left = `${startPercent}%`;
range.style.width = `${endPercent - startPercent}%`;
const valueDisplay = document.getElementById(`${id}-value`);
if (valueDisplay) {
valueDisplay.textContent = `${startVal}-${endVal}`;
}
};
inputStart.addEventListener("input", updateRange);
inputEnd.addEventListener("input", updateRange);
// Assemble the dual range slider
sliderWrapper.appendChild(track);
sliderWrapper.appendChild(range);
sliderWrapper.appendChild(inputStart);
sliderWrapper.appendChild(inputEnd);
container.appendChild(labelEl);
container.appendChild(sliderWrapper);
if (showWhen) {
container.style.display = "none";
container.dataset.showWhen = showWhen;
}
// Add tooltip handlers to the label
this.addTooltipHandlers(labelEl, description);
// Initialize range display
updateRange();
return container;
}
/**
* Create a color picker control
*/
createColor(id, label, defaultValue, showWhen, description) {
const container = document.createElement('div');
const container = document.createElement("div");
const labelEl = document.createElement('label');
const labelEl = document.createElement("label");
labelEl.htmlFor = id;
labelEl.textContent = label;
const input = document.createElement('input');
input.type = 'color';
const input = document.createElement("input");
input.type = "color";
input.id = id;
input.value = defaultValue || '#ffffff';
input.value = defaultValue || "#ffffff";
container.appendChild(labelEl);
container.appendChild(input);
if (showWhen) {
container.style.display = 'none';
container.style.display = "none";
container.dataset.showWhen = showWhen;
}
@ -374,17 +646,17 @@ export class UIBuilder {
* Create a select dropdown control
*/
createSelect(id, label, defaultValue, options, showWhen, description) {
const container = document.createElement('div');
const container = document.createElement("div");
const labelEl = document.createElement('label');
const labelEl = document.createElement("label");
labelEl.htmlFor = id;
labelEl.textContent = label;
const select = document.createElement('select');
const select = document.createElement("select");
select.id = id;
options.forEach(opt => {
const option = document.createElement('option');
options.forEach((opt) => {
const option = document.createElement("option");
option.value = opt.value;
option.textContent = opt.label;
if (opt.value === defaultValue) {
@ -397,7 +669,7 @@ export class UIBuilder {
container.appendChild(select);
if (showWhen) {
container.style.display = 'none';
container.style.display = "none";
container.dataset.showWhen = showWhen;
}
@ -411,19 +683,19 @@ export class UIBuilder {
* Create a text input control
*/
createTextInput(id, label, defaultValue, showWhen, description) {
const container = document.createElement('div');
const container = document.createElement("div");
const labelEl = document.createElement('label');
const labelEl = document.createElement("label");
labelEl.htmlFor = id;
labelEl.textContent = label;
const input = document.createElement('input');
input.type = 'text';
const input = document.createElement("input");
input.type = "text";
input.id = id;
input.value = defaultValue || '';
input.value = defaultValue || "";
// Only set maxLength for text inputs that aren't URLs
if (id !== 'bg-image-url') {
if (id !== "bg-image-url") {
input.maxLength = 20;
}
@ -431,7 +703,7 @@ export class UIBuilder {
container.appendChild(input);
if (showWhen) {
container.style.display = 'none';
container.style.display = "none";
container.dataset.showWhen = showWhen;
}
@ -445,21 +717,21 @@ export class UIBuilder {
* Create a file input control
*/
createFileInput(id, label, defaultValue, showWhen, description, accept) {
const container = document.createElement('div');
const container = document.createElement("div");
const labelEl = document.createElement('label');
const labelEl = document.createElement("label");
labelEl.htmlFor = id;
labelEl.textContent = label;
const input = document.createElement('input');
input.type = 'file';
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) => {
input.addEventListener("change", (e) => {
const file = e.target.files[0];
if (file) {
// Create a blob URL for the file
@ -482,7 +754,7 @@ export class UIBuilder {
container.appendChild(input);
if (showWhen) {
container.style.display = 'none';
container.style.display = "none";
container.dataset.showWhen = showWhen;
}
@ -498,64 +770,103 @@ export class UIBuilder {
*/
setupConditionalVisibility() {
// Find all controls with showWhen attribute
const conditionalControls = this.container.querySelectorAll('[data-show-when]');
const conditionalControls =
this.container.querySelectorAll("[data-show-when]");
conditionalControls.forEach(control => {
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') {
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;
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';
if (triggerControlId === "bg-type") {
if (controlId === "bg-color") {
control.style.display =
triggerControl.value === "solid" ||
triggerControl.value === "external-image"
? "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';
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 === "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';
} 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 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';
control.style.display = triggerControl.value ? "block" : "none";
}
}
};
@ -564,8 +875,8 @@ export class UIBuilder {
updateVisibility();
// Update on change
triggerControl.addEventListener('change', updateVisibility);
triggerControl.addEventListener('input', updateVisibility);
triggerControl.addEventListener("change", updateVisibility);
triggerControl.addEventListener("input", updateVisibility);
}
});
}