diff --git a/static/js/button-generator/effects/background-external-image.js b/static/js/button-generator/effects/background-external-image.js new file mode 100644 index 0000000..1e9fbf6 --- /dev/null +++ b/static/js/button-generator/effects/background-external-image.js @@ -0,0 +1,248 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * External Image Background Effect + * Loads an external image from a URL and displays it as the background + */ +export class ExternalImageBackgroundEffect extends ButtonEffect { + constructor() { + super({ + id: 'bg-external-image', + name: 'External Image Background', + type: 'background', + category: 'Background', + renderOrder: 1 + }); + + // Cache for loaded images + this.imageCache = new Map(); + this.loadingImages = new Map(); + this.generator = null; + + // Set up event listener for image loads + this.boundImageLoadHandler = this.handleImageLoad.bind(this); + window.addEventListener('imageLoaded', this.boundImageLoadHandler); + } + + /** + * Handle image load event + */ + handleImageLoad() { + // Trigger a redraw if we have a generator reference + if (this.generator) { + this.generator.updatePreview(); + } + } + + defineControls() { + return [ + { + id: 'bg-image-url', + type: 'text', + label: 'Image URL', + defaultValue: '', + showWhen: 'bg-type', + description: 'URL of the image to use as background' + }, + { + id: 'bg-image-fit', + type: 'select', + label: 'Image Fit', + defaultValue: 'cover', + options: [ + { value: 'cover', label: 'Cover (fill, crop if needed)' }, + { value: 'contain', label: 'Contain (fit inside)' }, + { value: 'stretch', label: 'Stretch (may distort)' }, + { value: 'center', label: 'Center (actual size)' } + ], + showWhen: 'bg-type', + description: 'How the image should fit in the canvas' + }, + { + id: 'bg-image-opacity', + type: 'range', + label: 'Image Opacity', + defaultValue: 1, + min: 0, + max: 1, + step: 0.05, + showWhen: 'bg-type', + description: 'Transparency of the background image' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['bg-type'] === 'external-image'; + } + + /** + * Start loading an image from a URL with caching + * Triggers async loading but returns immediately + * @param {string} url - Image URL + */ + startLoadingImage(url) { + // Skip if already cached or loading + if (this.imageCache.has(url) || this.loadingImages.has(url)) { + return; + } + + // Mark as loading to prevent duplicate requests + this.loadingImages.set(url, true); + + const img = new Image(); + + img.onload = () => { + this.imageCache.set(url, img); + this.loadingImages.delete(url); + // Trigger a redraw when image loads + const event = new CustomEvent('imageLoaded'); + window.dispatchEvent(event); + }; + + img.onerror = () => { + // Cache the error state to prevent retry spam + this.imageCache.set(url, 'ERROR'); + this.loadingImages.delete(url); + // Trigger a redraw to show error state + const event = new CustomEvent('imageLoaded'); + window.dispatchEvent(event); + }; + + img.src = url; + } + + /** + * Get cached image if available + * @param {string} url - Image URL + * @returns {HTMLImageElement|string|null} Image element, 'ERROR', or null + */ + getCachedImage(url) { + return this.imageCache.get(url) || null; + } + + /** + * Draw image with the specified fit mode + */ + drawImage(context, img, fitMode, opacity, width, height) { + context.globalAlpha = opacity; + + const imgRatio = img.width / img.height; + const canvasRatio = width / height; + + let drawX = 0; + let drawY = 0; + let drawWidth = width; + let drawHeight = height; + + switch (fitMode) { + case 'cover': + // Fill the canvas, cropping if necessary + if (imgRatio > canvasRatio) { + // Image is wider, fit height + drawHeight = height; + drawWidth = height * imgRatio; + drawX = (width - drawWidth) / 2; + } else { + // Image is taller, fit width + drawWidth = width; + drawHeight = width / imgRatio; + drawY = (height - drawHeight) / 2; + } + break; + + case 'contain': + // Fit inside the canvas, showing all of the image + if (imgRatio > canvasRatio) { + // Image is wider, fit width + drawWidth = width; + drawHeight = width / imgRatio; + drawY = (height - drawHeight) / 2; + } else { + // Image is taller, fit height + drawHeight = height; + drawWidth = height * imgRatio; + drawX = (width - drawWidth) / 2; + } + break; + + case 'center': + // Center the image at actual size + drawWidth = img.width; + drawHeight = img.height; + drawX = (width - drawWidth) / 2; + drawY = (height - drawHeight) / 2; + break; + + case 'stretch': + // Stretch to fill (default values already set) + break; + } + + context.drawImage(img, drawX, drawY, drawWidth, drawHeight); + context.globalAlpha = 1; // Reset alpha + } + + apply(context, controlValues, animState, renderData) { + const url = controlValues['bg-image-url'] || ''; + const fitMode = controlValues['bg-image-fit'] || 'cover'; + const opacity = controlValues['bg-image-opacity'] ?? 1; + + // If no URL provided, fill with a placeholder color + if (!url.trim()) { + context.fillStyle = '#cccccc'; + context.fillRect(0, 0, renderData.width, renderData.height); + + // Draw placeholder text + context.fillStyle = '#666666'; + context.font = '8px Arial'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText('Enter image URL', renderData.centerX, renderData.centerY); + return; + } + + // Start loading if not already cached or loading + const cachedImage = this.getCachedImage(url); + + if (!cachedImage) { + // Not cached yet - start loading + this.startLoadingImage(url); + + // Draw loading state + context.fillStyle = '#3498db'; + context.fillRect(0, 0, renderData.width, renderData.height); + + context.fillStyle = '#ffffff'; + context.font = '6px Arial'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText('Loading...', renderData.centerX, renderData.centerY); + return; + } + + if (cachedImage === 'ERROR') { + // Failed to load + context.fillStyle = '#ff6b6b'; + context.fillRect(0, 0, renderData.width, renderData.height); + + context.fillStyle = '#ffffff'; + context.font = '6px Arial'; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.fillText('Failed to load', renderData.centerX, renderData.centerY - 4); + context.fillText('image', renderData.centerX, renderData.centerY + 4); + return; + } + + // Image is loaded - draw it + this.drawImage(context, cachedImage, fitMode, opacity, renderData.width, renderData.height); + } +} + +// Auto-register effect +export function register(generator) { + const effect = new ExternalImageBackgroundEffect(); + effect.generator = generator; // Store reference for redraws + generator.registerEffect(effect); +} diff --git a/static/js/button-generator/effects/background-solid.js b/static/js/button-generator/effects/background-solid.js index f16cc68..49c2954 100644 --- a/static/js/button-generator/effects/background-solid.js +++ b/static/js/button-generator/effects/background-solid.js @@ -25,7 +25,8 @@ export class SolidBackgroundEffect extends ButtonEffect { { value: 'solid', label: 'Solid Color' }, { value: 'gradient', label: 'Gradient' }, { value: 'texture', label: 'Texture' }, - { value: 'emoji-wallpaper', label: 'Emoji Wallpaper' } + { value: 'emoji-wallpaper', label: 'Emoji Wallpaper' }, + { value: 'external-image', label: 'External Image' } ] }, { diff --git a/static/js/button-generator/main.js b/static/js/button-generator/main.js index 51233e8..c6061dc 100644 --- a/static/js/button-generator/main.js +++ b/static/js/button-generator/main.js @@ -13,6 +13,7 @@ import * as solidBg from "./effects/background-solid.js"; import * as gradientBg from "./effects/background-gradient.js"; import * as textureBg from "./effects/background-texture.js"; import * as emojiWallpaper from "./effects/background-emoji-wallpaper.js"; +import * as externalImage from "./effects/background-external-image.js"; import * as rainbowBg from "./effects/background-rainbow.js"; import * as rain from "./effects/background-rain.js"; import * as starfield from "./effects/background-starfield.js"; @@ -88,6 +89,7 @@ async function setupApp() { gradientBg.register(generator); textureBg.register(generator); emojiWallpaper.register(generator); + externalImage.register(generator); rainbowBg.register(generator); rain.register(generator); starfield.register(generator); diff --git a/static/js/button-generator/ui-builder.js b/static/js/button-generator/ui-builder.js index 00ce430..afafbbe 100644 --- a/static/js/button-generator/ui-builder.js +++ b/static/js/button-generator/ui-builder.js @@ -418,7 +418,11 @@ export class UIBuilder { input.type = 'text'; input.id = id; input.value = defaultValue || ''; - input.maxLength = 20; + + // Only set maxLength for text inputs that aren't URLs + if (id !== 'bg-image-url') { + input.maxLength = 20; + } container.appendChild(labelEl); container.appendChild(input); @@ -464,6 +468,8 @@ export class UIBuilder { 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-url' || controlId === 'bg-image-fit' || controlId === 'bg-image-opacity')) { + control.style.display = triggerControl.value === 'external-image' ? 'block' : 'none'; } } // For text color controls