From e7754141bfba9029d8c683c1b96a35bbe660bead Mon Sep 17 00:00:00 2001 From: Dan Date: Tue, 13 Jan 2026 12:34:41 +0000 Subject: [PATCH] Loads of button generator additions --- .../button-generator/button-generator-core.js | 8 + .../effects/background-external-image.js | 5 + .../effects/background-solid.js | 2 +- .../js/button-generator/effects/flash-text.js | 88 +++ .../button-generator/effects/rainbow-text.js | 12 + .../js/button-generator/effects/spin-text.js | 13 + .../button-generator/effects/text-shadow.js | 6 + .../button-generator/effects/text-standard.js | 9 +- .../button-generator/effects/ticker-text.js | 312 ++++++++++ .../js/button-generator/effects/wave-text.js | 12 + static/js/button-generator/main.js | 4 + static/js/button-generator/ui-builder.js | 559 ++++++++++++++---- 12 files changed, 904 insertions(+), 126 deletions(-) create mode 100644 static/js/button-generator/effects/flash-text.js create mode 100644 static/js/button-generator/effects/ticker-text.js diff --git a/static/js/button-generator/button-generator-core.js b/static/js/button-generator/button-generator-core.js index 292bc4f..2c5ba45 100644 --- a/static/js/button-generator/button-generator-core.js +++ b/static/js/button-generator/button-generator-core.js @@ -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; diff --git a/static/js/button-generator/effects/background-external-image.js b/static/js/button-generator/effects/background-external-image.js index d3ffc9d..28af29d 100644 --- a/static/js/button-generator/effects/background-external-image.js +++ b/static/js/button-generator/effects/background-external-image.js @@ -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'; diff --git a/static/js/button-generator/effects/background-solid.js b/static/js/button-generator/effects/background-solid.js index a835100..3c3b965 100644 --- a/static/js/button-generator/effects/background-solid.js +++ b/static/js/button-generator/effects/background-solid.js @@ -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)", }, ]; } diff --git a/static/js/button-generator/effects/flash-text.js b/static/js/button-generator/effects/flash-text.js new file mode 100644 index 0000000..bfd7a3d --- /dev/null +++ b/static/js/button-generator/effects/flash-text.js @@ -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)); +} diff --git a/static/js/button-generator/effects/rainbow-text.js b/static/js/button-generator/effects/rainbow-text.js index 5431dae..213f931 100644 --- a/static/js/button-generator/effects/rainbow-text.js +++ b/static/js/button-generator/effects/rainbow-text.js @@ -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; diff --git a/static/js/button-generator/effects/spin-text.js b/static/js/button-generator/effects/spin-text.js index 7c4b3ef..b50bada 100644 --- a/static/js/button-generator/effects/spin-text.js +++ b/static/js/button-generator/effects/spin-text.js @@ -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; diff --git a/static/js/button-generator/effects/text-shadow.js b/static/js/button-generator/effects/text-shadow.js index 1d7a882..09b141d 100644 --- a/static/js/button-generator/effects/text-shadow.js +++ b/static/js/button-generator/effects/text-shadow.js @@ -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; diff --git a/static/js/button-generator/effects/text-standard.js b/static/js/button-generator/effects/text-standard.js index fb90f87..c1ebf71 100644 --- a/static/js/button-generator/effects/text-standard.js +++ b/static/js/button-generator/effects/text-standard.js @@ -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; diff --git a/static/js/button-generator/effects/ticker-text.js b/static/js/button-generator/effects/ticker-text.js new file mode 100644 index 0000000..360f130 --- /dev/null +++ b/static/js/button-generator/effects/ticker-text.js @@ -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)); +} diff --git a/static/js/button-generator/effects/wave-text.js b/static/js/button-generator/effects/wave-text.js index 61e097c..f764d25 100644 --- a/static/js/button-generator/effects/wave-text.js +++ b/static/js/button-generator/effects/wave-text.js @@ -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; diff --git a/static/js/button-generator/main.js b/static/js/button-generator/main.js index 030236d..695507d 100644 --- a/static/js/button-generator/main.js +++ b/static/js/button-generator/main.js @@ -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); diff --git a/static/js/button-generator/ui-builder.js b/static/js/button-generator/ui-builder.js index 2ab5e1d..ed79f49 100644 --- a/static/js/button-generator/ui-builder.js +++ b/static/js/button-generator/ui-builder.js @@ -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} 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 = ` ${category} `; // 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}: ${defaultValue}`; - 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}: ${defaultValueStart}-${defaultValueEnd}`; + + // 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); } }); }