From 4ac45367e5d25734db92a1280b733f7dd62475a6 Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 9 Jan 2026 09:15:05 +0000 Subject: [PATCH 1/2] Start of the rewrite to a modular system --- layouts/_default/baseof.html | 3 +- layouts/shortcodes/button-generator.html | 587 +---------------- .../shortcodes/button-generator.html.backup | 607 ++++++++++++++++++ .../button-generator/button-generator-core.js | 374 +++++++++++ static/js/button-generator/debug-helper.js | 26 + static/js/button-generator/effect-base.js | 92 +++ static/js/button-generator/effects/EXAMPLE.js | 205 ++++++ .../effects/background-emoji-wallpaper.js | 109 ++++ .../effects/background-gradient.js | 76 +++ .../effects/background-rain.js | 117 ++++ .../effects/background-rainbow.js | 137 ++++ .../effects/background-solid.js | 56 ++ .../effects/background-texture.js | 217 +++++++ static/js/button-generator/effects/border.js | 139 ++++ static/js/button-generator/effects/glitch.js | 93 +++ .../js/button-generator/effects/hologram.js | 170 +++++ static/js/button-generator/effects/noise.js | 68 ++ static/js/button-generator/effects/pulse.js | 62 ++ .../button-generator/effects/rainbow-text.js | 100 +++ .../js/button-generator/effects/rgb-split.js | 85 +++ static/js/button-generator/effects/rotate.js | 72 +++ .../js/button-generator/effects/scanline.js | 79 +++ static/js/button-generator/effects/shimmer.js | 57 ++ .../js/button-generator/effects/spin-text.js | 144 +++++ .../js/button-generator/effects/spotlight.js | 209 ++++++ .../button-generator/effects/text-standard.js | 232 +++++++ .../js/button-generator/effects/wave-text.js | 167 +++++ static/js/button-generator/main.js | 352 ++++++++++ static/js/button-generator/ui-builder.js | 367 +++++++++++ 29 files changed, 4414 insertions(+), 588 deletions(-) create mode 100644 layouts/shortcodes/button-generator.html.backup create mode 100644 static/js/button-generator/button-generator-core.js create mode 100644 static/js/button-generator/debug-helper.js create mode 100644 static/js/button-generator/effect-base.js create mode 100644 static/js/button-generator/effects/EXAMPLE.js create mode 100644 static/js/button-generator/effects/background-emoji-wallpaper.js create mode 100644 static/js/button-generator/effects/background-gradient.js create mode 100644 static/js/button-generator/effects/background-rain.js create mode 100644 static/js/button-generator/effects/background-rainbow.js create mode 100644 static/js/button-generator/effects/background-solid.js create mode 100644 static/js/button-generator/effects/background-texture.js create mode 100644 static/js/button-generator/effects/border.js create mode 100644 static/js/button-generator/effects/glitch.js create mode 100644 static/js/button-generator/effects/hologram.js create mode 100644 static/js/button-generator/effects/noise.js create mode 100644 static/js/button-generator/effects/pulse.js create mode 100644 static/js/button-generator/effects/rainbow-text.js create mode 100644 static/js/button-generator/effects/rgb-split.js create mode 100644 static/js/button-generator/effects/rotate.js create mode 100644 static/js/button-generator/effects/scanline.js create mode 100644 static/js/button-generator/effects/shimmer.js create mode 100644 static/js/button-generator/effects/spin-text.js create mode 100644 static/js/button-generator/effects/spotlight.js create mode 100644 static/js/button-generator/effects/text-standard.js create mode 100644 static/js/button-generator/effects/wave-text.js create mode 100644 static/js/button-generator/main.js create mode 100644 static/js/button-generator/ui-builder.js diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html index 0ac2b0f..bdd3455 100755 --- a/layouts/_default/baseof.html +++ b/layouts/_default/baseof.html @@ -37,8 +37,7 @@ {{ if or (findRE "{{<\\s*button-generator" .RawContent) (findRE "{{%\\s*button-generator" .RawContent) }} - {{ $buttonGenerator := resources.Get "js/button-generator.js" | resources.Minify | resources.Fingerprint }} - + {{ end }} diff --git a/layouts/shortcodes/button-generator.html b/layouts/shortcodes/button-generator.html index 6babacb..2f264b9 100644 --- a/layouts/shortcodes/button-generator.html +++ b/layouts/shortcodes/button-generator.html @@ -17,591 +17,6 @@ -
-
-

- Text Line 1 - -

-
- - - - - - - - - - - - - - - - - -
- - -
- - - - - - - - - -
- - -
-
-
- -
-

- Text Line 1 Animation - -

-
- - - - - -
-
- -
-

- Text Line 2 - -

-
- - - - - - - - - - - - - - - - - -
- - -
- - - - - - - - - -
- - -
-
-
- -
-

- Text Line 2 Animation - -

-
- - - - - -
-
- -
-

- Background - -

-
- - - -
- - -
- - - - -
-
- -
-

- Background Animation - -

-
- - - - -
-
- -
-

- Border - -

-
- - - - - - - - -
-
- -
-

- Special Effects - -

-
-

- Almost all animations should stack, so pick as many as you want. -

- - - - - - - - - - - - - - - - - - {{/* - - - */}} -
-
-
+
diff --git a/layouts/shortcodes/button-generator.html.backup b/layouts/shortcodes/button-generator.html.backup new file mode 100644 index 0000000..6babacb --- /dev/null +++ b/layouts/shortcodes/button-generator.html.backup @@ -0,0 +1,607 @@ +
+
+
+

Preview

+
+
+ +
+
+ + +
+

Presets

+ + + +
+
+ +
+
+

+ Text Line 1 + +

+
+ + + + + + + + + + + + + + + + + +
+ + +
+ + + + + + + + + +
+ + +
+
+
+ +
+

+ Text Line 1 Animation + +

+
+ + + + + +
+
+ +
+

+ Text Line 2 + +

+
+ + + + + + + + + + + + + + + + + +
+ + +
+ + + + + + + + + +
+ + +
+
+
+ +
+

+ Text Line 2 Animation + +

+
+ + + + + +
+
+ +
+

+ Background + +

+
+ + + +
+ + +
+ + + + +
+
+ +
+

+ Background Animation + +

+
+ + + + +
+
+ +
+

+ Border + +

+
+ + + + + + + + +
+
+ +
+

+ Special Effects + +

+
+

+ Almost all animations should stack, so pick as many as you want. +

+ + + + + + + + + + + + + + + + + + {{/* + + + */}} +
+
+
+
+
diff --git a/static/js/button-generator/button-generator-core.js b/static/js/button-generator/button-generator-core.js new file mode 100644 index 0000000..1c6126a --- /dev/null +++ b/static/js/button-generator/button-generator-core.js @@ -0,0 +1,374 @@ +import { ButtonEffect } from './effect-base.js'; + +/** + * Animation state class - passed to effects for frame-based rendering + */ +export class AnimationState { + constructor(frameNumber = 0, totalFrames = 40, fps = 20) { + this.frame = frameNumber; + this.totalFrames = totalFrames; + this.progress = frameNumber / totalFrames; // 0 to 1 + this.fps = fps; + this.time = (frameNumber / fps) * 1000; // milliseconds + } + + /** + * Helper to get phase for periodic animations (0 to 2π) + * @param {number} speed - Speed multiplier + * @returns {number} Phase in radians + */ + getPhase(speed = 1.0) { + return this.progress * speed * Math.PI * 2; + } +} + +/** + * Main ButtonGenerator class with effect registry system + */ +export class ButtonGenerator { + constructor(canvas, config = {}) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + + // Animation configuration + this.animConfig = { + fps: config.fps || 20, + duration: config.duration || 2, // seconds + get totalFrames() { + return this.fps * this.duration; + } + }; + + // Effect registry organized by type + this.effects = { + transform: [], + background: [], + border: [], + text: [], + text2: [], + general: [] + }; + + // Registered effects by ID for quick lookup + this.effectsById = new Map(); + + // Control elements cache + this.controlElements = {}; + + // Animation state + this.previewAnimationId = null; + + // Font list for preloading + this.fonts = config.fonts || [ + 'Lato', 'Roboto', 'Open Sans', 'Montserrat', 'Oswald', + 'Bebas Neue', 'Roboto Mono', 'VT323', 'Press Start 2P', 'DSEG7-Classic' + ]; + } + + /** + * Register an effect with the generator + * @param {ButtonEffect} effect - Effect instance to register + */ + registerEffect(effect) { + if (!(effect instanceof ButtonEffect)) { + throw new Error('Effect must extend ButtonEffect class'); + } + + if (this.effectsById.has(effect.id)) { + console.warn(`Effect with ID "${effect.id}" is already registered. Skipping.`); + return; + } + + // Add to type-specific array + const type = effect.type; + if (!this.effects[type]) { + this.effects[type] = []; + } + + this.effects[type].push(effect); + this.effectsById.set(effect.id, effect); + + // Sort by render order + this.effects[type].sort((a, b) => a.renderOrder - b.renderOrder); + + console.log(`Registered effect: ${effect.name} (${effect.id}) [${type}]`); + } + + /** + * Get all registered effects + * @returns {Array} + */ + getAllEffects() { + return Array.from(this.effectsById.values()); + } + + /** + * Get effects by type + * @param {string} type - Effect type + * @returns {Array} + */ + getEffectsByType(type) { + return this.effects[type] || []; + } + + /** + * Initialize and preload fonts + * @returns {Promise} + */ + async preloadFonts() { + const fontPromises = this.fonts.flatMap(font => [ + document.fonts.load(`400 12px "${font}"`), + document.fonts.load(`700 12px "${font}"`), + document.fonts.load(`italic 400 12px "${font}"`) + ]); + + await Promise.all(fontPromises); + console.log('All fonts loaded for canvas'); + } + + /** + * Get current control values from DOM + * @returns {Object} Map of control ID to value + */ + getControlValues() { + const values = {}; + + // Get all registered control IDs from effects + const allControls = new Set(); + this.getAllEffects().forEach(effect => { + effect.controls.forEach(control => { + allControls.add(control.id); + }); + }); + + // Read values from DOM + allControls.forEach(id => { + const element = document.getElementById(id); + if (element) { + if (element.type === 'checkbox') { + values[id] = element.checked; + } else if (element.type === 'range' || element.type === 'number') { + values[id] = parseFloat(element.value); + } else { + values[id] = element.value; + } + } + }); + + return values; + } + + /** + * Draw button with all effects applied + * @param {AnimationState} animState - Animation state (null for static) + * @param {Object} baseControls - Base button controls (text, colors, etc.) + */ + draw(animState = null, baseControls = {}) { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + const controlValues = { ...baseControls, ...this.getControlValues() }; + const renderData = { + width: this.canvas.width, + height: this.canvas.height, + centerX: this.canvas.width / 2, + centerY: this.canvas.height / 2 + }; + + // Apply effects in order: transform -> background -> border -> text/text2 -> general + const renderOrder = ['transform', 'background', 'border', 'text', 'text2', 'general']; + + // Save context once before transforms + this.ctx.save(); + + renderOrder.forEach(type => { + this.effects[type]?.forEach(effect => { + if (effect.canApply(controlValues)) { + // Transform effects should NOT be wrapped in save/restore + // They need to persist for all subsequent drawing operations + if (type !== 'transform') { + this.ctx.save(); + } + + try { + effect.apply(this.ctx, controlValues, animState, renderData); + } catch (error) { + console.error(`Error applying effect ${effect.id}:`, error); + } + + if (type !== 'transform') { + this.ctx.restore(); + } + } + }); + }); + + // Restore context once after all drawing + this.ctx.restore(); + } + + /** + * Check if any animations are enabled + * @returns {boolean} + */ + hasAnimationsEnabled() { + const controlValues = this.getControlValues(); + return this.getAllEffects().some(effect => + effect.type !== 'background' && // Background effects can be static + effect.isEnabled(controlValues) + ); + } + + /** + * Start animated preview loop + */ + startAnimatedPreview() { + this.stopAnimatedPreview(); + + let frameNum = 0; + let lastFrameTime = performance.now(); + const frameDelay = 1000 / this.animConfig.fps; + + const animate = (currentTime) => { + const elapsed = currentTime - lastFrameTime; + + if (elapsed >= frameDelay) { + const animState = new AnimationState( + frameNum, + this.animConfig.totalFrames, + this.animConfig.fps + ); + this.draw(animState); + + frameNum = (frameNum + 1) % this.animConfig.totalFrames; + lastFrameTime = currentTime - (elapsed % frameDelay); + } + + this.previewAnimationId = requestAnimationFrame(animate); + }; + + this.previewAnimationId = requestAnimationFrame(animate); + } + + /** + * Stop animated preview + */ + stopAnimatedPreview() { + if (this.previewAnimationId) { + cancelAnimationFrame(this.previewAnimationId); + this.previewAnimationId = null; + } + } + + /** + * Update preview (static or animated based on settings) + */ + updatePreview() { + if (this.hasAnimationsEnabled()) { + this.startAnimatedPreview(); + } else { + this.stopAnimatedPreview(); + this.draw(); + } + } + + /** + * Export as animated GIF + * @param {Function} progressCallback - Called with progress (0-1) + * @returns {Promise} + */ + async exportAsGif(progressCallback = null) { + return new Promise((resolve, reject) => { + try { + // Create temporary canvas for frame generation + const frameCanvas = document.createElement('canvas'); + frameCanvas.width = this.canvas.width; + frameCanvas.height = this.canvas.height; + const frameCtx = frameCanvas.getContext('2d'); + + // Initialize gif.js + const gif = new GIF({ + workers: 2, + quality: 10, + workerScript: '/js/gif.worker.js', + width: this.canvas.width, + height: this.canvas.height + }); + + // Generate frames + const totalFrames = this.animConfig.totalFrames; + + const generateFrames = async () => { + for (let i = 0; i < totalFrames; i++) { + const animState = new AnimationState(i, totalFrames, this.animConfig.fps); + + // Draw to temporary canvas + frameCtx.clearRect(0, 0, frameCanvas.width, frameCanvas.height); + const tempGenerator = new ButtonGenerator(frameCanvas, { + fps: this.animConfig.fps, + duration: this.animConfig.duration, + fonts: this.fonts + }); + + // Copy effects + this.getAllEffects().forEach(effect => { + tempGenerator.registerEffect(effect); + }); + + tempGenerator.draw(animState); + + gif.addFrame(frameCtx, { + delay: 1000 / this.animConfig.fps, + copy: true + }); + + if (progressCallback) { + progressCallback(i / totalFrames, 'generating'); + } + + // Yield to browser every 5 frames + if (i % 5 === 0) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + } + }; + + generateFrames().then(() => { + gif.on('finished', (blob) => { + resolve(blob); + }); + + gif.on('progress', (progress) => { + if (progressCallback) { + progressCallback(progress, 'encoding'); + } + }); + + gif.render(); + }); + + } catch (error) { + reject(error); + } + }); + } + + /** + * Bind UI controls to redraw on change + */ + bindControls() { + const allControls = new Set(); + this.getAllEffects().forEach(effect => { + effect.controls.forEach(control => { + allControls.add(control.id); + }); + }); + + allControls.forEach(id => { + const element = document.getElementById(id); + if (element) { + element.addEventListener('input', () => this.updatePreview()); + element.addEventListener('change', () => this.updatePreview()); + } + }); + } +} diff --git a/static/js/button-generator/debug-helper.js b/static/js/button-generator/debug-helper.js new file mode 100644 index 0000000..ae4e97d --- /dev/null +++ b/static/js/button-generator/debug-helper.js @@ -0,0 +1,26 @@ +// Debug helper - add this temporarily to main.js to see what's happening + +export function debugControlValues(generator) { + console.log('=== DEBUG: Control Values ==='); + const values = generator.getControlValues(); + + // Text controls + console.log('Text controls:'); + console.log(' button-text:', values['button-text']); + console.log(' text-enabled:', values['text-enabled']); + console.log(' font-size:', values['font-size']); + console.log(' text-x:', values['text-x']); + console.log(' text-y:', values['text-y']); + + // Check which effects are enabled + console.log('\nEnabled effects:'); + generator.getAllEffects().forEach(effect => { + const enabled = effect.isEnabled(values); + if (enabled) { + console.log(` ✓ ${effect.name} (${effect.id})`); + } + }); + + console.log('\nAll control values:', values); + console.log('=== END DEBUG ==='); +} diff --git a/static/js/button-generator/effect-base.js b/static/js/button-generator/effect-base.js new file mode 100644 index 0000000..87489cc --- /dev/null +++ b/static/js/button-generator/effect-base.js @@ -0,0 +1,92 @@ +/** + * Base class for button generator effects + * All effects should extend this class and implement required methods + */ +export class ButtonEffect { + /** + * @param {Object} config - Effect configuration + * @param {string} config.id - Unique identifier for this effect + * @param {string} config.name - Display name for the effect + * @param {string} config.type - Effect type: 'text', 'text2', 'background', 'general', 'border' + * @param {string} config.category - UI category for grouping effects + * @param {number} config.renderOrder - Order in rendering pipeline (lower = earlier) + */ + constructor(config) { + this.config = config; // Store full config for subclasses to access + this.id = config.id; + this.name = config.name; + this.type = config.type; // 'text', 'text2', 'background', 'general', 'border' + this.category = config.category; + this.renderOrder = config.renderOrder || 100; + this.controls = this.defineControls(); + } + + /** + * Define UI controls for this effect + * @returns {Array} Array of control definitions + * + * Control definition format: + * { + * id: string, // HTML element ID + * type: 'checkbox' | 'range' | 'color' | 'select' | 'text', + * label: string, // Display label + * defaultValue: any, // Default value + * min: number, // For range controls + * max: number, // For range controls + * step: number, // For range controls + * options: Array<{value, label}>, // For select controls + * showWhen: string, // ID of checkbox that controls visibility + * description: string // Optional tooltip/help text + * } + */ + defineControls() { + return []; + } + + /** + * Check if this effect is currently enabled + * @param {Object} controlValues - Current values of all controls + * @returns {boolean} + */ + isEnabled(controlValues) { + // Default: check for a control with ID pattern 'animate-{effectId}' or '{effectId}-enabled' + const enableControl = controlValues[`animate-${this.id}`] || + controlValues[`${this.id}-enabled`]; + return enableControl === true || enableControl === 'true'; + } + + /** + * Apply the effect during rendering + * @param {CanvasRenderingContext2D} context - Canvas context to draw on + * @param {Object} controlValues - Current values of all controls + * @param {AnimationState} animState - Current animation state (null for static) + * @param {Object} renderData - Additional render data (text metrics, colors, etc.) + */ + apply(context, controlValues, animState, renderData) { + throw new Error('Effect.apply() must be implemented by subclass'); + } + + /** + * Get control values specific to this effect + * @param {Object} allControlValues - All control values + * @returns {Object} Only the controls relevant to this effect + */ + getEffectControls(allControlValues) { + const effectControls = {}; + this.controls.forEach(control => { + if (control.id in allControlValues) { + effectControls[control.id] = allControlValues[control.id]; + } + }); + return effectControls; + } + + /** + * Validate that this effect can be applied + * @param {Object} controlValues - Current values of all controls + * @returns {boolean} + */ + canApply(controlValues) { + return this.isEnabled(controlValues); + } +} diff --git a/static/js/button-generator/effects/EXAMPLE.js b/static/js/button-generator/effects/EXAMPLE.js new file mode 100644 index 0000000..c2126d0 --- /dev/null +++ b/static/js/button-generator/effects/EXAMPLE.js @@ -0,0 +1,205 @@ +/** + * EXAMPLE EFFECT + * + * This is a template for creating new effects. + * Copy this file and modify it to create your own custom effects. + * + * This example creates a "spotlight" effect that highlights a circular area + * and darkens the rest of the button. + */ + +import { ButtonEffect } from '../effect-base.js'; + +/** + * Spotlight Effect + * Creates a moving circular spotlight that highlights different areas + */ +export class SpotlightEffect extends ButtonEffect { + constructor() { + super({ + // Unique ID for this effect (used in control IDs) + id: 'spotlight', + + // Display name shown in UI + name: 'Spotlight', + + // Effect type determines render order category + // Options: 'background', 'border', 'text', 'text2', 'general' + type: 'general', + + // Category for organizing effects in UI + category: 'Visual Effects', + + // Render order within type (lower = earlier) + // 1-9: backgrounds, 10-19: borders, 20-29: transforms, + // 30-49: text, 50-79: overlays, 80-99: post-processing + renderOrder: 60 + }); + } + + /** + * Define UI controls for this effect + * These controls will be automatically bound to the generator + */ + defineControls() { + return [ + // Main enable/disable checkbox + { + id: 'animate-spotlight', + type: 'checkbox', + label: 'Spotlight Effect', + defaultValue: false, + description: 'Moving circular spotlight' + }, + + // Spotlight size control + { + id: 'spotlight-size', + type: 'range', + label: 'Spotlight Size', + defaultValue: 20, + min: 10, + max: 50, + step: 1, + showWhen: 'animate-spotlight', // Only show when checkbox is enabled + description: 'Radius of the spotlight' + }, + + // Darkness of the vignette + { + id: 'spotlight-darkness', + type: 'range', + label: 'Darkness', + defaultValue: 0.5, + min: 0, + max: 1, + step: 0.05, + showWhen: 'animate-spotlight', + description: 'How dark the non-spotlight area should be' + }, + + // Speed of movement + { + id: 'spotlight-speed', + type: 'range', + label: 'Movement Speed', + defaultValue: 1, + min: 0.1, + max: 3, + step: 0.1, + showWhen: 'animate-spotlight', + description: 'Speed of spotlight movement' + } + ]; + } + + /** + * Determine if this effect should be applied + * @param {Object} controlValues - Current values of all controls + * @returns {boolean} + */ + isEnabled(controlValues) { + return controlValues['animate-spotlight'] === true; + } + + /** + * Apply the effect to the canvas + * + * @param {CanvasRenderingContext2D} context - Canvas 2D rendering context + * @param {Object} controlValues - Current values of all controls + * @param {AnimationState|null} animState - Animation state (null for static render) + * @param {Object} renderData - Render information: { width, height, centerX, centerY } + */ + apply(context, controlValues, animState, renderData) { + // Skip if no animation (spotlight needs movement) + if (!animState) return; + + // Get control values + const size = controlValues['spotlight-size'] || 20; + const darkness = controlValues['spotlight-darkness'] || 0.5; + const speed = controlValues['spotlight-speed'] || 1; + + // Calculate spotlight position + // Move in a circular pattern using animation phase + const phase = animState.getPhase(speed); + const spotX = renderData.centerX + Math.cos(phase) * 20; + const spotY = renderData.centerY + Math.sin(phase) * 10; + + // Create radial gradient for spotlight effect + const gradient = context.createRadialGradient( + spotX, spotY, 0, // Inner circle (center of spotlight) + spotX, spotY, size // Outer circle (edge of spotlight) + ); + + // Center is transparent (spotlight is bright) + gradient.addColorStop(0, `rgba(0, 0, 0, 0)`); + // Edge fades to dark + gradient.addColorStop(0.5, `rgba(0, 0, 0, ${darkness * 0.3})`); + gradient.addColorStop(1, `rgba(0, 0, 0, ${darkness})`); + + // Apply the gradient as an overlay + context.fillStyle = gradient; + context.fillRect(0, 0, renderData.width, renderData.height); + + // Optional: Add a bright center dot + context.fillStyle = 'rgba(255, 255, 255, 0.3)'; + context.beginPath(); + context.arc(spotX, spotY, 2, 0, Math.PI * 2); + context.fill(); + } + + /** + * Optional: Override canApply for more complex logic + * By default, it just checks isEnabled() + */ + canApply(controlValues) { + // Example: Only apply if text is also enabled + const textEnabled = controlValues['textEnabled']; + return this.isEnabled(controlValues) && textEnabled; + } + + /** + * Optional: Add helper methods for your effect + */ + calculateSpotlightPath(progress, width, height) { + // Example helper method + return { + x: width * progress, + y: height / 2 + }; + } +} + +/** + * Registration function + * This is called to add the effect to the generator + * + * @param {ButtonGenerator} generator - The button generator instance + */ +export function register(generator) { + generator.registerEffect(new SpotlightEffect()); +} + +/** + * USAGE: + * + * 1. Copy this file to a new name (e.g., my-effect.js) + * 2. Modify the class name, id, and effect logic + * 3. Import in main.js: + * import * as myEffect from './effects/my-effect.js'; + * 4. Register in setupApp(): + * myEffect.register(generator); + * 5. Add HTML controls with matching IDs + */ + +/** + * TIPS: + * + * - Use animState.progress for linear animations (0 to 1) + * - Use animState.getPhase(speed) for periodic animations (0 to 2π) + * - Use Math.sin/cos for smooth periodic motion + * - Check if (!animState) at the start if your effect requires animation + * - The context is automatically saved/restored, so feel free to transform + * - Use renderData for canvas dimensions and center point + * - Look at existing effects for more examples + */ diff --git a/static/js/button-generator/effects/background-emoji-wallpaper.js b/static/js/button-generator/effects/background-emoji-wallpaper.js new file mode 100644 index 0000000..e6c38a4 --- /dev/null +++ b/static/js/button-generator/effects/background-emoji-wallpaper.js @@ -0,0 +1,109 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Emoji wallpaper background effect + * Tiles a user-specified emoji across the background + */ +export class EmojiWallpaperEffect extends ButtonEffect { + constructor() { + super({ + id: 'bg-emoji-wallpaper', + name: 'Emoji Wallpaper', + type: 'background', + category: 'Background', + renderOrder: 1 + }); + } + + defineControls() { + return [ + { + id: 'emoji-text', + type: 'text', + label: 'Emoji Character', + defaultValue: '✨', + showWhen: 'bg-type', + description: 'Emoji to tile (can be any text)' + }, + { + id: 'emoji-size', + type: 'range', + label: 'Emoji Size', + defaultValue: 12, + min: 6, + max: 24, + step: 1, + showWhen: 'bg-type', + description: 'Size of each emoji' + }, + { + id: 'emoji-spacing', + type: 'range', + label: 'Emoji Spacing', + defaultValue: 16, + min: 8, + max: 32, + step: 2, + showWhen: 'bg-type', + description: 'Space between emojis' + }, + { + id: 'emoji-bg-color', + type: 'color', + label: 'Background Color', + defaultValue: '#1a1a2e', + showWhen: 'bg-type', + description: 'Background color behind emojis' + }, + { + id: 'emoji-opacity', + type: 'range', + label: 'Emoji Opacity', + defaultValue: 30, + min: 10, + max: 100, + step: 5, + showWhen: 'bg-type', + description: 'Transparency of emojis (lower = more transparent)' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['bg-type'] === 'emoji-wallpaper'; + } + + apply(context, controlValues, animState, renderData) { + const emoji = controlValues['emoji-text'] || '✨'; + const size = controlValues['emoji-size'] || 12; + const spacing = controlValues['emoji-spacing'] || 16; + const bgColor = controlValues['emoji-bg-color'] || '#1a1a2e'; + const opacity = (controlValues['emoji-opacity'] || 30) / 100; + + // Fill background color + context.fillStyle = bgColor; + context.fillRect(0, 0, renderData.width, renderData.height); + + // Setup emoji font + context.font = `${size}px Arial`; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + context.globalAlpha = opacity; + + // Tile emojis + for (let y = 0; y < renderData.height + spacing; y += spacing) { + for (let x = 0; x < renderData.width + spacing; x += spacing) { + // Offset every other row for a brick pattern + const offsetX = (Math.floor(y / spacing) % 2) * (spacing / 2); + context.fillText(emoji, x + offsetX, y); + } + } + + context.globalAlpha = 1.0; + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new EmojiWallpaperEffect()); +} diff --git a/static/js/button-generator/effects/background-gradient.js b/static/js/button-generator/effects/background-gradient.js new file mode 100644 index 0000000..4e8c8c8 --- /dev/null +++ b/static/js/button-generator/effects/background-gradient.js @@ -0,0 +1,76 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Gradient background effect + */ +export class GradientBackgroundEffect extends ButtonEffect { + constructor() { + super({ + id: 'bg-gradient', + name: 'Gradient Background', + type: 'background', + category: 'Background', + renderOrder: 1 + }); + } + + defineControls() { + return [ + { + id: 'gradient-color1', + type: 'color', + label: 'Gradient Color 1', + defaultValue: '#ff0000', + showWhen: 'bg-type', + description: 'Start color of gradient' + }, + { + id: 'gradient-color2', + type: 'color', + label: 'Gradient Color 2', + defaultValue: '#0000ff', + showWhen: 'bg-type', + description: 'End color of gradient' + }, + { + id: 'gradient-angle', + type: 'range', + label: 'Gradient Angle', + defaultValue: 90, + min: 0, + max: 360, + step: 1, + showWhen: 'bg-type', + description: 'Angle of gradient direction' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['bg-type'] === 'gradient'; + } + + apply(context, controlValues, animState, renderData) { + const color1 = controlValues['gradient-color1'] || '#ff0000'; + const color2 = controlValues['gradient-color2'] || '#0000ff'; + const angle = (controlValues['gradient-angle'] || 90) * (Math.PI / 180); + + // Calculate gradient endpoints + const x1 = renderData.centerX + Math.cos(angle) * renderData.centerX; + const y1 = renderData.centerY + Math.sin(angle) * renderData.centerY; + const x2 = renderData.centerX - Math.cos(angle) * renderData.centerX; + const y2 = renderData.centerY - Math.sin(angle) * renderData.centerY; + + const gradient = context.createLinearGradient(x1, y1, x2, y2); + gradient.addColorStop(0, color1); + gradient.addColorStop(1, color2); + + context.fillStyle = gradient; + context.fillRect(0, 0, renderData.width, renderData.height); + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new GradientBackgroundEffect()); +} diff --git a/static/js/button-generator/effects/background-rain.js b/static/js/button-generator/effects/background-rain.js new file mode 100644 index 0000000..fc3a609 --- /dev/null +++ b/static/js/button-generator/effects/background-rain.js @@ -0,0 +1,117 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Raining background effect + * Animated raindrops falling down the button + */ +export class RainBackgroundEffect extends ButtonEffect { + constructor() { + super({ + id: 'bg-rain', + name: 'Rain Effect', + type: 'general', + category: 'Background Animations', + renderOrder: 55 // After background, before other effects + }); + + // Initialize raindrop positions (persistent across frames) + this.raindrops = []; + this.initialized = false; + } + + defineControls() { + return [ + { + id: 'animate-rain', + type: 'checkbox', + label: 'Rain Effect', + defaultValue: false + }, + { + id: 'rain-density', + type: 'range', + label: 'Rain Density', + defaultValue: 15, + min: 5, + max: 30, + step: 1, + showWhen: 'animate-rain', + description: 'Number of raindrops' + }, + { + id: 'rain-speed', + type: 'range', + label: 'Rain Speed', + defaultValue: 1.5, + min: 0.5, + max: 3, + step: 0.1, + showWhen: 'animate-rain', + description: 'Speed of falling rain' + }, + { + id: 'rain-color', + type: 'color', + label: 'Rain Color', + defaultValue: '#6ba3ff', + showWhen: 'animate-rain', + description: 'Color of raindrops' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-rain'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const density = controlValues['rain-density'] || 15; + const speed = controlValues['rain-speed'] || 1.5; + const color = controlValues['rain-color'] || '#6ba3ff'; + + // Initialize raindrops on first frame + if (!this.initialized || this.raindrops.length !== density) { + this.raindrops = []; + for (let i = 0; i < density; i++) { + this.raindrops.push({ + x: Math.random() * renderData.width, + y: Math.random() * renderData.height, + length: 2 + Math.random() * 4, + speedMultiplier: 0.8 + Math.random() * 0.4 + }); + } + this.initialized = true; + } + + // Draw raindrops + context.strokeStyle = color; + context.lineWidth = 1; + context.lineCap = 'round'; + + this.raindrops.forEach(drop => { + // Update position + drop.y += speed * drop.speedMultiplier; + + // Reset to top when reaching bottom + if (drop.y > renderData.height + drop.length) { + drop.y = -drop.length; + drop.x = Math.random() * renderData.width; + } + + // Draw raindrop + context.globalAlpha = 0.6; + context.beginPath(); + context.moveTo(drop.x, drop.y); + context.lineTo(drop.x, drop.y + drop.length); + context.stroke(); + context.globalAlpha = 1.0; + }); + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new RainBackgroundEffect()); +} diff --git a/static/js/button-generator/effects/background-rainbow.js b/static/js/button-generator/effects/background-rainbow.js new file mode 100644 index 0000000..07dae99 --- /dev/null +++ b/static/js/button-generator/effects/background-rainbow.js @@ -0,0 +1,137 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Rainbow flash background effect + * Animates background through rainbow colors + */ +export class RainbowBackgroundEffect extends ButtonEffect { + constructor() { + super({ + id: 'bg-rainbow', + name: 'Rainbow Background', + type: 'background', + category: 'Background Animations', + renderOrder: 2 // After base background + }); + } + + defineControls() { + return [ + { + id: 'animate-bg-rainbow', + type: 'checkbox', + label: 'Rainbow Flash', + defaultValue: false + }, + { + id: 'rainbow-speed', + type: 'range', + label: 'Rainbow Speed', + defaultValue: 1, + min: 0.1, + max: 5, + step: 0.1, + showWhen: 'animate-bg-rainbow', + description: 'Speed of rainbow cycling' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-bg-rainbow'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const speed = controlValues['rainbow-speed'] || 1; + const hue = (animState.progress * speed * 360) % 360; + + const bgType = controlValues['bg-type']; + + if (bgType === 'solid') { + // Solid rainbow + context.fillStyle = `hsl(${hue}, 70%, 50%)`; + context.fillRect(0, 0, renderData.width, renderData.height); + } else if (bgType === 'gradient') { + // Rainbow gradient + const angle = (controlValues['gradient-angle'] || 90) * (Math.PI / 180); + const x1 = renderData.centerX + Math.cos(angle) * renderData.centerX; + const y1 = renderData.centerY + Math.sin(angle) * renderData.centerY; + const x2 = renderData.centerX - Math.cos(angle) * renderData.centerX; + const y2 = renderData.centerY - Math.sin(angle) * renderData.centerY; + + const gradient = context.createLinearGradient(x1, y1, x2, y2); + gradient.addColorStop(0, `hsl(${hue}, 70%, 50%)`); + gradient.addColorStop(1, `hsl(${(hue + 60) % 360}, 70%, 60%)`); + + context.fillStyle = gradient; + context.fillRect(0, 0, renderData.width, renderData.height); + } + } +} + +/** + * Rainbow gradient sweep effect + * Creates a moving rainbow gradient that sweeps across the button + */ +export class RainbowGradientSweepEffect extends ButtonEffect { + constructor() { + super({ + id: 'bg-rainbow-gradient', + name: 'Rainbow Gradient Sweep', + type: 'general', + category: 'Background Animations', + renderOrder: 50 // After background and text + }); + } + + defineControls() { + return [ + { + id: 'animate-bg-rainbow-gradient', + type: 'checkbox', + label: 'Rainbow Sweep', + defaultValue: false, + description: 'Moving rainbow gradient overlay' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-bg-rainbow-gradient'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + // Map progress to position (-100 to 100) + const position = animState.progress * 200 - 100; + + // Create a horizontal gradient that sweeps across + const gradient = context.createLinearGradient( + position - 50, + 0, + position + 50, + 0 + ); + + // Create rainbow stops that also cycle through colors + const hueOffset = animState.progress * 360; + gradient.addColorStop(0, `hsla(${(hueOffset + 0) % 360}, 80%, 50%, 0)`); + gradient.addColorStop(0.2, `hsla(${(hueOffset + 60) % 360}, 80%, 50%, 0.6)`); + gradient.addColorStop(0.4, `hsla(${(hueOffset + 120) % 360}, 80%, 50%, 0.8)`); + gradient.addColorStop(0.6, `hsla(${(hueOffset + 180) % 360}, 80%, 50%, 0.8)`); + gradient.addColorStop(0.8, `hsla(${(hueOffset + 240) % 360}, 80%, 50%, 0.6)`); + gradient.addColorStop(1, `hsla(${(hueOffset + 300) % 360}, 80%, 50%, 0)`); + + context.fillStyle = gradient; + context.fillRect(0, 0, renderData.width, renderData.height); + } +} + +// Auto-register effects +export function register(generator) { + generator.registerEffect(new RainbowBackgroundEffect()); + generator.registerEffect(new RainbowGradientSweepEffect()); +} diff --git a/static/js/button-generator/effects/background-solid.js b/static/js/button-generator/effects/background-solid.js new file mode 100644 index 0000000..f16cc68 --- /dev/null +++ b/static/js/button-generator/effects/background-solid.js @@ -0,0 +1,56 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Solid color background effect + */ +export class SolidBackgroundEffect extends ButtonEffect { + constructor() { + super({ + id: 'bg-solid', + name: 'Solid Background', + type: 'background', + category: 'Background', + renderOrder: 1 + }); + } + + defineControls() { + return [ + { + id: 'bg-type', + type: 'select', + label: 'Background Type', + defaultValue: 'solid', + options: [ + { value: 'solid', label: 'Solid Color' }, + { value: 'gradient', label: 'Gradient' }, + { value: 'texture', label: 'Texture' }, + { value: 'emoji-wallpaper', label: 'Emoji Wallpaper' } + ] + }, + { + id: 'bg-color', + type: 'color', + label: 'Background Color', + defaultValue: '#4a90e2', + showWhen: 'bg-type', + description: 'Solid background color' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['bg-type'] === 'solid'; + } + + apply(context, controlValues, animState, renderData) { + const color = controlValues['bg-color'] || '#4a90e2'; + context.fillStyle = color; + context.fillRect(0, 0, renderData.width, renderData.height); + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new SolidBackgroundEffect()); +} diff --git a/static/js/button-generator/effects/background-texture.js b/static/js/button-generator/effects/background-texture.js new file mode 100644 index 0000000..8afccbd --- /dev/null +++ b/static/js/button-generator/effects/background-texture.js @@ -0,0 +1,217 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Texture background effect + * Provides various procedural texture patterns + */ +export class TextureBackgroundEffect extends ButtonEffect { + constructor() { + super({ + id: 'bg-texture', + name: 'Texture Background', + type: 'background', + category: 'Background', + renderOrder: 1 + }); + } + + defineControls() { + return [ + { + id: 'texture-type', + type: 'select', + label: 'Texture Type', + defaultValue: 'dots', + showWhen: 'bg-type', + options: [ + { value: 'dots', label: 'Dots' }, + { value: 'grid', label: 'Grid' }, + { value: 'diagonal', label: 'Diagonal Lines' }, + { value: 'checkerboard', label: 'Checkerboard' }, + { value: 'noise', label: 'Noise' }, + { value: 'stars', label: 'Stars' } + ] + }, + { + id: 'texture-color1', + type: 'color', + label: 'Texture Color 1', + defaultValue: '#000000', + showWhen: 'bg-type', + description: 'Base color' + }, + { + id: 'texture-color2', + type: 'color', + label: 'Texture Color 2', + defaultValue: '#ffffff', + showWhen: 'bg-type', + description: 'Pattern color' + }, + { + id: 'texture-scale', + type: 'range', + label: 'Texture Scale', + defaultValue: 50, + min: 10, + max: 100, + step: 5, + showWhen: 'bg-type', + description: 'Size/density of pattern' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['bg-type'] === 'texture'; + } + + apply(context, controlValues, animState, renderData) { + const type = controlValues['texture-type'] || 'dots'; + const color1 = controlValues['texture-color1'] || '#000000'; + const color2 = controlValues['texture-color2'] || '#ffffff'; + const scale = controlValues['texture-scale'] || 50; + + const texture = this.drawTexture( + type, + color1, + color2, + scale, + renderData.width, + renderData.height + ); + + context.drawImage(texture, 0, 0); + } + + /** + * Draw texture pattern to a temporary canvas + */ + drawTexture(type, color1, color2, scale, width, height) { + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = width; + tempCanvas.height = height; + const ctx = tempCanvas.getContext('2d'); + + // Fill base color + ctx.fillStyle = color1; + ctx.fillRect(0, 0, width, height); + + // Draw pattern + ctx.fillStyle = color2; + const size = Math.max(2, Math.floor(scale / 10)); + + switch (type) { + case 'dots': + this.drawDots(ctx, width, height, size); + break; + case 'grid': + this.drawGrid(ctx, width, height, size); + break; + case 'diagonal': + this.drawDiagonal(ctx, width, height, size); + break; + case 'checkerboard': + this.drawCheckerboard(ctx, width, height, size); + break; + case 'noise': + this.drawNoise(ctx, width, height); + break; + case 'stars': + this.drawStars(ctx, width, height, scale); + break; + } + + return tempCanvas; + } + + /** + * Draw dots pattern + */ + drawDots(ctx, width, height, size) { + for (let y = 0; y < height; y += size * 2) { + for (let x = 0; x < width; x += size * 2) { + ctx.beginPath(); + ctx.arc(x, y, size / 2, 0, Math.PI * 2); + ctx.fill(); + } + } + } + + /** + * Draw grid pattern + */ + drawGrid(ctx, width, height, size) { + // Vertical lines + for (let x = 0; x < width; x += size) { + ctx.fillRect(x, 0, 1, height); + } + // Horizontal lines + for (let y = 0; y < height; y += size) { + ctx.fillRect(0, y, width, 1); + } + } + + /** + * Draw diagonal lines pattern + */ + drawDiagonal(ctx, width, height, size) { + for (let i = -height; i < width; i += size) { + ctx.fillRect(i, 0, 2, height); + ctx.save(); + ctx.translate(i + 1, 0); + ctx.rotate(Math.PI / 4); + ctx.fillRect(0, 0, 2, Math.max(width, height)); + ctx.restore(); + } + } + + /** + * Draw checkerboard pattern + */ + drawCheckerboard(ctx, width, height, size) { + for (let y = 0; y < height; y += size) { + for (let x = 0; x < width; x += size) { + if ((x / size + y / size) % 2 === 0) { + ctx.fillRect(x, y, size, size); + } + } + } + } + + /** + * Draw random noise pattern + */ + drawNoise(ctx, width, height) { + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (Math.random() > 0.5) { + ctx.fillRect(x, y, 1, 1); + } + } + } + } + + /** + * Draw stars pattern + */ + drawStars(ctx, width, height, scale) { + const numStars = scale; + for (let i = 0; i < numStars; i++) { + const x = Math.floor(Math.random() * width); + const y = Math.floor(Math.random() * height); + + // Draw plus-shape star + ctx.fillRect(x, y, 1, 1); // Center + ctx.fillRect(x - 1, y, 1, 1); // Left + ctx.fillRect(x + 1, y, 1, 1); // Right + ctx.fillRect(x, y - 1, 1, 1); // Top + ctx.fillRect(x, y + 1, 1, 1); // Bottom + } + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new TextureBackgroundEffect()); +} diff --git a/static/js/button-generator/effects/border.js b/static/js/button-generator/effects/border.js new file mode 100644 index 0000000..79a496f --- /dev/null +++ b/static/js/button-generator/effects/border.js @@ -0,0 +1,139 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Border effect + * Draws borders around the button with various styles + */ +export class BorderEffect extends ButtonEffect { + constructor() { + super({ + id: 'border', + name: 'Border', + type: 'border', + category: 'Border', + renderOrder: 10 + }); + } + + defineControls() { + return [ + { + id: 'border-width', + type: 'range', + label: 'Border Width', + defaultValue: 2, + min: 0, + max: 5, + step: 1, + description: 'Width of border in pixels' + }, + { + id: 'border-color', + type: 'color', + label: 'Border Color', + defaultValue: '#000000' + }, + { + id: 'border-style', + type: 'select', + label: 'Border Style', + defaultValue: 'solid', + options: [ + { value: 'solid', label: 'Solid' }, + { value: 'inset', label: 'Inset (3D)' }, + { value: 'outset', label: 'Outset (3D)' }, + { value: 'ridge', label: 'Ridge' } + ] + } + ]; + } + + isEnabled(controlValues) { + const width = controlValues['border-width'] || 0; + return width > 0; + } + + apply(context, controlValues, animState, renderData) { + const width = controlValues['border-width'] || 0; + if (width === 0) return; + + const color = controlValues['border-color'] || '#000000'; + const style = controlValues['border-style'] || 'solid'; + + if (style === 'solid') { + this.drawSolidBorder(context, width, color, renderData); + } else if (style === 'inset' || style === 'outset') { + this.draw3DBorder(context, width, style === 'outset', renderData); + } else if (style === 'ridge') { + this.drawRidgeBorder(context, width, renderData); + } + } + + /** + * Draw solid border + */ + drawSolidBorder(context, width, color, renderData) { + context.strokeStyle = color; + context.lineWidth = width; + context.strokeRect( + width / 2, + width / 2, + renderData.width - width, + renderData.height - width + ); + } + + /** + * Draw 3D inset/outset border + */ + draw3DBorder(context, width, isOutset, renderData) { + const lightColor = isOutset ? '#ffffff' : '#000000'; + const darkColor = isOutset ? '#000000' : '#ffffff'; + + // Top and left (light) + context.strokeStyle = lightColor; + context.lineWidth = width; + context.beginPath(); + context.moveTo(0, renderData.height); + context.lineTo(0, 0); + context.lineTo(renderData.width, 0); + context.stroke(); + + // Bottom and right (dark) + context.strokeStyle = darkColor; + context.beginPath(); + context.moveTo(renderData.width, 0); + context.lineTo(renderData.width, renderData.height); + context.lineTo(0, renderData.height); + context.stroke(); + } + + /** + * Draw ridge border (double 3D effect) + */ + drawRidgeBorder(context, width, renderData) { + // Outer ridge (light) + context.strokeStyle = '#ffffff'; + context.lineWidth = width / 2; + context.strokeRect( + width / 4, + width / 4, + renderData.width - width / 2, + renderData.height - width / 2 + ); + + // Inner ridge (dark) + context.strokeStyle = '#000000'; + context.strokeRect( + (width * 3) / 4, + (width * 3) / 4, + renderData.width - width * 1.5, + renderData.height - width * 1.5 + ); + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new BorderEffect()); +} diff --git a/static/js/button-generator/effects/glitch.js b/static/js/button-generator/effects/glitch.js new file mode 100644 index 0000000..ee78f34 --- /dev/null +++ b/static/js/button-generator/effects/glitch.js @@ -0,0 +1,93 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Glitch effect + * Creates horizontal scanline displacement for a glitchy look + */ +export class GlitchEffect extends ButtonEffect { + constructor() { + super({ + id: 'glitch', + name: 'Glitch', + type: 'general', + category: 'Visual Effects', + renderOrder: 80 + }); + } + + defineControls() { + return [ + { + id: 'animate-glitch', + type: 'checkbox', + label: 'Glitch Effect', + defaultValue: false + }, + { + id: 'glitch-intensity', + type: 'range', + label: 'Glitch Intensity', + defaultValue: 5, + min: 1, + max: 20, + step: 1, + showWhen: 'animate-glitch', + description: 'Maximum pixel offset for glitch' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-glitch'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const intensity = controlValues['glitch-intensity'] || 5; + const imageData = context.getImageData(0, 0, renderData.width, renderData.height); + + // Randomly glitch ~10% of scanlines per frame + const glitchProbability = 0.1; + const maxOffset = intensity; + + for (let y = 0; y < renderData.height; y++) { + if (Math.random() < glitchProbability) { + const offset = Math.floor((Math.random() - 0.5) * maxOffset * 2); + this.shiftScanline(imageData, y, offset, renderData.width); + } + } + + context.putImageData(imageData, 0, 0); + } + + /** + * Shift a horizontal scanline by offset pixels (with wrapping) + */ + shiftScanline(imageData, y, offset, width) { + const rowStart = y * width * 4; + const rowData = new Uint8ClampedArray(width * 4); + + // Copy row + for (let i = 0; i < width * 4; i++) { + rowData[i] = imageData.data[rowStart + i]; + } + + // Shift and wrap + for (let x = 0; x < width; x++) { + let sourceX = (x - offset + width) % width; + let destIdx = rowStart + x * 4; + let srcIdx = sourceX * 4; + + imageData.data[destIdx] = rowData[srcIdx]; + imageData.data[destIdx + 1] = rowData[srcIdx + 1]; + imageData.data[destIdx + 2] = rowData[srcIdx + 2]; + imageData.data[destIdx + 3] = rowData[srcIdx + 3]; + } + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new GlitchEffect()); +} diff --git a/static/js/button-generator/effects/hologram.js b/static/js/button-generator/effects/hologram.js new file mode 100644 index 0000000..e226aa1 --- /dev/null +++ b/static/js/button-generator/effects/hologram.js @@ -0,0 +1,170 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Hologram effect + * Creates a futuristic holographic appearance with glitches and scan lines + */ +export class HologramEffect extends ButtonEffect { + constructor() { + super({ + id: 'hologram', + name: 'Hologram', + type: 'general', + category: 'Visual Effects', + renderOrder: 88 // Near the end, after most other effects + }); + } + + defineControls() { + return [ + { + id: 'animate-hologram', + type: 'checkbox', + label: 'Hologram Effect', + defaultValue: false, + description: 'Futuristic holographic appearance' + }, + { + id: 'hologram-intensity', + type: 'range', + label: 'Effect Intensity', + defaultValue: 50, + min: 10, + max: 100, + step: 5, + showWhen: 'animate-hologram', + description: 'Strength of hologram effect' + }, + { + id: 'hologram-glitch-freq', + type: 'range', + label: 'Glitch Frequency', + defaultValue: 30, + min: 0, + max: 100, + step: 10, + showWhen: 'animate-hologram', + description: 'How often glitches occur' + }, + { + id: 'hologram-color', + type: 'color', + label: 'Hologram Tint', + defaultValue: '#00ffff', + showWhen: 'animate-hologram', + description: 'Color tint for hologram effect' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-hologram'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const intensity = (controlValues['hologram-intensity'] || 50) / 100; + const glitchFreq = (controlValues['hologram-glitch-freq'] || 30) / 100; + const color = controlValues['hologram-color'] || '#00ffff'; + + // Get current canvas content + const imageData = context.getImageData(0, 0, renderData.width, renderData.height); + const data = imageData.data; + + // Parse hologram color for tinting + const hexColor = color.replace('#', ''); + const r = parseInt(hexColor.substr(0, 2), 16); + const g = parseInt(hexColor.substr(2, 2), 16); + const b = parseInt(hexColor.substr(4, 2), 16); + + // Apply holographic tint + for (let i = 0; i < data.length; i += 4) { + // Mix with hologram color + data[i] = data[i] * (1 - intensity * 0.3) + r * intensity * 0.3; // Red + data[i + 1] = data[i + 1] * (1 - intensity * 0.5) + g * intensity * 0.5; // Green (more cyan) + data[i + 2] = data[i + 2] * (1 - intensity * 0.5) + b * intensity * 0.5; // Blue (more cyan) + } + + context.putImageData(imageData, 0, 0); + + // Add horizontal scan lines + context.globalAlpha = 0.05 * intensity; + context.fillStyle = '#000000'; + for (let y = 0; y < renderData.height; y += 2) { + context.fillRect(0, y, renderData.width, 1); + } + context.globalAlpha = 1.0; + + // Add moving highlight scan line + const scanY = (animState.progress * renderData.height) % renderData.height; + const gradient = context.createLinearGradient(0, scanY - 3, 0, scanY + 3); + gradient.addColorStop(0, 'rgba(0, 255, 255, 0)'); + gradient.addColorStop(0.5, `rgba(${r}, ${g}, ${b}, ${0.3 * intensity})`); + gradient.addColorStop(1, 'rgba(0, 255, 255, 0)'); + context.fillStyle = gradient; + context.fillRect(0, scanY - 3, renderData.width, 6); + + // Random glitches + if (Math.random() < glitchFreq * 0.1) { + const glitchY = Math.floor(Math.random() * renderData.height); + const glitchHeight = Math.floor(2 + Math.random() * 4); + const offset = (Math.random() - 0.5) * 6 * intensity; + + const sliceData = context.getImageData(0, glitchY, renderData.width, glitchHeight); + context.putImageData(sliceData, offset, glitchY); + } + + // Add chromatic aberration on edges + if (intensity > 0.3) { + const originalImage = context.getImageData(0, 0, renderData.width, renderData.height); + const aberration = 2 * intensity; + + // Slight red shift right + const redShift = context.getImageData(0, 0, renderData.width, renderData.height); + for (let i = 0; i < redShift.data.length; i += 4) { + const pixelIndex = i / 4; + const x = pixelIndex % renderData.width; + if (x < 3 || x > renderData.width - 3) { + const sourceIndex = ((pixelIndex + Math.floor(aberration)) * 4); + if (sourceIndex < originalImage.data.length) { + redShift.data[i] = originalImage.data[sourceIndex]; + } + } + } + + // Slight blue shift left + const blueShift = context.getImageData(0, 0, renderData.width, renderData.height); + for (let i = 0; i < blueShift.data.length; i += 4) { + const pixelIndex = i / 4; + const x = pixelIndex % renderData.width; + if (x < 3 || x > renderData.width - 3) { + const sourceIndex = ((pixelIndex - Math.floor(aberration)) * 4); + if (sourceIndex >= 0 && sourceIndex < originalImage.data.length) { + blueShift.data[i + 2] = originalImage.data[sourceIndex + 2]; + } + } + } + + context.putImageData(redShift, 0, 0); + context.globalCompositeOperation = 'screen'; + context.globalAlpha = 0.3; + context.putImageData(blueShift, 0, 0); + context.globalCompositeOperation = 'source-over'; + context.globalAlpha = 1.0; + } + + // Add flickering effect + if (Math.random() < 0.05) { + context.globalAlpha = 0.9 + Math.random() * 0.1; + context.fillStyle = 'rgba(255, 255, 255, 0.05)'; + context.fillRect(0, 0, renderData.width, renderData.height); + context.globalAlpha = 1.0; + } + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new HologramEffect()); +} diff --git a/static/js/button-generator/effects/noise.js b/static/js/button-generator/effects/noise.js new file mode 100644 index 0000000..92db24d --- /dev/null +++ b/static/js/button-generator/effects/noise.js @@ -0,0 +1,68 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Noise/Static effect + * Adds random pixel noise for a static/interference look + */ +export class NoiseEffect extends ButtonEffect { + constructor() { + super({ + id: 'noise', + name: 'Noise', + type: 'general', + category: 'Visual Effects', + renderOrder: 90 + }); + } + + defineControls() { + return [ + { + id: 'animate-noise', + type: 'checkbox', + label: 'Noise Effect', + defaultValue: false, + description: 'Random static/interference' + }, + { + id: 'noise-intensity', + type: 'range', + label: 'Noise Intensity', + defaultValue: 0.1, + min: 0.01, + max: 0.5, + step: 0.01, + showWhen: 'animate-noise', + description: 'Amount of noise' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-noise'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const intensity = controlValues['noise-intensity'] || 0.1; + const imageData = context.getImageData(0, 0, renderData.width, renderData.height); + + for (let i = 0; i < imageData.data.length; i += 4) { + // Random noise value + const noise = (Math.random() - 0.5) * 255 * intensity; + + imageData.data[i] = Math.max(0, Math.min(255, imageData.data[i] + noise)); + imageData.data[i + 1] = Math.max(0, Math.min(255, imageData.data[i + 1] + noise)); + imageData.data[i + 2] = Math.max(0, Math.min(255, imageData.data[i + 2] + noise)); + // Alpha unchanged + } + + context.putImageData(imageData, 0, 0); + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new NoiseEffect()); +} diff --git a/static/js/button-generator/effects/pulse.js b/static/js/button-generator/effects/pulse.js new file mode 100644 index 0000000..41a745a --- /dev/null +++ b/static/js/button-generator/effects/pulse.js @@ -0,0 +1,62 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Pulse effect + * Scales the button content up and down + */ +export class PulseEffect extends ButtonEffect { + constructor() { + super({ + id: 'pulse', + name: 'Pulse', + type: 'transform', + category: 'Visual Effects', + renderOrder: 1 // Must run before any drawing + }); + } + + defineControls() { + return [ + { + id: 'animate-pulse', + type: 'checkbox', + label: 'Pulse Effect', + defaultValue: false + }, + { + id: 'pulse-scale', + type: 'range', + label: 'Pulse Scale', + defaultValue: 1.2, + min: 1.0, + max: 2.0, + step: 0.05, + showWhen: 'animate-pulse', + description: 'Maximum scale size' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-pulse'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const maxScale = controlValues['pulse-scale'] || 1.2; + const minScale = 1.0; + const scale = minScale + (maxScale - minScale) * + (Math.sin(animState.getPhase(1.0)) * 0.5 + 0.5); + + // Apply transformation (context save/restore handled by caller) + context.translate(renderData.centerX, renderData.centerY); + context.scale(scale, scale); + context.translate(-renderData.centerX, -renderData.centerY); + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new PulseEffect()); +} diff --git a/static/js/button-generator/effects/rainbow-text.js b/static/js/button-generator/effects/rainbow-text.js new file mode 100644 index 0000000..e4fa148 --- /dev/null +++ b/static/js/button-generator/effects/rainbow-text.js @@ -0,0 +1,100 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Rainbow text animation effect + * Cycles text color through rainbow hues + */ +export class RainbowTextEffect extends ButtonEffect { + constructor(textLineNumber = 1) { + const suffix = textLineNumber === 1 ? '' : '2'; + super({ + id: `text-rainbow${suffix}`, + name: `Rainbow Text ${textLineNumber}`, + type: textLineNumber === 1 ? 'text' : 'text2', + category: textLineNumber === 1 ? 'Text Line 1' : 'Text Line 2', + renderOrder: 5, // Apply before wave (lower order) + textLineNumber: textLineNumber // Pass through config + }); + this.textLineNumber = textLineNumber; + } + + defineControls() { + const textLineNumber = this.textLineNumber || this.config?.textLineNumber || 1; + const suffix = textLineNumber === 1 ? '' : '2'; + return [ + { + id: `animate-text-rainbow${suffix}`, + type: 'checkbox', + label: 'Rainbow Animation', + defaultValue: false + }, + { + id: `text-rainbow-speed${suffix}`, + type: 'range', + label: 'Rainbow Speed', + defaultValue: 1, + min: 0.1, + max: 5, + step: 0.1, + showWhen: `animate-text-rainbow${suffix}`, + description: 'Speed of color cycling' + } + ]; + } + + isEnabled(controlValues) { + const suffix = this.textLineNumber === 1 ? '' : '2'; + return controlValues[`animate-text-rainbow${suffix}`] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; // Rainbow requires animation + + const suffix = this.textLineNumber === 1 ? '' : '2'; + + // Get text configuration + const text = controlValues[`button-text${suffix}`] || ''; + const enabled = controlValues[`text${suffix}-enabled`]; + if (!text || !enabled) return; + + // Check if wave is also enabled - if so, skip (wave will handle rainbow) + if (controlValues[`animate-text-wave${suffix}`]) 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 x = (controlValues[`text${suffix}-x`] / 100) * renderData.width; + const y = (controlValues[`text${suffix}-y`] / 100) * renderData.height; + + const speed = controlValues[`text-rainbow-speed${suffix}`] || 1; + + // Calculate rainbow color + const hue = (animState.progress * speed * 360) % 360; + const fillStyle = `hsl(${hue}, 80%, 60%)`; + const strokeStyle = `hsl(${hue}, 80%, 30%)`; + + // Set font + context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + + // Draw outline if enabled + if (controlValues[`text${suffix}-outline`]) { + context.strokeStyle = strokeStyle; + context.lineWidth = 2; + context.strokeText(text, x, y); + } + + // Draw text + context.fillStyle = fillStyle; + context.fillText(text, x, y); + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new RainbowTextEffect(1)); + generator.registerEffect(new RainbowTextEffect(2)); +} diff --git a/static/js/button-generator/effects/rgb-split.js b/static/js/button-generator/effects/rgb-split.js new file mode 100644 index 0000000..63013a7 --- /dev/null +++ b/static/js/button-generator/effects/rgb-split.js @@ -0,0 +1,85 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * RGB Split / Chromatic Aberration effect + * Separates color channels for a glitchy chromatic aberration look + */ +export class RgbSplitEffect extends ButtonEffect { + constructor() { + super({ + id: 'rgb-split', + name: 'RGB Split', + type: 'general', + category: 'Visual Effects', + renderOrder: 85 + }); + } + + defineControls() { + return [ + { + id: 'animate-rgb-split', + type: 'checkbox', + label: 'RGB Split', + defaultValue: false, + description: 'Chromatic aberration effect' + }, + { + id: 'rgb-split-intensity', + type: 'range', + label: 'Split Intensity', + defaultValue: 2, + min: 1, + max: 10, + step: 0.5, + showWhen: 'animate-rgb-split', + description: 'Pixel offset for color channels' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-rgb-split'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const intensity = controlValues['rgb-split-intensity'] || 2; + const imageData = context.getImageData(0, 0, renderData.width, renderData.height); + const result = context.createImageData(renderData.width, renderData.height); + + // Oscillating offset + const phase = Math.sin(animState.getPhase(1.0)); + const offsetX = Math.round(phase * intensity); + + for (let y = 0; y < renderData.height; y++) { + for (let x = 0; x < renderData.width; x++) { + const idx = (y * renderData.width + x) * 4; + + // Red channel - shift left + const redX = Math.max(0, Math.min(renderData.width - 1, x - offsetX)); + const redIdx = (y * renderData.width + redX) * 4; + result.data[idx] = imageData.data[redIdx]; + + // Green channel - no shift + result.data[idx + 1] = imageData.data[idx + 1]; + + // Blue channel - shift right + const blueX = Math.max(0, Math.min(renderData.width - 1, x + offsetX)); + const blueIdx = (y * renderData.width + blueX) * 4; + result.data[idx + 2] = imageData.data[blueIdx + 2]; + + // Alpha channel + result.data[idx + 3] = imageData.data[idx + 3]; + } + } + + context.putImageData(result, 0, 0); + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new RgbSplitEffect()); +} diff --git a/static/js/button-generator/effects/rotate.js b/static/js/button-generator/effects/rotate.js new file mode 100644 index 0000000..88012cc --- /dev/null +++ b/static/js/button-generator/effects/rotate.js @@ -0,0 +1,72 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Rotate effect + * Rotates the button back and forth + */ +export class RotateEffect extends ButtonEffect { + constructor() { + super({ + id: 'rotate', + name: 'Rotate', + type: 'transform', + category: 'Visual Effects', + renderOrder: 2 // Must run before any drawing (after pulse) + }); + } + + defineControls() { + return [ + { + id: 'animate-rotate', + type: 'checkbox', + label: 'Rotate Effect', + defaultValue: false + }, + { + id: 'rotate-angle', + type: 'range', + label: 'Max Angle', + defaultValue: 15, + min: 1, + max: 45, + step: 1, + showWhen: 'animate-rotate', + description: 'Maximum rotation angle in degrees' + }, + { + id: 'rotate-speed', + type: 'range', + label: 'Rotation Speed', + defaultValue: 1, + min: 0.1, + max: 3, + step: 0.1, + showWhen: 'animate-rotate', + description: 'Speed of rotation' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-rotate'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const maxAngle = controlValues['rotate-angle'] || 15; + const speed = controlValues['rotate-speed'] || 1; + const angle = Math.sin(animState.getPhase(speed)) * maxAngle * (Math.PI / 180); + + // Apply transformation (context save/restore handled by caller) + context.translate(renderData.centerX, renderData.centerY); + context.rotate(angle); + context.translate(-renderData.centerX, -renderData.centerY); + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new RotateEffect()); +} diff --git a/static/js/button-generator/effects/scanline.js b/static/js/button-generator/effects/scanline.js new file mode 100644 index 0000000..5dab0b7 --- /dev/null +++ b/static/js/button-generator/effects/scanline.js @@ -0,0 +1,79 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Scanline effect + * Creates CRT-style horizontal lines + */ +export class ScanlineEffect extends ButtonEffect { + constructor() { + super({ + id: 'scanline', + name: 'Scanline', + type: 'general', + category: 'Visual Effects', + renderOrder: 75 + }); + } + + defineControls() { + return [ + { + id: 'animate-scanline', + type: 'checkbox', + label: 'Scanline Effect', + defaultValue: false + }, + { + id: 'scanline-intensity', + type: 'range', + label: 'Scanline Intensity', + defaultValue: 0.3, + min: 0.1, + max: 0.8, + step: 0.05, + showWhen: 'animate-scanline', + description: 'Darkness of scanlines' + }, + { + id: 'scanline-speed', + type: 'range', + label: 'Scanline Speed', + defaultValue: 1, + min: 0.1, + max: 3, + step: 0.1, + showWhen: 'animate-scanline', + description: 'Movement speed of scanlines' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-scanline'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const intensity = controlValues['scanline-intensity'] || 0.3; + const speed = controlValues['scanline-speed'] || 1; + + // Create overlay with scanlines + context.globalCompositeOperation = 'multiply'; + context.fillStyle = `rgba(0, 0, 0, ${intensity})`; + + // Animate scanline position + const offset = (animState.progress * speed * renderData.height) % 2; + + for (let y = offset; y < renderData.height; y += 2) { + context.fillRect(0, Math.floor(y), renderData.width, 1); + } + + context.globalCompositeOperation = 'source-over'; + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new ScanlineEffect()); +} diff --git a/static/js/button-generator/effects/shimmer.js b/static/js/button-generator/effects/shimmer.js new file mode 100644 index 0000000..327c124 --- /dev/null +++ b/static/js/button-generator/effects/shimmer.js @@ -0,0 +1,57 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Shimmer effect + * Creates a sweeping light/shine effect across the button + */ +export class ShimmerEffect extends ButtonEffect { + constructor() { + super({ + id: 'shimmer', + name: 'Shimmer', + type: 'general', + category: 'Visual Effects', + renderOrder: 70 + }); + } + + defineControls() { + return [ + { + id: 'animate-shimmer', + type: 'checkbox', + label: 'Shimmer Effect', + defaultValue: false, + description: 'Sweeping light effect' + } + ]; + } + + isEnabled(controlValues) { + return controlValues['animate-shimmer'] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const shimmerX = animState.progress * (renderData.width + 40) - 20; + + const gradient = context.createLinearGradient( + shimmerX - 15, + 0, + shimmerX + 15, + renderData.height + ); + gradient.addColorStop(0, 'rgba(255, 255, 255, 0)'); + gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.3)'); + gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); + + context.fillStyle = gradient; + context.fillRect(0, 0, renderData.width, renderData.height); + } +} + +// Auto-register effect +export function register(generator) { + generator.registerEffect(new ShimmerEffect()); +} diff --git a/static/js/button-generator/effects/spin-text.js b/static/js/button-generator/effects/spin-text.js new file mode 100644 index 0000000..d009b2b --- /dev/null +++ b/static/js/button-generator/effects/spin-text.js @@ -0,0 +1,144 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Spinning text animation effect + * Makes each character rotate independently + */ +export class SpinTextEffect extends ButtonEffect { + constructor(textLineNumber = 1) { + const suffix = textLineNumber === 1 ? '' : '2'; + super({ + id: `text-spin${suffix}`, + name: `Spinning Text ${textLineNumber}`, + type: textLineNumber === 1 ? 'text' : 'text2', + category: textLineNumber === 1 ? 'Text Line 1' : 'Text Line 2', + renderOrder: 8, // Before wave, after rainbow + textLineNumber: textLineNumber + }); + this.textLineNumber = textLineNumber; + } + + defineControls() { + const textLineNumber = this.textLineNumber || this.config?.textLineNumber || 1; + const suffix = textLineNumber === 1 ? '' : '2'; + return [ + { + id: `animate-text-spin${suffix}`, + type: 'checkbox', + label: 'Spinning Animation', + defaultValue: false + }, + { + id: `spin-speed${suffix}`, + type: 'range', + label: 'Spin Speed', + defaultValue: 1, + min: 0.1, + max: 5, + step: 0.1, + showWhen: `animate-text-spin${suffix}`, + description: 'Speed of character rotation' + }, + { + id: `spin-stagger${suffix}`, + type: 'range', + label: 'Spin Stagger', + defaultValue: 0.3, + min: 0, + max: 1, + step: 0.1, + showWhen: `animate-text-spin${suffix}`, + description: 'Delay between characters' + } + ]; + } + + isEnabled(controlValues) { + const suffix = this.textLineNumber === 1 ? '' : '2'; + return controlValues[`animate-text-spin${suffix}`] === true; + } + + apply(context, controlValues, animState, renderData) { + const suffix = this.textLineNumber === 1 ? '' : '2'; + const text = controlValues[`button-text${suffix}`] || ''; + + if (!text || !controlValues[`text${suffix}-enabled`]) return; + if (!animState) return; + + const speed = controlValues[`spin-speed${suffix}`] || 1; + const stagger = controlValues[`spin-stagger${suffix}`] || 0.3; + const fontSize = controlValues[`font-size${suffix}`] || 14; + const fontFamily = controlValues[`font-family${suffix}`] || 'Arial'; + const fontWeight = controlValues[`text${suffix}-bold`] ? 'bold' : 'normal'; + const fontStyle = controlValues[`text${suffix}-italic`] ? 'italic' : 'normal'; + + context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + + // Get text color + let fillStyle; + const colorType = controlValues[`text${suffix}-color-type`] || 'solid'; + if (colorType === 'gradient') { + const color1 = controlValues[`text${suffix}-gradient-color1`] || '#ffffff'; + const color2 = controlValues[`text${suffix}-gradient-color2`] || '#00ffff'; + const angle = (controlValues[`text${suffix}-gradient-angle`] || 90) * (Math.PI / 180); + const centerX = renderData.centerX; + const centerY = renderData.centerY; + const x1 = centerX + Math.cos(angle) * 20; + const y1 = centerY + Math.sin(angle) * 20; + const x2 = centerX - Math.cos(angle) * 20; + const y2 = centerY - Math.sin(angle) * 20; + const gradient = context.createLinearGradient(x1, y1, x2, y2); + gradient.addColorStop(0, color1); + gradient.addColorStop(1, color2); + fillStyle = gradient; + } else { + fillStyle = controlValues[`text${suffix}-color`] || '#ffffff'; + } + + // Calculate base position + const x = controlValues[`text${suffix}-x`] || 50; + const y = controlValues[`text${suffix}-y`] || 50; + const baseX = (x / 100) * renderData.width; + const baseY = (y / 100) * renderData.height; + + // Measure total text width for centering + const totalWidth = context.measureText(text).width; + let currentX = baseX - totalWidth / 2; + + // Draw each character with rotation + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const charWidth = context.measureText(char).width; + const charCenterX = currentX + charWidth / 2; + + // Calculate rotation for this character + const phase = animState.getPhase(speed) + i * stagger * Math.PI * 2; + const rotation = phase % (Math.PI * 2); + + context.save(); + context.translate(charCenterX, baseY); + context.rotate(rotation); + + // Apply outline if enabled + if (controlValues[`text${suffix}-outline`]) { + context.strokeStyle = controlValues[`text${suffix}-outline-color`] || '#000000'; + context.lineWidth = 2; + context.strokeText(char, 0, 0); + } + + context.fillStyle = fillStyle; + context.fillText(char, 0, 0); + context.restore(); + + currentX += charWidth; + } + } +} + +// Auto-register effects for both text lines +export function register(generator) { + generator.registerEffect(new SpinTextEffect(1)); + generator.registerEffect(new SpinTextEffect(2)); +} diff --git a/static/js/button-generator/effects/spotlight.js b/static/js/button-generator/effects/spotlight.js new file mode 100644 index 0000000..b1185e7 --- /dev/null +++ b/static/js/button-generator/effects/spotlight.js @@ -0,0 +1,209 @@ +/** + * EXAMPLE EFFECT + * + * This is a template for creating new effects. + * Copy this file and modify it to create your own custom effects. + * + * This example creates a "spotlight" effect that highlights a circular area + * and darkens the rest of the button. + */ + +import { ButtonEffect } from "../effect-base.js"; + +/** + * Spotlight Effect + * Creates a moving circular spotlight that highlights different areas + */ +export class SpotlightEffect extends ButtonEffect { + constructor() { + super({ + // Unique ID for this effect (used in control IDs) + id: "spotlight", + + // Display name shown in UI + name: "Spotlight", + + // Effect type determines render order category + // Options: 'background', 'border', 'text', 'text2', 'general' + type: "general", + + // Category for organizing effects in UI + category: "Visual Effects", + + // Render order within type (lower = earlier) + // 1-9: backgrounds, 10-19: borders, 20-29: transforms, + // 30-49: text, 50-79: overlays, 80-99: post-processing + renderOrder: 60, + }); + } + + /** + * Define UI controls for this effect + * These controls will be automatically bound to the generator + */ + defineControls() { + return [ + // Main enable/disable checkbox + { + id: "animate-spotlight", + type: "checkbox", + label: "Spotlight Effect", + defaultValue: false, + description: "Moving circular spotlight", + }, + + // Spotlight size control + { + id: "spotlight-size", + type: "range", + label: "Spotlight Size", + defaultValue: 20, + min: 10, + max: 50, + step: 1, + showWhen: "animate-spotlight", // Only show when checkbox is enabled + description: "Radius of the spotlight", + }, + + // Darkness of the vignette + { + id: "spotlight-darkness", + type: "range", + label: "Darkness", + defaultValue: 0.5, + min: 0, + max: 1, + step: 0.05, + showWhen: "animate-spotlight", + description: "How dark the non-spotlight area should be", + }, + + // Speed of movement + { + id: "spotlight-speed", + type: "range", + label: "Movement Speed", + defaultValue: 1, + min: 0.1, + max: 3, + step: 0.1, + showWhen: "animate-spotlight", + description: "Speed of spotlight movement", + }, + ]; + } + + /** + * Determine if this effect should be applied + * @param {Object} controlValues - Current values of all controls + * @returns {boolean} + */ + isEnabled(controlValues) { + return controlValues["animate-spotlight"] === true; + } + + /** + * Apply the effect to the canvas + * + * @param {CanvasRenderingContext2D} context - Canvas 2D rendering context + * @param {Object} controlValues - Current values of all controls + * @param {AnimationState|null} animState - Animation state (null for static render) + * @param {Object} renderData - Render information: { width, height, centerX, centerY } + */ + apply(context, controlValues, animState, renderData) { + // Skip if no animation (spotlight needs movement) + if (!animState) return; + + // Get control values + const size = controlValues["spotlight-size"] || 20; + const darkness = controlValues["spotlight-darkness"] || 0.5; + const speed = controlValues["spotlight-speed"] || 1; + + // Calculate spotlight position + // Move in a circular pattern using animation phase + const phase = animState.getPhase(speed); + const spotX = renderData.centerX + Math.cos(phase) * 20; + const spotY = renderData.centerY + Math.sin(phase) * 10; + + // Create radial gradient for spotlight effect + const gradient = context.createRadialGradient( + spotX, + spotY, + 0, // Inner circle (center of spotlight) + spotX, + spotY, + size, // Outer circle (edge of spotlight) + ); + + // Center is transparent (spotlight is bright) + gradient.addColorStop(0, `rgba(0, 0, 0, 0)`); + // Edge fades to dark + gradient.addColorStop(0.5, `rgba(0, 0, 0, ${darkness * 0.3})`); + gradient.addColorStop(1, `rgba(0, 0, 0, ${darkness})`); + + // Apply the gradient as an overlay + context.fillStyle = gradient; + context.fillRect(0, 0, renderData.width, renderData.height); + + // Optional: Add a bright center dot + context.fillStyle = "rgba(255, 255, 255, 0.3)"; + context.beginPath(); + context.arc(spotX, spotY, 2, 0, Math.PI * 2); + context.fill(); + } + + /** + * Optional: Override canApply for more complex logic + * By default, it just checks isEnabled() + */ + canApply(controlValues) { + // Example: Only apply if text is also enabled + const textEnabled = controlValues["textEnabled"]; + return this.isEnabled(controlValues) && textEnabled; + } + + /** + * Optional: Add helper methods for your effect + */ + calculateSpotlightPath(progress, width, height) { + // Example helper method + return { + x: width * progress, + y: height / 2, + }; + } +} + +/** + * Registration function + * This is called to add the effect to the generator + * + * @param {ButtonGenerator} generator - The button generator instance + */ +export function register(generator) { + generator.registerEffect(new SpotlightEffect()); +} + +/** + * USAGE: + * + * 1. Copy this file to a new name (e.g., my-effect.js) + * 2. Modify the class name, id, and effect logic + * 3. Import in main.js: + * import * as myEffect from './effects/my-effect.js'; + * 4. Register in setupApp(): + * myEffect.register(generator); + * 5. Add HTML controls with matching IDs + */ + +/** + * TIPS: + * + * - Use animState.progress for linear animations (0 to 1) + * - Use animState.getPhase(speed) for periodic animations (0 to 2π) + * - Use Math.sin/cos for smooth periodic motion + * - Check if (!animState) at the start if your effect requires animation + * - The context is automatically saved/restored, so feel free to transform + * - Use renderData for canvas dimensions and center point + * - Look at existing effects for more examples + */ diff --git a/static/js/button-generator/effects/text-standard.js b/static/js/button-generator/effects/text-standard.js new file mode 100644 index 0000000..9caaf58 --- /dev/null +++ b/static/js/button-generator/effects/text-standard.js @@ -0,0 +1,232 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Standard text rendering effect + * Renders static text (when no animations are active) + */ +export class StandardTextEffect extends ButtonEffect { + constructor(textLineNumber = 1) { + const suffix = textLineNumber === 1 ? '' : '2'; + super({ + id: `text-standard${suffix}`, + name: `Standard Text ${textLineNumber}`, + type: textLineNumber === 1 ? 'text' : 'text2', + category: textLineNumber === 1 ? 'Text Line 1' : 'Text Line 2', + renderOrder: 20, // After animations + textLineNumber: textLineNumber // Pass through config so defineControls can access it + }); + this.textLineNumber = textLineNumber; + } + + defineControls() { + // Access from config since this is called before constructor completes + const textLineNumber = this.textLineNumber || this.config?.textLineNumber || 1; + const suffix = textLineNumber === 1 ? '' : '2'; + return [ + { + id: `button-text${suffix}`, + type: 'text', + label: `Text Line ${textLineNumber}`, + defaultValue: textLineNumber === 1 ? 'RITUAL.SH' : '' + }, + { + id: `text${suffix}-enabled`, + type: 'checkbox', + label: `Enable Text Line ${textLineNumber}`, + defaultValue: textLineNumber === 1 + }, + { + id: `font-size${suffix}`, + type: 'range', + label: 'Font Size', + min: 6, + max: 24, + defaultValue: textLineNumber === 1 ? 14 : 12 + }, + { + id: `text${suffix}-x`, + type: 'range', + label: 'Horizontal Position', + min: 0, + max: 100, + defaultValue: 50, + description: 'Percentage from left' + }, + { + id: `text${suffix}-y`, + type: 'range', + label: 'Vertical Position', + min: 0, + max: 100, + defaultValue: textLineNumber === 1 ? 35 : 65, + description: 'Percentage from top' + }, + { + id: `text${suffix}-color-type`, + type: 'select', + label: 'Color Type', + defaultValue: 'solid', + options: [ + { value: 'solid', label: 'Solid Color' }, + { value: 'gradient', label: 'Gradient' } + ] + }, + { + id: `text${suffix}-color`, + type: 'color', + label: 'Text Color', + defaultValue: '#ffffff', + showWhen: `text${suffix}-color-type` + }, + { + id: `text${suffix}-gradient-color1`, + type: 'color', + label: 'Gradient Color 1', + defaultValue: '#ffffff', + showWhen: `text${suffix}-color-type` + }, + { + id: `text${suffix}-gradient-color2`, + type: 'color', + label: 'Gradient Color 2', + defaultValue: '#00ffff', + showWhen: `text${suffix}-color-type` + }, + { + id: `text${suffix}-gradient-angle`, + type: 'range', + label: 'Gradient Angle', + min: 0, + max: 360, + defaultValue: 0, + showWhen: `text${suffix}-color-type` + }, + { + id: `text${suffix}-outline`, + type: 'checkbox', + label: 'Outline', + defaultValue: false + }, + { + id: `outline${suffix}-color`, + type: 'color', + label: 'Outline Color', + defaultValue: '#000000', + showWhen: `text${suffix}-outline` + }, + { + id: `font-family${suffix}`, + type: 'select', + label: 'Font', + defaultValue: 'Lato', + options: [ + { value: 'Lato', label: 'Lato' }, + { value: 'Roboto', label: 'Roboto' }, + { value: 'Open Sans', label: 'Open Sans' }, + { value: 'Montserrat', label: 'Montserrat' }, + { value: 'Oswald', label: 'Oswald' }, + { value: 'Bebas Neue', label: 'Bebas Neue' }, + { value: 'Roboto Mono', label: 'Roboto Mono' }, + { value: 'VT323', label: 'VT323' }, + { value: 'Press Start 2P', label: 'Press Start 2P' }, + { value: 'DSEG7-Classic', label: 'DSEG7' } + ] + }, + { + id: `font-bold${suffix}`, + type: 'checkbox', + label: 'Bold', + defaultValue: false + }, + { + id: `font-italic${suffix}`, + type: 'checkbox', + label: 'Italic', + defaultValue: false + } + ]; + } + + isEnabled(controlValues) { + const suffix = this.textLineNumber === 1 ? '' : '2'; + const text = controlValues[`button-text${suffix}`]; + const enabled = controlValues[`text${suffix}-enabled`]; + + // Only render if text exists, is enabled, and no animations are active on this text + const waveActive = controlValues[`animate-text-wave${suffix}`]; + const rainbowActive = controlValues[`animate-text-rainbow${suffix}`]; + + return text && enabled && !waveActive && !rainbowActive; + } + + apply(context, controlValues, animState, renderData) { + const suffix = this.textLineNumber === 1 ? '' : '2'; + + const text = controlValues[`button-text${suffix}`]; + if (!text) 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 x = (controlValues[`text${suffix}-x`] / 100) * renderData.width; + const y = (controlValues[`text${suffix}-y`] / 100) * renderData.height; + + // Set font + context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + + // Get colors + const colors = this.getTextColors(context, controlValues, text, x, y, fontSize); + + // Draw outline if enabled + if (controlValues[`text${suffix}-outline`]) { + context.strokeStyle = colors.strokeStyle; + context.lineWidth = 2; + context.strokeText(text, x, y); + } + + // Draw text + context.fillStyle = colors.fillStyle; + context.fillText(text, x, y); + } + + /** + * Get text colors (solid or gradient) + */ + getTextColors(context, controlValues, text, x, y, fontSize) { + const suffix = this.textLineNumber === 1 ? '' : '2'; + const colorType = controlValues[`text${suffix}-color-type`] || 'solid'; + + let fillStyle, strokeStyle; + + 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 StandardTextEffect(1)); + generator.registerEffect(new StandardTextEffect(2)); +} diff --git a/static/js/button-generator/effects/wave-text.js b/static/js/button-generator/effects/wave-text.js new file mode 100644 index 0000000..cf36211 --- /dev/null +++ b/static/js/button-generator/effects/wave-text.js @@ -0,0 +1,167 @@ +import { ButtonEffect } from '../effect-base.js'; + +/** + * Wave text animation effect + * Makes text characters wave up and down in a sine wave pattern + */ +export class WaveTextEffect extends ButtonEffect { + constructor(textLineNumber = 1) { + const suffix = textLineNumber === 1 ? '' : '2'; + super({ + id: `text-wave${suffix}`, + name: `Text Wave ${textLineNumber}`, + type: textLineNumber === 1 ? 'text' : 'text2', + category: textLineNumber === 1 ? 'Text Line 1' : 'Text Line 2', + renderOrder: 10, + textLineNumber: textLineNumber // Pass through config + }); + this.textLineNumber = textLineNumber; + } + + defineControls() { + const textLineNumber = this.textLineNumber || this.config?.textLineNumber || 1; + const suffix = textLineNumber === 1 ? '' : '2'; + return [ + { + id: `animate-text-wave${suffix}`, + type: 'checkbox', + label: 'Wave Animation', + defaultValue: false + }, + { + id: `wave-amplitude${suffix}`, + type: 'range', + label: 'Wave Amplitude', + defaultValue: 3, + min: 1, + max: 10, + step: 0.5, + showWhen: `animate-text-wave${suffix}`, + description: 'Height of the wave motion' + }, + { + id: `wave-speed${suffix}`, + type: 'range', + label: 'Wave Speed', + defaultValue: 1, + min: 0.1, + max: 3, + step: 0.1, + showWhen: `animate-text-wave${suffix}`, + description: 'Speed of the wave animation' + } + ]; + } + + isEnabled(controlValues) { + const suffix = this.textLineNumber === 1 ? '' : '2'; + return controlValues[`animate-text-wave${suffix}`] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; // Wave requires animation + + const suffix = this.textLineNumber === 1 ? '' : '2'; + + // Get text configuration + const text = controlValues[`button-text${suffix}`] || ''; + const enabled = controlValues[`text${suffix}-enabled`]; + if (!text || !enabled) 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 amplitude = controlValues[`wave-amplitude${suffix}`] || 3; + const speed = controlValues[`wave-speed${suffix}`] || 1; + + // Set font + context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`; + context.textAlign = 'center'; + context.textBaseline = 'middle'; + + // Get colors + const colors = this.getTextColors(context, controlValues, text, baseX, baseY, fontSize, animState); + + // Measure total width for centering + const totalWidth = context.measureText(text).width; + let currentX = baseX - totalWidth / 2; + + // Draw each character with wave offset + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const charWidth = context.measureText(char).width; + + // Calculate wave offset for this character + const phase = animState.getPhase(speed); + const charOffset = i / text.length; + const waveY = Math.sin(phase + charOffset * Math.PI * 2) * amplitude; + + const charX = currentX + charWidth / 2; + const charY = baseY + waveY; + + // Draw outline if enabled + if (controlValues[`text${suffix}-outline`]) { + context.strokeStyle = colors.strokeStyle; + context.lineWidth = 2; + context.strokeText(char, charX, charY); + } + + // Draw character + context.fillStyle = colors.fillStyle; + context.fillText(char, charX, charY); + + currentX += charWidth; + } + } + + /** + * Get text colors (solid, gradient, or rainbow) + */ + getTextColors(context, controlValues, text, x, y, fontSize, animState) { + const suffix = this.textLineNumber === 1 ? '' : '2'; + + let fillStyle, strokeStyle; + + // Check if rainbow text is also enabled + if (animState && controlValues[`animate-text-rainbow${suffix}`]) { + 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 WaveTextEffect(1)); + generator.registerEffect(new WaveTextEffect(2)); +} diff --git a/static/js/button-generator/main.js b/static/js/button-generator/main.js new file mode 100644 index 0000000..805edef --- /dev/null +++ b/static/js/button-generator/main.js @@ -0,0 +1,352 @@ +/** + * Button Generator - Main Application File + * + * This demonstrates how to use the modular button generator system. + * Effects are imported and registered with the generator. + */ + +import { ButtonGenerator } from "./button-generator-core.js"; +import { UIBuilder } from "./ui-builder.js"; + +// Import effects (each effect file exports a register() function) +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 rainbowBg from "./effects/background-rainbow.js"; +import * as rain from "./effects/background-rain.js"; +import * as border from "./effects/border.js"; +import * as standardText from "./effects/text-standard.js"; +import * as waveText from "./effects/wave-text.js"; +import * as rainbowText from "./effects/rainbow-text.js"; +import * as spinText from "./effects/spin-text.js"; +import * as glitch from "./effects/glitch.js"; +import * as pulse from "./effects/pulse.js"; +import * as shimmer from "./effects/shimmer.js"; +import * as scanline from "./effects/scanline.js"; +import * as rgbSplit from "./effects/rgb-split.js"; +import * as noise from "./effects/noise.js"; +import * as rotate from "./effects/rotate.js"; +import * as hologram from "./effects/hologram.js"; +import * as spotlight from "./effects/spotlight.js"; + +/** + * Initialize the button generator application + */ +export function init() { + // Wait for DOM to be ready + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", setupApp); + } else { + setupApp(); + } +} + +/** + * Setup the application + */ +async function setupApp() { + console.log("Initializing Button Generator..."); + + // Setup collapsible sections + setupCollapsible(); + + // Get canvas + const canvas = document.getElementById("button-canvas"); + if (!canvas) { + console.error("Canvas element not found!"); + return; + } + + // Create button generator + const generator = new ButtonGenerator(canvas, { + fps: 20, + duration: 2, + fonts: [ + "Lato", + "Roboto", + "Open Sans", + "Montserrat", + "Oswald", + "Bebas Neue", + "Roboto Mono", + "VT323", + "Press Start 2P", + "DSEG7-Classic", + ], + }); + + // Register all effects + console.log("Registering effects..."); + solidBg.register(generator); + gradientBg.register(generator); + textureBg.register(generator); + emojiWallpaper.register(generator); + rainbowBg.register(generator); + rain.register(generator); + border.register(generator); + standardText.register(generator); + waveText.register(generator); + rainbowText.register(generator); + spinText.register(generator); + glitch.register(generator); + pulse.register(generator); + shimmer.register(generator); + scanline.register(generator); + rgbSplit.register(generator); + noise.register(generator); + rotate.register(generator); + hologram.register(generator); + spotlight.register(generator); + + console.log(`Registered ${generator.getAllEffects().length} effects`); + + // Build UI from effects + console.log("Building UI..."); + const controlsContainer = document.querySelector(".controls-section"); + if (!controlsContainer) { + console.error("Controls container not found!"); + return; + } + + const uiBuilder = new UIBuilder(controlsContainer); + uiBuilder.buildUI(generator.getAllEffects()); + uiBuilder.setupConditionalVisibility(); + + // Preload fonts + console.log("Loading fonts..."); + await generator.preloadFonts(); + + // Bind controls (after UI is built) + generator.bindControls(); + + // Setup additional UI handlers + setupUIHandlers(generator); + + // Setup download button + setupDownloadButton(generator); + + // Setup presets + setupPresets(generator); + + // Initial draw + generator.updatePreview(); + + console.log("Button Generator ready!"); +} + +/** + * Setup collapsible section functionality + */ +function setupCollapsible() { + const headers = document.querySelectorAll(".control-group-header"); + console.log("Found", headers.length, "collapsible headers"); + + headers.forEach((header) => { + header.addEventListener("click", () => { + const controlGroup = header.closest(".control-group"); + if (controlGroup) { + controlGroup.classList.toggle("collapsed"); + } + }); + }); +} + +/** + * Setup UI handlers for conditional visibility + */ +function setupUIHandlers(generator) { + // Note: Conditional visibility is now handled automatically by UIBuilder.setupConditionalVisibility() + // This function is kept for any additional custom handlers if needed in the future +} + +/** + * Setup download button + */ +function setupDownloadButton(generator) { + const downloadBtn = document.getElementById("download-button"); + if (!downloadBtn) return; + + downloadBtn.addEventListener("click", async () => { + const originalText = downloadBtn.textContent; + downloadBtn.disabled = true; + downloadBtn.textContent = "Generating GIF..."; + + try { + const blob = await generator.exportAsGif((progress, stage) => { + if (stage === "generating") { + const percent = Math.round(progress * 100); + downloadBtn.textContent = `Generating: ${percent}%`; + } else if (stage === "encoding") { + const percent = Math.round(progress * 100); + downloadBtn.textContent = `Encoding: ${percent}%`; + } + }); + + // Download the blob + const link = document.createElement("a"); + link.download = "button-88x31.gif"; + link.href = URL.createObjectURL(blob); + link.click(); + URL.revokeObjectURL(link.href); + + downloadBtn.textContent = originalText; + } catch (error) { + console.error("Error generating GIF:", error); + alert("Error generating GIF. Please try again."); + downloadBtn.textContent = originalText; + } finally { + downloadBtn.disabled = false; + } + }); +} + +/** + * Setup preset buttons + */ +function setupPresets(generator) { + // Random preset + const randomBtn = document.getElementById("preset-random"); + if (randomBtn) { + randomBtn.addEventListener("click", () => { + applyRandomPreset(); + generator.updatePreview(); + }); + } + + // Classic preset + const classicBtn = document.getElementById("preset-classic"); + if (classicBtn) { + classicBtn.addEventListener("click", () => { + applyClassicPreset(); + generator.updatePreview(); + }); + } + + // Modern preset + const modernBtn = document.getElementById("preset-modern"); + if (modernBtn) { + modernBtn.addEventListener("click", () => { + applyModernPreset(); + generator.updatePreview(); + }); + } +} + +/** + * Apply random preset + */ +function applyRandomPreset() { + const randomColor = () => + "#" + + Math.floor(Math.random() * 16777215) + .toString(16) + .padStart(6, "0"); + + // Random background + const bgTypeEl = document.getElementById("bg-type"); + if (bgTypeEl) { + bgTypeEl.value = ["solid", "gradient", "texture"][ + Math.floor(Math.random() * 3) + ]; + bgTypeEl.dispatchEvent(new Event("change")); + } + + setControlValue("bg-color", randomColor()); + setControlValue("gradient-color1", randomColor()); + setControlValue("gradient-color2", randomColor()); + setControlValue("gradient-angle", Math.floor(Math.random() * 360)); + + // Random text colors + setControlValue("text-color", randomColor()); + setControlValue("text-gradient-color1", randomColor()); + setControlValue("text-gradient-color2", randomColor()); + setControlValue("text2-color", randomColor()); + setControlValue("text2-gradient-color1", randomColor()); + setControlValue("text2-gradient-color2", randomColor()); + + // Random border + setControlValue("border-color", randomColor()); + setControlValue("border-width", Math.floor(Math.random() * 6)); +} + +/** + * Apply classic 90s preset + */ +function applyClassicPreset() { + setControlValue("bg-type", "gradient"); + setControlValue("gradient-color1", "#6e6e6eff"); + setControlValue("gradient-color2", "#979797"); + setControlValue("gradient-angle", 90); + + setControlValue("text-color-type", "solid"); + setControlValue("text-color", "#000000"); + setControlValue("text2-color-type", "solid"); + setControlValue("text2-color", "#000000"); + + setControlValue("border-width", 2); + setControlValue("border-color", "#000000"); + setControlValue("border-style", "outset"); + + setControlValue("font-family", "VT323"); + setControlValue("font-family2", "VT323"); + + document.getElementById("bg-type")?.dispatchEvent(new Event("change")); +} + +/** + * Apply modern preset + */ +function applyModernPreset() { + setControlValue("bg-type", "gradient"); + setControlValue("gradient-color1", "#0a0a0a"); + setControlValue("gradient-color2", "#1a0a2e"); + setControlValue("gradient-angle", 135); + + setControlValue("text-color-type", "gradient"); + setControlValue("text-gradient-color1", "#00ffaa"); + setControlValue("text-gradient-color2", "#00ffff"); + setControlValue("text-gradient-angle", 90); + + setControlValue("text2-color-type", "gradient"); + setControlValue("text2-gradient-color1", "#ff00ff"); + setControlValue("text2-gradient-color2", "#ff6600"); + + setControlValue("border-width", 1); + setControlValue("border-color", "#00ffaa"); + setControlValue("border-style", "solid"); + + setControlValue("font-family", "Roboto Mono"); + setControlValue("font-family2", "Roboto Mono"); + + document.getElementById("bg-type")?.dispatchEvent(new Event("change")); + document + .getElementById("text-color-type") + ?.dispatchEvent(new Event("change")); + document + .getElementById("text2-color-type") + ?.dispatchEvent(new Event("change")); +} + +/** + * Helper to set control value + */ +function setControlValue(id, value) { + const el = document.getElementById(id); + if (el) { + if (el.type === "checkbox") { + el.checked = value; + } else { + el.value = value; + } + + // Update value display if it exists + const valueDisplay = document.getElementById(id + "-value"); + if (valueDisplay) { + valueDisplay.textContent = value; + } + } +} + +// Auto-initialize when imported +init(); diff --git a/static/js/button-generator/ui-builder.js b/static/js/button-generator/ui-builder.js new file mode 100644 index 0000000..2a051a4 --- /dev/null +++ b/static/js/button-generator/ui-builder.js @@ -0,0 +1,367 @@ +/** + * UI Builder - Dynamically generates control UI from effect definitions + */ + +export class UIBuilder { + constructor(containerElement) { + this.container = containerElement; + this.controlGroups = new Map(); // category -> { element, controls } + } + + /** + * Build the entire UI from registered effects + * @param {Array} effects - All registered effects + */ + buildUI(effects) { + // Clear existing content + this.container.innerHTML = ''; + this.controlGroups.clear(); + + // Group effects by category + const categorized = this.categorizeEffects(effects); + + // Create control groups for each category + for (const [category, categoryEffects] of categorized) { + this.createControlGroup(category, categoryEffects); + } + } + + /** + * Categorize effects by their category property + * @param {Array} effects + * @returns {Map>} + */ + categorizeEffects(effects) { + const categories = new Map(); + + effects.forEach(effect => { + const category = effect.category || 'Other'; + if (!categories.has(category)) { + categories.set(category, []); + } + categories.get(category).push(effect); + }); + + // Sort categories in a logical order + const orderedCategories = new Map(); + const categoryOrder = [ + 'Text Line 1', + 'Text Line 2', + 'Background', + 'Background Animations', + 'Border', + 'Visual Effects', + 'General Effects', + 'Special Effects' + ]; + + categoryOrder.forEach(cat => { + if (categories.has(cat)) { + orderedCategories.set(cat, categories.get(cat)); + } + }); + + // Add any remaining categories + categories.forEach((effects, cat) => { + if (!orderedCategories.has(cat)) { + orderedCategories.set(cat, effects); + } + }); + + return orderedCategories; + } + + /** + * Create a collapsible control group + * @param {string} category - Category name + * @param {Array} effects - Effects in this category + */ + createControlGroup(category, effects) { + const groupDiv = document.createElement('div'); + groupDiv.className = 'control-group'; + + // Create header + const header = document.createElement('h3'); + header.className = 'control-group-header'; + header.innerHTML = ` + ${category} + + `; + + // Create content container + const content = document.createElement('div'); + content.className = 'control-group-content'; + + // Add controls for each effect in this category + effects.forEach(effect => { + this.addEffectControls(content, effect); + }); + + // Add click handler for collapsing + header.addEventListener('click', () => { + groupDiv.classList.toggle('collapsed'); + }); + + groupDiv.appendChild(header); + groupDiv.appendChild(content); + this.container.appendChild(groupDiv); + + this.controlGroups.set(category, { element: groupDiv, effects }); + } + + /** + * Add controls for a single effect + * @param {HTMLElement} container - Container to add controls to + * @param {ButtonEffect} effect - Effect to create controls for + */ + addEffectControls(container, effect) { + effect.controls.forEach(control => { + const controlEl = this.createControl(control); + if (controlEl) { + container.appendChild(controlEl); + } + }); + } + + /** + * Create a single control element + * @param {Object} controlDef - Control definition from effect + * @returns {HTMLElement} + */ + createControl(controlDef) { + const { id, type, label, defaultValue, min, max, step, options, showWhen, description } = controlDef; + + switch (type) { + case 'checkbox': + return this.createCheckbox(id, label, defaultValue, showWhen); + + case 'range': + return this.createRange(id, label, defaultValue, min, max, step, description, showWhen); + + case 'color': + return this.createColor(id, label, defaultValue, showWhen); + + case 'select': + return this.createSelect(id, label, defaultValue, options, showWhen); + + case 'text': + return this.createTextInput(id, label, defaultValue); + + default: + console.warn(`Unknown control type: ${type}`); + return null; + } + } + + /** + * Create a checkbox control + */ + createCheckbox(id, label, defaultValue, showWhen) { + const wrapper = document.createElement('label'); + wrapper.className = 'checkbox-label'; + + const input = document.createElement('input'); + input.type = 'checkbox'; + input.id = id; + input.checked = defaultValue || false; + + const span = document.createElement('span'); + span.textContent = label; + + wrapper.appendChild(input); + wrapper.appendChild(span); + + if (showWhen) { + wrapper.style.display = 'none'; + wrapper.dataset.showWhen = showWhen; + } + + return wrapper; + } + + /** + * Create a range slider control + */ + createRange(id, label, defaultValue, min, max, step, description, showWhen) { + const container = document.createElement('div'); + + const labelEl = document.createElement('label'); + labelEl.htmlFor = id; + labelEl.innerHTML = `${label}: ${defaultValue}`; + if (description) { + labelEl.title = description; + } + + const input = document.createElement('input'); + input.type = 'range'; + input.id = id; + input.min = min !== undefined ? min : 0; + input.max = max !== undefined ? max : 100; + input.value = defaultValue !== undefined ? defaultValue : 50; + if (step !== undefined) { + input.step = step; + } + + // Update value display on input + input.addEventListener('input', () => { + const valueDisplay = document.getElementById(`${id}-value`); + if (valueDisplay) { + valueDisplay.textContent = input.value; + } + }); + + container.appendChild(labelEl); + container.appendChild(input); + + if (showWhen) { + container.style.display = 'none'; + container.dataset.showWhen = showWhen; + } + + return container; + } + + /** + * Create a color picker control + */ + createColor(id, label, defaultValue, showWhen) { + const container = document.createElement('div'); + + const labelEl = document.createElement('label'); + labelEl.htmlFor = id; + labelEl.textContent = label; + + const input = document.createElement('input'); + input.type = 'color'; + input.id = id; + input.value = defaultValue || '#ffffff'; + + container.appendChild(labelEl); + container.appendChild(input); + + if (showWhen) { + container.style.display = 'none'; + container.dataset.showWhen = showWhen; + } + + return container; + } + + /** + * Create a select dropdown control + */ + createSelect(id, label, defaultValue, options, showWhen) { + const container = document.createElement('div'); + + const labelEl = document.createElement('label'); + labelEl.htmlFor = id; + labelEl.textContent = label; + + const select = document.createElement('select'); + select.id = id; + + options.forEach(opt => { + const option = document.createElement('option'); + option.value = opt.value; + option.textContent = opt.label; + if (opt.value === defaultValue) { + option.selected = true; + } + select.appendChild(option); + }); + + container.appendChild(labelEl); + container.appendChild(select); + + if (showWhen) { + container.style.display = 'none'; + container.dataset.showWhen = showWhen; + } + + return container; + } + + /** + * Create a text input control + */ + createTextInput(id, label, defaultValue) { + const container = document.createElement('div'); + + const labelEl = document.createElement('label'); + labelEl.htmlFor = id; + labelEl.textContent = label; + + const input = document.createElement('input'); + input.type = 'text'; + input.id = id; + input.value = defaultValue || ''; + input.maxLength = 20; + + container.appendChild(labelEl); + container.appendChild(input); + + return container; + } + + /** + * Setup conditional visibility for controls + * Should be called after all controls are created + */ + setupConditionalVisibility() { + // Find all controls with showWhen attribute + const conditionalControls = this.container.querySelectorAll('[data-show-when]'); + + conditionalControls.forEach(control => { + const triggerControlId = control.dataset.showWhen; + const triggerControl = document.getElementById(triggerControlId); + + if (triggerControl) { + const updateVisibility = () => { + if (triggerControl.type === 'checkbox') { + control.style.display = triggerControl.checked ? 'block' : 'none'; + } else if (triggerControl.tagName === 'SELECT') { + // Get the control ID to determine what value to check for + const controlId = control.querySelector('input, select')?.id; + + // For background controls + if (triggerControlId === 'bg-type') { + if (controlId === 'bg-color') { + control.style.display = triggerControl.value === 'solid' ? 'block' : 'none'; + } else if (controlId && (controlId.startsWith('gradient-') || controlId === 'gradient-angle')) { + control.style.display = triggerControl.value === 'gradient' ? 'block' : 'none'; + } else if (controlId && (controlId.startsWith('texture-') || controlId === 'texture-type' || controlId === 'texture-scale')) { + control.style.display = triggerControl.value === 'texture' ? 'block' : 'none'; + } else if (controlId && (controlId.startsWith('emoji-') || controlId === 'emoji-text')) { + control.style.display = triggerControl.value === 'emoji-wallpaper' ? 'block' : 'none'; + } + } + // For text color controls + else if (triggerControlId === 'text-color-type') { + if (controlId === 'text-color') { + control.style.display = triggerControl.value === 'solid' ? 'block' : 'none'; + } else if (controlId && controlId.startsWith('text-gradient-')) { + control.style.display = triggerControl.value === 'gradient' ? 'block' : 'none'; + } + } else if (triggerControlId === 'text2-color-type') { + if (controlId === 'text2-color') { + control.style.display = triggerControl.value === 'solid' ? 'block' : 'none'; + } else if (controlId && controlId.startsWith('text2-gradient-')) { + control.style.display = triggerControl.value === 'gradient' ? 'block' : 'none'; + } + } else { + // Default: show when any value is selected + control.style.display = triggerControl.value ? 'block' : 'none'; + } + } + }; + + // Initial visibility + updateVisibility(); + + // Update on change + triggerControl.addEventListener('change', updateVisibility); + triggerControl.addEventListener('input', updateVisibility); + } + }); + } +} From c0d6bee9c309c1c060f916e7a7f367ca5eef824f Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 9 Jan 2026 13:20:06 +0000 Subject: [PATCH 2/2] New effects, refactor --- assets/sass/pages/button-generator.scss | 45 ++- content/resources/button-generator/index.md | 6 +- .../effects/background-aurora.js | 191 +++++++++ .../effects/background-bubbles.js | 178 +++++++++ .../effects/background-fire.js | 216 ++++++++++ .../effects/background-starfield.js | 166 ++++++++ static/js/button-generator/effects/border.js | 372 +++++++++++++++--- .../js/button-generator/effects/spin-text.js | 90 +++-- .../js/button-generator/effects/spotlight.js | 18 +- .../button-generator/effects/text-shadow.js | 167 ++++++++ .../button-generator/effects/text-standard.js | 197 +++++----- .../js/button-generator/effects/wave-text.js | 17 +- static/js/button-generator/main.js | 11 + static/js/button-generator/ui-builder.js | 161 +++++++- 14 files changed, 1620 insertions(+), 215 deletions(-) create mode 100644 static/js/button-generator/effects/background-aurora.js create mode 100644 static/js/button-generator/effects/background-bubbles.js create mode 100644 static/js/button-generator/effects/background-fire.js create mode 100644 static/js/button-generator/effects/background-starfield.js create mode 100644 static/js/button-generator/effects/text-shadow.js diff --git a/assets/sass/pages/button-generator.scss b/assets/sass/pages/button-generator.scss index 193610b..17c0710 100644 --- a/assets/sass/pages/button-generator.scss +++ b/assets/sass/pages/button-generator.scss @@ -75,7 +75,6 @@ ); z-index: 90; } - &::before { content: ""; @@ -203,7 +202,7 @@ } label { - display: block; + //display: block; margin-top: 0.75rem; margin-bottom: 0.25rem; font-weight: 500; @@ -438,6 +437,7 @@ input[type="checkbox"] { margin: 0; cursor: pointer; + margin-right: 0.25em; } span { @@ -535,4 +535,45 @@ transform: translateY(0); } } + + // Custom tooltip styles + .control-tooltip { + position: fixed; + background: linear-gradient( + 135deg, + rgba(0, 120, 200, 0.98) 0%, + rgba(0, 100, 180, 0.98) 100% + ); + color: #fff; + padding: 0.5rem 0.75rem; + border-radius: 4px; + font-size: 0.8rem; + pointer-events: none; + z-index: 10000; + max-width: 250px; + box-shadow: + 0 0 20px rgba(0, 150, 255, 0.5), + 0 4px 12px rgba(0, 0, 0, 0.4), + inset 0 0 20px rgba(0, 150, 255, 0.1); + border: 1px solid rgba(0, 150, 255, 0.6); + opacity: 0; + transition: opacity 0.15s ease; + line-height: 1.4; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + + &.visible { + opacity: 1; + } + + &::before { + content: ""; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-bottom-color: rgba(0, 120, 200, 0.98); + filter: drop-shadow(0 -2px 4px rgba(0, 0, 0, 0.2)); + } + } } diff --git a/content/resources/button-generator/index.md b/content/resources/button-generator/index.md index 9f5d5d2..a5cc19e 100644 --- a/content/resources/button-generator/index.md +++ b/content/resources/button-generator/index.md @@ -14,10 +14,14 @@ This supports gif despite the basic `canvas` tag limitation courtesy of [gif.js] Big thanks to [neonaut's 88x31 archive](https://neonaut.neocities.org/cyber/88x31) and everyone who made the buttons that appear there. You should check it out if you need inspiration for your button! +**Important note:** Some effects and animations stack, some don't. Some work better with certain lengths of text or variables depending on text length. Experiment, see what happens. + {{< button-generator >}} --- ### Changelog -- 08/01/2025 - Initial release. +- 07/01/2025 - Initial release. +- 08/01/2025 - Total refactor to be modular, added many more effects. +- 09/01/2025 - Rewrote the bevel inset and outset border code so they look a lot nicer at the corners. Updates throughout so that multibit (emoji!) characters should work. diff --git a/static/js/button-generator/effects/background-aurora.js b/static/js/button-generator/effects/background-aurora.js new file mode 100644 index 0000000..464a107 --- /dev/null +++ b/static/js/button-generator/effects/background-aurora.js @@ -0,0 +1,191 @@ +import { ButtonEffect } from "../effect-base.js"; + +/** + * Aurora/Plasma background effect + * Flowing organic color patterns using layered gradients + */ +export class AuroraEffect extends ButtonEffect { + constructor() { + super({ + id: "bg-aurora", + name: "Aurora", + type: "general", + category: "Background Animations", + renderOrder: 55, + }); + } + + defineControls() { + return [ + { + id: "animate-aurora", + type: "checkbox", + label: "Aurora Effect", + defaultValue: false, + }, + { + id: "aurora-speed", + type: "range", + label: "Flow Speed", + defaultValue: 1, + min: 1, + max: 3, + step: 1, + showWhen: "animate-aurora", + description: "Speed of flowing colors", + }, + { + id: "aurora-intensity", + type: "range", + label: "Intensity", + defaultValue: 0.6, + min: 0.2, + max: 1, + step: 0.1, + showWhen: "animate-aurora", + description: "Brightness and opacity", + }, + { + id: "aurora-complexity", + type: "range", + label: "Complexity", + defaultValue: 3, + min: 2, + max: 6, + step: 1, + showWhen: "animate-aurora", + description: "Number of wave layers", + }, + { + id: "aurora-color-scheme", + type: "select", + label: "Color Scheme", + defaultValue: "northern", + options: [ + { value: "northern", label: "Northern Lights" }, + { value: "purple", label: "Purple Dream" }, + { value: "fire", label: "Fire" }, + { value: "ocean", label: "Ocean" }, + { value: "rainbow", label: "Rainbow" }, + ], + showWhen: "animate-aurora", + description: "Color palette", + }, + ]; + } + + isEnabled(controlValues) { + return controlValues["animate-aurora"] === true; + } + + getColorScheme(scheme, hue) { + switch (scheme) { + case "northern": + return [ + { h: 120, s: 70, l: 50 }, // Green + { h: 160, s: 70, l: 50 }, // Teal + { h: 200, s: 70, l: 50 }, // Blue + ]; + case "purple": + return [ + { h: 270, s: 70, l: 50 }, // Purple + { h: 300, s: 70, l: 50 }, // Magenta + { h: 330, s: 70, l: 50 }, // Pink + ]; + case "fire": + return [ + { h: 0, s: 80, l: 50 }, // Red + { h: 30, s: 80, l: 50 }, // Orange + { h: 50, s: 80, l: 50 }, // Yellow-Orange + ]; + case "ocean": + return [ + { h: 180, s: 70, l: 50 }, // Cyan + { h: 200, s: 70, l: 50 }, // Light Blue + { h: 220, s: 70, l: 50 }, // Blue + ]; + case "rainbow": + return [ + { h: (hue + 0) % 360, s: 70, l: 50 }, + { h: (hue + 120) % 360, s: 70, l: 50 }, + { h: (hue + 240) % 360, s: 70, l: 50 }, + ]; + default: + return [ + { h: 120, s: 70, l: 50 }, + { h: 180, s: 70, l: 50 }, + { h: 240, s: 70, l: 50 }, + ]; + } + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const speed = controlValues["aurora-speed"] || 1; + const intensity = controlValues["aurora-intensity"] || 0.6; + const complexity = controlValues["aurora-complexity"] || 3; + const colorScheme = controlValues["aurora-color-scheme"] || "northern"; + + const time = animState.getPhase(speed); + + // Create flowing hue shift that loops properly (only used for rainbow scheme) + // Convert phase (0 to 2π) to hue degrees (0 to 360) + const hueShift = (time / (Math.PI * 2)) * 360; + const colors = this.getColorScheme(colorScheme, hueShift); + + // Draw multiple overlapping gradients to create aurora effect + context.globalCompositeOperation = "screen"; // Blend mode for aurora effect + + for (let i = 0; i < complexity; i++) { + const phase = time + i * ((Math.PI * 2) / complexity); + + // Calculate wave positions + const wave1X = + renderData.centerX + Math.sin(phase) * renderData.width * 0.5; + const wave1Y = + renderData.centerY + Math.cos(phase * 1.3) * renderData.height * 0.5; + + // Create radial gradient + const gradient = context.createRadialGradient( + wave1X, + wave1Y, + 0, + wave1X, + wave1Y, + renderData.width * 0.8, + ); + + // Pick color based on wave index + const colorIdx = i % colors.length; + const color = colors[colorIdx]; + + const baseOpacity = intensity * 0.3; + + // Rainbow scheme already has hueShift applied in getColorScheme + // Other schemes use their fixed colors + gradient.addColorStop( + 0, + `hsla(${color.h}, ${color.s}%, ${color.l}%, ${baseOpacity})`, + ); + gradient.addColorStop( + 0.5, + `hsla(${color.h}, ${color.s}%, ${color.l}%, ${baseOpacity * 0.5})`, + ); + gradient.addColorStop( + 1, + `hsla(${color.h}, ${color.s}%, ${color.l}%, 0)`, + ); + + context.fillStyle = gradient; + context.fillRect(0, 0, renderData.width, renderData.height); + } + + // Reset composite operation + context.globalCompositeOperation = "source-over"; + } +} + +export function register(generator) { + generator.registerEffect(new AuroraEffect()); +} diff --git a/static/js/button-generator/effects/background-bubbles.js b/static/js/button-generator/effects/background-bubbles.js new file mode 100644 index 0000000..be3fc8f --- /dev/null +++ b/static/js/button-generator/effects/background-bubbles.js @@ -0,0 +1,178 @@ +import { ButtonEffect } from "../effect-base.js"; + +/** + * Bubbles rising background effect + * Floating bubbles that rise with drift + */ +export class BubblesEffect extends ButtonEffect { + constructor() { + super({ + id: "bg-bubbles", + name: "Bubbles", + type: "general", + category: "Background Animations", + renderOrder: 55, + }); + + this.bubbles = []; + this.initialized = false; + } + + defineControls() { + return [ + { + id: "animate-bubbles", + type: "checkbox", + label: "Bubbles Effect", + defaultValue: false, + }, + { + id: "bubble-count", + type: "range", + label: "Bubble Count", + defaultValue: 15, + min: 5, + max: 40, + step: 1, + showWhen: "animate-bubbles", + description: "Number of bubbles", + }, + { + id: "bubble-speed", + type: "range", + label: "Rise Speed", + defaultValue: 1, + min: 0.3, + max: 3, + step: 0.1, + showWhen: "animate-bubbles", + description: "Speed of rising bubbles", + }, + { + id: "bubble-drift", + type: "range", + label: "Drift Amount", + defaultValue: 1, + min: 0, + max: 3, + step: 0.1, + showWhen: "animate-bubbles", + description: "Side-to-side drift", + }, + { + id: "bubble-color", + type: "color", + label: "Bubble Color", + defaultValue: "#4da6ff", + showWhen: "animate-bubbles", + description: "Color of bubbles", + }, + ]; + } + + isEnabled(controlValues) { + return controlValues["animate-bubbles"] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const count = controlValues["bubble-count"] || 15; + const speed = controlValues["bubble-speed"] || 1; + const drift = controlValues["bubble-drift"] || 1; + const bubbleColor = controlValues["bubble-color"] || "#4da6ff"; + + // Initialize bubbles on first frame or count change + if (!this.initialized || this.bubbles.length !== count) { + this.bubbles = []; + for (let i = 0; i < count; i++) { + this.bubbles.push({ + x: Math.random() * renderData.width, + startY: Math.random(), // Store as 0-1 ratio instead of pixel value + size: 3 + Math.random() * 8, + speedMultiplier: 0.5 + Math.random() * 1, + driftPhase: Math.random() * Math.PI * 2, + driftSpeed: 0.5 + Math.random() * 1, + }); + } + this.initialized = true; + } + + // Parse color for gradient + const hexToRgb = (hex) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : { r: 77, g: 166, b: 255 }; + }; + + const rgb = hexToRgb(bubbleColor); + + // Draw bubbles + this.bubbles.forEach((bubble) => { + // Calculate Y position based on animation progress for perfect looping + // Each bubble has a different starting offset and speed + const bubbleProgress = + (animState.progress * speed * bubble.speedMultiplier + bubble.startY) % + 1; + + // Convert to pixel position (bubbles rise from bottom to top) + const bubbleY = + renderData.height + bubble.size - bubbleProgress * (renderData.height + bubble.size * 2); + + // Drift side to side + const driftOffset = + Math.sin( + animState.getPhase(bubble.driftSpeed * 0.5) + bubble.driftPhase + ) * + drift * + 2; + const currentX = bubble.x + driftOffset; + + // Draw bubble with gradient for 3D effect + const gradient = context.createRadialGradient( + currentX - bubble.size * 0.3, + bubbleY - bubble.size * 0.3, + 0, + currentX, + bubbleY, + bubble.size + ); + + gradient.addColorStop( + 0, + `rgba(${rgb.r + 40}, ${rgb.g + 40}, ${rgb.b + 40}, 0.6)` + ); + gradient.addColorStop( + 0.6, + `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.4)` + ); + gradient.addColorStop(1, `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.2)`); + + context.fillStyle = gradient; + context.beginPath(); + context.arc(currentX, bubbleY, bubble.size, 0, Math.PI * 2); + context.fill(); + + // Add highlight + context.fillStyle = "rgba(255, 255, 255, 0.4)"; + context.beginPath(); + context.arc( + currentX - bubble.size * 0.3, + bubbleY - bubble.size * 0.3, + bubble.size * 0.3, + 0, + Math.PI * 2 + ); + context.fill(); + }); + } +} + +export function register(generator) { + generator.registerEffect(new BubblesEffect()); +} diff --git a/static/js/button-generator/effects/background-fire.js b/static/js/button-generator/effects/background-fire.js new file mode 100644 index 0000000..e6072d5 --- /dev/null +++ b/static/js/button-generator/effects/background-fire.js @@ -0,0 +1,216 @@ +import { ButtonEffect } from "../effect-base.js"; + +/** + * Fire background effect + * Animated flames rising from bottom using particles + */ +export class FireEffect extends ButtonEffect { + constructor() { + super({ + id: "bg-fire", + name: "Fire", + type: "general", + category: "Background Animations", + renderOrder: 55, + }); + + this.particles = []; + this.initialized = false; + } + + defineControls() { + return [ + { + id: "animate-fire", + type: "checkbox", + label: "Fire Effect", + defaultValue: false, + }, + { + id: "fire-intensity", + type: "range", + label: "Intensity", + defaultValue: 50, + min: 20, + max: 100, + step: 5, + showWhen: "animate-fire", + description: "Number of flame particles", + }, + { + id: "fire-height", + type: "range", + label: "Flame Height", + defaultValue: 0.6, + min: 0.3, + max: 1, + step: 0.1, + showWhen: "animate-fire", + description: "How high flames reach", + }, + { + id: "fire-speed", + type: "range", + label: "Speed", + defaultValue: 1, + min: 0.3, + max: 3, + step: 0.1, + showWhen: "animate-fire", + description: "Speed of rising flames", + }, + { + id: "fire-color-scheme", + type: "select", + label: "Color Scheme", + defaultValue: "normal", + options: [ + { value: "normal", label: "Normal Fire" }, + { value: "blue", label: "Blue Flame" }, + { value: "green", label: "Green Flame" }, + { value: "purple", label: "Purple Flame" }, + { value: "white", label: "White Hot" }, + ], + showWhen: "animate-fire", + description: "Flame color", + }, + ]; + } + + isEnabled(controlValues) { + return controlValues["animate-fire"] === true; + } + + getFireColors(scheme) { + switch (scheme) { + case "normal": + return [ + { r: 255, g: 60, b: 0 }, // Red-orange + { r: 255, g: 140, b: 0 }, // Orange + { r: 255, g: 200, b: 0 }, // Yellow + ]; + case "blue": + return [ + { r: 0, g: 100, b: 255 }, // Blue + { r: 100, g: 180, b: 255 }, // Light blue + { r: 200, g: 230, b: 255 }, // Very light blue + ]; + case "green": + return [ + { r: 0, g: 200, b: 50 }, // Green + { r: 100, g: 255, b: 100 }, // Light green + { r: 200, g: 255, b: 150 }, // Very light green + ]; + case "purple": + return [ + { r: 150, g: 0, b: 255 }, // Purple + { r: 200, g: 100, b: 255 }, // Light purple + { r: 230, g: 180, b: 255 }, // Very light purple + ]; + case "white": + return [ + { r: 255, g: 200, b: 150 }, // Warm white + { r: 255, g: 240, b: 200 }, // Light white + { r: 255, g: 255, b: 255 }, // Pure white + ]; + default: + return [ + { r: 255, g: 60, b: 0 }, + { r: 255, g: 140, b: 0 }, + { r: 255, g: 200, b: 0 }, + ]; + } + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const intensity = controlValues["fire-intensity"] || 50; + const height = controlValues["fire-height"] || 0.6; + const speed = controlValues["fire-speed"] || 1; + const colorScheme = controlValues["fire-color-scheme"] || "normal"; + + const colors = this.getFireColors(colorScheme); + const maxHeight = renderData.height * height; + + // Spawn new particles at the bottom + for (let i = 0; i < intensity / 10; i++) { + this.particles.push({ + x: Math.random() * renderData.width, + y: renderData.height, + vx: (Math.random() - 0.5) * 1.5, + vy: -(2 + Math.random() * 3) * speed, + size: 2 + Math.random() * 6, + life: 1.0, + colorIndex: Math.random(), + }); + } + + // Update and draw particles + this.particles = this.particles.filter((particle) => { + // Update position + particle.x += particle.vx; + particle.y += particle.vy; + + // Add some turbulence + particle.vx += (Math.random() - 0.5) * 0.2; + particle.vy *= 0.98; // Slow down as they rise + + // Fade out based on height and time + const heightRatio = + (renderData.height - particle.y) / renderData.height; + particle.life -= 0.015; + + if (particle.life > 0 && particle.y > renderData.height - maxHeight) { + // Choose color based on life (hotter at bottom, cooler at top) + const colorProgress = 1 - particle.life; + const colorIdx = Math.floor(colorProgress * (colors.length - 1)); + const colorBlend = (colorProgress * (colors.length - 1)) % 1; + + const c1 = colors[Math.min(colorIdx, colors.length - 1)]; + const c2 = colors[Math.min(colorIdx + 1, colors.length - 1)]; + + const r = Math.floor(c1.r + (c2.r - c1.r) * colorBlend); + const g = Math.floor(c1.g + (c2.g - c1.g) * colorBlend); + const b = Math.floor(c1.b + (c2.b - c1.b) * colorBlend); + + // Draw particle with gradient + const gradient = context.createRadialGradient( + particle.x, + particle.y, + 0, + particle.x, + particle.y, + particle.size + ); + + gradient.addColorStop( + 0, + `rgba(${r}, ${g}, ${b}, ${particle.life * 0.8})` + ); + gradient.addColorStop( + 0.5, + `rgba(${r}, ${g}, ${b}, ${particle.life * 0.5})` + ); + gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`); + + context.fillStyle = gradient; + context.beginPath(); + context.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2); + context.fill(); + + return true; + } + return false; + }); + + // Limit particle count + if (this.particles.length > intensity * 5) { + this.particles = this.particles.slice(-intensity * 5); + } + } +} + +export function register(generator) { + generator.registerEffect(new FireEffect()); +} diff --git a/static/js/button-generator/effects/background-starfield.js b/static/js/button-generator/effects/background-starfield.js new file mode 100644 index 0000000..1440d32 --- /dev/null +++ b/static/js/button-generator/effects/background-starfield.js @@ -0,0 +1,166 @@ +import { ButtonEffect } from "../effect-base.js"; + +/** + * Starfield background effect + * Twinkling stars with optional shooting stars + */ +export class StarfieldEffect extends ButtonEffect { + constructor() { + super({ + id: "bg-starfield", + name: "Starfield", + type: "general", + category: "Background Animations", + renderOrder: 55, + }); + + this.stars = []; + this.shootingStars = []; + this.initialized = false; + } + + defineControls() { + return [ + { + id: "animate-starfield", + type: "checkbox", + label: "Starfield Effect", + description: + "This might look a bit different when exported, work in progress!", + defaultValue: false, + }, + { + id: "star-density", + type: "range", + label: "Star Density", + defaultValue: 30, + min: 10, + max: 80, + step: 5, + showWhen: "animate-starfield", + description: "Number of stars", + }, + { + id: "star-twinkle-speed", + type: "range", + label: "Twinkle Speed", + defaultValue: 1, + min: 0.1, + max: 3, + step: 0.1, + showWhen: "animate-starfield", + description: "Speed of twinkling", + }, + { + id: "star-shooting-enabled", + type: "checkbox", + label: "Shooting Stars", + defaultValue: true, + showWhen: "animate-starfield", + description: "Enable shooting stars", + }, + { + id: "star-color", + type: "color", + label: "Star Color", + defaultValue: "#ffffff", + showWhen: "animate-starfield", + description: "Color of stars", + }, + ]; + } + + isEnabled(controlValues) { + return controlValues["animate-starfield"] === true; + } + + apply(context, controlValues, animState, renderData) { + if (!animState) return; + + const density = controlValues["star-density"] || 30; + const twinkleSpeed = controlValues["star-twinkle-speed"] || 1; + const shootingEnabled = controlValues["star-shooting-enabled"] !== false; + const starColor = controlValues["star-color"] || "#ffffff"; + + // Initialize stars on first frame or density change + if (!this.initialized || this.stars.length !== density) { + this.stars = []; + for (let i = 0; i < density; i++) { + this.stars.push({ + x: Math.random() * renderData.width, + y: Math.random() * renderData.height, + size: 0.5 + Math.random() * 1.5, + twinkleOffset: Math.random() * Math.PI * 2, + twinkleSpeed: 0.5 + Math.random() * 1.5, + }); + } + this.initialized = true; + } + + // Draw twinkling stars + this.stars.forEach((star) => { + const twinkle = + Math.sin( + animState.getPhase(twinkleSpeed * star.twinkleSpeed) + + star.twinkleOffset, + ) * + 0.5 + + 0.5; + const opacity = 0.3 + twinkle * 0.7; + + context.fillStyle = starColor; + context.globalAlpha = opacity; + context.beginPath(); + context.arc(star.x, star.y, star.size, 0, Math.PI * 2); + context.fill(); + context.globalAlpha = 1.0; + }); + + // Shooting stars + if (shootingEnabled) { + // Randomly spawn shooting stars + if (Math.random() < 0.02 && this.shootingStars.length < 3) { + this.shootingStars.push({ + x: Math.random() * renderData.width, + y: -10, + vx: (Math.random() - 0.5) * 2, + vy: 3 + Math.random() * 2, + life: 1.0, + }); + } + + // Update and draw shooting stars + this.shootingStars = this.shootingStars.filter((star) => { + star.x += star.vx; + star.y += star.vy; + star.life -= 0.02; + + if (star.life > 0) { + // Draw shooting star trail + const gradient = context.createLinearGradient( + star.x, + star.y, + star.x - star.vx * 5, + star.y - star.vy * 5, + ); + gradient.addColorStop(0, `rgba(255, 255, 255, ${star.life * 0.8})`); + gradient.addColorStop(1, "rgba(255, 255, 255, 0)"); + + context.strokeStyle = gradient; + context.lineWidth = 2; + context.beginPath(); + context.moveTo(star.x, star.y); + context.lineTo(star.x - star.vx * 5, star.y - star.vy * 5); + context.stroke(); + + return true; + } + return false; + }); + } + } +} + +export function register(generator) { + generator.registerEffect(new StarfieldEffect()); +} diff --git a/static/js/button-generator/effects/border.js b/static/js/button-generator/effects/border.js index 79a496f..0ed6029 100644 --- a/static/js/button-generator/effects/border.js +++ b/static/js/button-generator/effects/border.js @@ -1,4 +1,4 @@ -import { ButtonEffect } from '../effect-base.js'; +import { ButtonEffect } from "../effect-base.js"; /** * Border effect @@ -7,65 +7,107 @@ import { ButtonEffect } from '../effect-base.js'; export class BorderEffect extends ButtonEffect { constructor() { super({ - id: 'border', - name: 'Border', - type: 'border', - category: 'Border', - renderOrder: 10 + id: "border", + name: "Border", + type: "border", + category: "Border", + renderOrder: 10, }); } defineControls() { return [ { - id: 'border-width', - type: 'range', - label: 'Border Width', + id: "border-width", + type: "range", + label: "Border Width", defaultValue: 2, min: 0, max: 5, step: 1, - description: 'Width of border in pixels' + description: "Width of border in pixels", }, { - id: 'border-color', - type: 'color', - label: 'Border Color', - defaultValue: '#000000' + id: "border-color", + type: "color", + label: "Border Color", + defaultValue: "#000000", }, { - id: 'border-style', - type: 'select', - label: 'Border Style', - defaultValue: 'solid', + id: "border-style", + type: "select", + label: "Border Style", + defaultValue: "solid", options: [ - { value: 'solid', label: 'Solid' }, - { value: 'inset', label: 'Inset (3D)' }, - { value: 'outset', label: 'Outset (3D)' }, - { value: 'ridge', label: 'Ridge' } - ] - } + { value: "solid", label: "Solid" }, + { value: "dashed", label: "Dashed" }, + { value: "dotted", label: "Dotted" }, + { value: "double", label: "Double" }, + { value: "inset", label: "Inset (3D)" }, + { value: "outset", label: "Outset (3D)" }, + { value: "ridge", label: "Ridge" }, + { value: "rainbow", label: "Rainbow (Animated)" }, + { value: "marching-ants", label: "Marching Ants" }, + { value: "checkerboard", label: "Checkerboard" }, + ], + }, + { + id: "border-rainbow-speed", + type: "range", + label: "Rainbow Speed", + defaultValue: 1, + min: 1, + max: 3, + step: 1, + showWhen: "border-style", + description: "Speed of rainbow animation", + }, + { + id: "border-march-speed", + type: "range", + label: "March Speed", + defaultValue: 1, + min: 1, + max: 3, + step: 1, + showWhen: "border-style", + description: "Speed of marching animation", + }, ]; } isEnabled(controlValues) { - const width = controlValues['border-width'] || 0; + const width = controlValues["border-width"] || 0; return width > 0; } apply(context, controlValues, animState, renderData) { - const width = controlValues['border-width'] || 0; + const width = controlValues["border-width"] || 0; if (width === 0) return; - const color = controlValues['border-color'] || '#000000'; - const style = controlValues['border-style'] || 'solid'; + const color = controlValues["border-color"] || "#000000"; + const style = controlValues["border-style"] || "solid"; - if (style === 'solid') { + if (style === "solid") { this.drawSolidBorder(context, width, color, renderData); - } else if (style === 'inset' || style === 'outset') { - this.draw3DBorder(context, width, style === 'outset', renderData); - } else if (style === 'ridge') { + } else if (style === "dashed") { + this.drawDashedBorder(context, width, color, renderData); + } else if (style === "dotted") { + this.drawDottedBorder(context, width, color, renderData); + } else if (style === "double") { + this.drawDoubleBorder(context, width, color, renderData); + } else if (style === "inset" || style === "outset") { + this.draw3DBorder(context, width, color, style === "outset", renderData); + } else if (style === "ridge") { this.drawRidgeBorder(context, width, renderData); + } else if (style === "rainbow") { + const speed = controlValues["border-rainbow-speed"] || 1; + this.drawRainbowBorder(context, width, animState, speed, renderData); + } else if (style === "marching-ants") { + const speed = controlValues["border-march-speed"] || 1; + this.drawMarchingAntsBorder(context, width, animState, speed, renderData); + } else if (style === "checkerboard") { + this.drawCheckerboardBorder(context, width, color, renderData); } } @@ -79,33 +121,65 @@ export class BorderEffect extends ButtonEffect { width / 2, width / 2, renderData.width - width, - renderData.height - width + renderData.height - width, ); } /** * Draw 3D inset/outset border */ - draw3DBorder(context, width, isOutset, renderData) { - const lightColor = isOutset ? '#ffffff' : '#000000'; - const darkColor = isOutset ? '#000000' : '#ffffff'; + draw3DBorder(context, width, color, isOutset, renderData) { + const w = renderData.width; + const h = renderData.height; + const t = width; - // Top and left (light) - context.strokeStyle = lightColor; - context.lineWidth = width; - context.beginPath(); - context.moveTo(0, renderData.height); - context.lineTo(0, 0); - context.lineTo(renderData.width, 0); - context.stroke(); + const normalized = color.toLowerCase(); + const isPureBlack = normalized === "#000000"; + const isPureWhite = normalized === "#ffffff"; - // Bottom and right (dark) - context.strokeStyle = darkColor; - context.beginPath(); - context.moveTo(renderData.width, 0); - context.lineTo(renderData.width, renderData.height); - context.lineTo(0, renderData.height); - context.stroke(); + let lightColor; + let darkColor; + + if (isPureBlack || isPureWhite) { + lightColor = isOutset ? "#ffffff" : "#000000"; + darkColor = isOutset ? "#000000" : "#ffffff"; + } else { + const lighter = this.adjustColor(color, 0.25); + const darker = this.adjustColor(color, -0.25); + + lightColor = isOutset ? lighter : darker; + darkColor = isOutset ? darker : lighter; + } + + context.fillStyle = lightColor; + context.fillRect(0, 0, w - t, t); + context.fillRect(0, t, t, h - t); + + context.fillStyle = darkColor; + context.fillRect(t, h - t, w - t, t); + context.fillRect(w - t, 0, t, h - t); + + this.drawBevelCorners(context, t, w, h, lightColor, darkColor, isOutset); + } + + drawBevelCorners(ctx, t, w, h, light, dark, isOutset) { + // Top-left corner + ctx.fillStyle = dark; + ctx.beginPath(); + ctx.moveTo(0, h); + ctx.lineTo(t, h); + ctx.lineTo(t, h - t); + ctx.closePath(); + ctx.fill(); + + // Bottom-right corner + ctx.fillStyle = light; + ctx.beginPath(); + ctx.moveTo(w - t, 0); + ctx.lineTo(w - t, t); + ctx.lineTo(w, 0); + ctx.closePath(); + ctx.fill(); } /** @@ -113,24 +187,212 @@ export class BorderEffect extends ButtonEffect { */ drawRidgeBorder(context, width, renderData) { // Outer ridge (light) - context.strokeStyle = '#ffffff'; + context.strokeStyle = "#ffffff"; context.lineWidth = width / 2; context.strokeRect( width / 4, width / 4, renderData.width - width / 2, - renderData.height - width / 2 + renderData.height - width / 2, ); // Inner ridge (dark) - context.strokeStyle = '#000000'; + context.strokeStyle = "#000000"; context.strokeRect( (width * 3) / 4, (width * 3) / 4, renderData.width - width * 1.5, - renderData.height - width * 1.5 + renderData.height - width * 1.5, ); } + + adjustColor(hex, amount) { + // hex: "#rrggbb", amount: -1.0 .. 1.0 + let r = parseInt(hex.slice(1, 3), 16); + let g = parseInt(hex.slice(3, 5), 16); + let b = parseInt(hex.slice(5, 7), 16); + + const adjust = (c) => + Math.min(255, Math.max(0, Math.round(c + amount * 255))); + + r = adjust(r); + g = adjust(g); + b = adjust(b); + + return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; + } + + /** + * Draw dashed border + */ + drawDashedBorder(context, width, color, renderData) { + context.strokeStyle = color; + context.lineWidth = width; + context.setLineDash([6, 3]); // 6px dash, 3px gap + context.strokeRect( + width / 2, + width / 2, + renderData.width - width, + renderData.height - width + ); + context.setLineDash([]); // Reset to solid + } + + /** + * Draw dotted border + */ + drawDottedBorder(context, width, color, renderData) { + context.strokeStyle = color; + context.lineWidth = width; + context.setLineDash([2, 3]); // 2px dot, 3px gap + context.lineCap = "round"; + context.strokeRect( + width / 2, + width / 2, + renderData.width - width, + renderData.height - width + ); + context.setLineDash([]); // Reset to solid + context.lineCap = "butt"; + } + + /** + * Draw double border + */ + drawDoubleBorder(context, width, color, renderData) { + const gap = Math.max(1, Math.floor(width / 3)); + const lineWidth = Math.max(1, Math.floor((width - gap) / 2)); + + context.strokeStyle = color; + context.lineWidth = lineWidth; + + // Outer border + context.strokeRect( + lineWidth / 2, + lineWidth / 2, + renderData.width - lineWidth, + renderData.height - lineWidth + ); + + // Inner border + const innerOffset = lineWidth + gap; + context.strokeRect( + innerOffset + lineWidth / 2, + innerOffset + lineWidth / 2, + renderData.width - innerOffset * 2 - lineWidth, + renderData.height - innerOffset * 2 - lineWidth + ); + } + + /** + * Draw rainbow animated border + */ + drawRainbowBorder(context, width, animState, speed, renderData) { + if (!animState) { + // Fallback to solid if no animation + this.drawSolidBorder(context, width, "#ff0000", renderData); + return; + } + + const hue = (animState.progress * speed * 360) % 360; + const color = `hsl(${hue}, 80%, 50%)`; + + context.strokeStyle = color; + context.lineWidth = width; + context.strokeRect( + width / 2, + width / 2, + renderData.width - width, + renderData.height - width + ); + } + + /** + * Draw marching ants animated border + */ + drawMarchingAntsBorder(context, width, animState, speed, renderData) { + if (!animState) { + // Fallback to dashed if no animation + this.drawDashedBorder(context, width, "#000000", renderData); + return; + } + + // Animate the dash offset using phase for smooth looping + const phase = animState.getPhase(speed); + const dashLength = 9; // 4px dash + 5px gap = 9px total + const offset = (phase / (Math.PI * 2)) * dashLength; + + context.strokeStyle = "#000000"; + context.lineWidth = width; + context.setLineDash([4, 5]); + context.lineDashOffset = -offset; + context.strokeRect( + width / 2, + width / 2, + renderData.width - width, + renderData.height - width + ); + context.setLineDash([]); + context.lineDashOffset = 0; + } + + /** + * Draw checkerboard border + */ + drawCheckerboardBorder(context, width, color, renderData) { + const squareSize = Math.max(2, width); + const w = renderData.width; + const h = renderData.height; + + // Parse the color + const r = parseInt(color.slice(1, 3), 16); + const g = parseInt(color.slice(3, 5), 16); + const b = parseInt(color.slice(5, 7), 16); + + // Create light and dark versions + const darkColor = color; + const lightColor = `rgb(${Math.min(255, r + 60)}, ${Math.min( + 255, + g + 60 + )}, ${Math.min(255, b + 60)})`; + + // Draw checkerboard on all four sides + // Top + for (let x = 0; x < w; x += squareSize) { + for (let y = 0; y < width; y += squareSize) { + const checker = Math.floor(x / squareSize + y / squareSize) % 2; + context.fillStyle = checker === 0 ? darkColor : lightColor; + context.fillRect(x, y, squareSize, squareSize); + } + } + + // Bottom + for (let x = 0; x < w; x += squareSize) { + for (let y = h - width; y < h; y += squareSize) { + const checker = Math.floor(x / squareSize + y / squareSize) % 2; + context.fillStyle = checker === 0 ? darkColor : lightColor; + context.fillRect(x, y, squareSize, squareSize); + } + } + + // Left + for (let x = 0; x < width; x += squareSize) { + for (let y = width; y < h - width; y += squareSize) { + const checker = Math.floor(x / squareSize + y / squareSize) % 2; + context.fillStyle = checker === 0 ? darkColor : lightColor; + context.fillRect(x, y, squareSize, squareSize); + } + } + + // Right + for (let x = w - width; x < w; x += squareSize) { + for (let y = width; y < h - width; y += squareSize) { + const checker = Math.floor(x / squareSize + y / squareSize) % 2; + context.fillStyle = checker === 0 ? darkColor : lightColor; + context.fillRect(x, y, squareSize, squareSize); + } + } + } } // Auto-register effect diff --git a/static/js/button-generator/effects/spin-text.js b/static/js/button-generator/effects/spin-text.js index d009b2b..a075844 100644 --- a/static/js/button-generator/effects/spin-text.js +++ b/static/js/button-generator/effects/spin-text.js @@ -1,4 +1,4 @@ -import { ButtonEffect } from '../effect-base.js'; +import { ButtonEffect } from "../effect-base.js"; /** * Spinning text animation effect @@ -6,61 +6,62 @@ import { ButtonEffect } from '../effect-base.js'; */ export class SpinTextEffect extends ButtonEffect { constructor(textLineNumber = 1) { - const suffix = textLineNumber === 1 ? '' : '2'; + const suffix = textLineNumber === 1 ? "" : "2"; super({ id: `text-spin${suffix}`, name: `Spinning Text ${textLineNumber}`, - type: textLineNumber === 1 ? 'text' : 'text2', - category: textLineNumber === 1 ? 'Text Line 1' : 'Text Line 2', + type: textLineNumber === 1 ? "text" : "text2", + category: textLineNumber === 1 ? "Text Line 1" : "Text Line 2", renderOrder: 8, // Before wave, after rainbow - textLineNumber: textLineNumber + textLineNumber: textLineNumber, }); this.textLineNumber = textLineNumber; } defineControls() { - const textLineNumber = this.textLineNumber || this.config?.textLineNumber || 1; - const suffix = textLineNumber === 1 ? '' : '2'; + const textLineNumber = + this.textLineNumber || this.config?.textLineNumber || 1; + const suffix = textLineNumber === 1 ? "" : "2"; return [ { id: `animate-text-spin${suffix}`, - type: 'checkbox', - label: 'Spinning Animation', - defaultValue: false + type: "checkbox", + label: "Spinning Animation", + defaultValue: false, }, { id: `spin-speed${suffix}`, - type: 'range', - label: 'Spin Speed', + type: "range", + label: "Spin Speed", defaultValue: 1, - min: 0.1, + min: 1, max: 5, - step: 0.1, + step: 1, showWhen: `animate-text-spin${suffix}`, - description: 'Speed of character rotation' + description: "Speed of character rotation", }, { id: `spin-stagger${suffix}`, - type: 'range', - label: 'Spin Stagger', + type: "range", + label: "Spin Stagger", defaultValue: 0.3, min: 0, max: 1, step: 0.1, showWhen: `animate-text-spin${suffix}`, - description: 'Delay between characters' - } + description: "Delay between characters", + }, ]; } isEnabled(controlValues) { - const suffix = this.textLineNumber === 1 ? '' : '2'; + const suffix = this.textLineNumber === 1 ? "" : "2"; return controlValues[`animate-text-spin${suffix}`] === true; } apply(context, controlValues, animState, renderData) { - const suffix = this.textLineNumber === 1 ? '' : '2'; - const text = controlValues[`button-text${suffix}`] || ''; + const suffix = this.textLineNumber === 1 ? "" : "2"; + const text = controlValues[`button-text${suffix}`] || ""; if (!text || !controlValues[`text${suffix}-enabled`]) return; if (!animState) return; @@ -68,21 +69,26 @@ export class SpinTextEffect extends ButtonEffect { const speed = controlValues[`spin-speed${suffix}`] || 1; const stagger = controlValues[`spin-stagger${suffix}`] || 0.3; const fontSize = controlValues[`font-size${suffix}`] || 14; - const fontFamily = controlValues[`font-family${suffix}`] || 'Arial'; - const fontWeight = controlValues[`text${suffix}-bold`] ? 'bold' : 'normal'; - const fontStyle = controlValues[`text${suffix}-italic`] ? 'italic' : 'normal'; + const fontFamily = controlValues[`font-family${suffix}`] || "Arial"; + const fontWeight = controlValues[`text${suffix}-bold`] ? "bold" : "normal"; + const fontStyle = controlValues[`text${suffix}-italic`] + ? "italic" + : "normal"; context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`; - context.textAlign = 'center'; - context.textBaseline = 'middle'; + context.textAlign = "center"; + context.textBaseline = "middle"; // Get text color let fillStyle; - const colorType = controlValues[`text${suffix}-color-type`] || 'solid'; - if (colorType === 'gradient') { - const color1 = controlValues[`text${suffix}-gradient-color1`] || '#ffffff'; - const color2 = controlValues[`text${suffix}-gradient-color2`] || '#00ffff'; - const angle = (controlValues[`text${suffix}-gradient-angle`] || 90) * (Math.PI / 180); + const colorType = controlValues[`text${suffix}-color-type`] || "solid"; + if (colorType === "gradient") { + const color1 = + controlValues[`text${suffix}-gradient-color1`] || "#ffffff"; + const color2 = + controlValues[`text${suffix}-gradient-color2`] || "#00ffff"; + const angle = + (controlValues[`text${suffix}-gradient-angle`] || 90) * (Math.PI / 180); const centerX = renderData.centerX; const centerY = renderData.centerY; const x1 = centerX + Math.cos(angle) * 20; @@ -94,7 +100,7 @@ export class SpinTextEffect extends ButtonEffect { gradient.addColorStop(1, color2); fillStyle = gradient; } else { - fillStyle = controlValues[`text${suffix}-color`] || '#ffffff'; + fillStyle = controlValues[`text${suffix}-color`] || "#ffffff"; } // Calculate base position @@ -103,13 +109,24 @@ export class SpinTextEffect extends ButtonEffect { const baseX = (x / 100) * renderData.width; const baseY = (y / 100) * renderData.height; + // Split text into grapheme clusters (handles emojis properly) + // Use Intl.Segmenter if available, otherwise fall back to spread operator + let chars; + if (typeof Intl !== 'undefined' && Intl.Segmenter) { + const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' }); + chars = Array.from(segmenter.segment(text), s => s.segment); + } else { + // Fallback: spread operator handles basic emoji + chars = [...text]; + } + // Measure total text width for centering const totalWidth = context.measureText(text).width; let currentX = baseX - totalWidth / 2; // Draw each character with rotation - for (let i = 0; i < text.length; i++) { - const char = text[i]; + for (let i = 0; i < chars.length; i++) { + const char = chars[i]; const charWidth = context.measureText(char).width; const charCenterX = currentX + charWidth / 2; @@ -123,7 +140,8 @@ export class SpinTextEffect extends ButtonEffect { // Apply outline if enabled if (controlValues[`text${suffix}-outline`]) { - context.strokeStyle = controlValues[`text${suffix}-outline-color`] || '#000000'; + context.strokeStyle = + controlValues[`text${suffix}-outline-color`] || "#000000"; context.lineWidth = 2; context.strokeText(char, 0, 0); } diff --git a/static/js/button-generator/effects/spotlight.js b/static/js/button-generator/effects/spotlight.js index b1185e7..cfd79fb 100644 --- a/static/js/button-generator/effects/spotlight.js +++ b/static/js/button-generator/effects/spotlight.js @@ -70,10 +70,10 @@ export class SpotlightEffect extends ButtonEffect { id: "spotlight-darkness", type: "range", label: "Darkness", - defaultValue: 0.5, + defaultValue: 1, min: 0, max: 1, - step: 0.05, + step: 0.1, showWhen: "animate-spotlight", description: "How dark the non-spotlight area should be", }, @@ -84,9 +84,9 @@ export class SpotlightEffect extends ButtonEffect { type: "range", label: "Movement Speed", defaultValue: 1, - min: 0.1, + min: 1, max: 3, - step: 0.1, + step: 1, showWhen: "animate-spotlight", description: "Speed of spotlight movement", }, @@ -152,16 +152,6 @@ export class SpotlightEffect extends ButtonEffect { context.fill(); } - /** - * Optional: Override canApply for more complex logic - * By default, it just checks isEnabled() - */ - canApply(controlValues) { - // Example: Only apply if text is also enabled - const textEnabled = controlValues["textEnabled"]; - return this.isEnabled(controlValues) && textEnabled; - } - /** * Optional: Add helper methods for your effect */ diff --git a/static/js/button-generator/effects/text-shadow.js b/static/js/button-generator/effects/text-shadow.js new file mode 100644 index 0000000..3daa287 --- /dev/null +++ b/static/js/button-generator/effects/text-shadow.js @@ -0,0 +1,167 @@ +import { ButtonEffect } from "../effect-base.js"; + +/** + * Text Drop Shadow Effect + * Renders text with a drop shadow underneath + * This draws the shadow first, then standard text rendering draws on top + */ +export class TextShadowEffect extends ButtonEffect { + constructor(textLineNumber = 1) { + const suffix = textLineNumber === 1 ? "" : "2"; + super({ + id: `text-shadow${suffix}`, + name: `Drop Shadow ${textLineNumber}`, + type: textLineNumber === 1 ? "text" : "text2", + category: textLineNumber === 1 ? "Text Line 1" : "Text Line 2", + renderOrder: 19, // Before standard text (20), so shadow draws first + textLineNumber: textLineNumber, + }); + this.textLineNumber = textLineNumber; + } + + defineControls() { + const textLineNumber = + this.textLineNumber || this.config?.textLineNumber || 1; + const suffix = textLineNumber === 1 ? "" : "2"; + + return [ + { + id: `text${suffix}-shadow-enabled`, + type: "checkbox", + label: "Drop Shadow", + defaultValue: false, + description: + "Add drop shadow to text - Not compatible with other text effects!!", + }, + { + id: `text${suffix}-shadow-color`, + type: "color", + label: "Shadow Color", + defaultValue: "#000000", + showWhen: `text${suffix}-shadow-enabled`, + description: "Color of the shadow", + }, + { + id: `text${suffix}-shadow-blur`, + type: "range", + label: "Shadow Blur", + defaultValue: 4, + min: 0, + max: 10, + step: 1, + showWhen: `text${suffix}-shadow-enabled`, + description: "Blur radius of shadow", + }, + { + id: `text${suffix}-shadow-offset-x`, + type: "range", + label: "Shadow X Offset", + defaultValue: 2, + min: -10, + max: 10, + step: 1, + showWhen: `text${suffix}-shadow-enabled`, + description: "Horizontal shadow offset", + }, + { + id: `text${suffix}-shadow-offset-y`, + type: "range", + label: "Shadow Y Offset", + defaultValue: 2, + min: -10, + max: 10, + step: 1, + showWhen: `text${suffix}-shadow-enabled`, + description: "Vertical shadow offset", + }, + { + id: `text${suffix}-shadow-opacity`, + type: "range", + label: "Shadow Opacity", + defaultValue: 0.8, + min: 0, + max: 1, + step: 0.1, + showWhen: `text${suffix}-shadow-enabled`, + description: "Opacity of the shadow", + }, + ]; + } + + isEnabled(controlValues) { + const suffix = this.textLineNumber === 1 ? "" : "2"; + const textEnabled = controlValues[`text${suffix}-enabled`]; + const shadowEnabled = controlValues[`text${suffix}-shadow-enabled`]; + return textEnabled && shadowEnabled; + } + + apply(context, controlValues, animState, renderData) { + const suffix = this.textLineNumber === 1 ? "" : "2"; + + const text = controlValues[`button-text${suffix}`] || ""; + if (!text) return; + + // Get shadow settings + const shadowColor = + controlValues[`text${suffix}-shadow-color`] || "#000000"; + const shadowBlur = controlValues[`text${suffix}-shadow-blur`] || 4; + const shadowOffsetX = controlValues[`text${suffix}-shadow-offset-x`] || 2; + const shadowOffsetY = controlValues[`text${suffix}-shadow-offset-y`] || 2; + const shadowOpacity = controlValues[`text${suffix}-shadow-opacity`] || 0.8; + + // Get text rendering settings + const fontSize = controlValues[`font-size${suffix}`] || 14; + const fontFamily = controlValues[`font-family${suffix}`] || "Arial"; + const fontWeight = controlValues[`text${suffix}-bold`] ? "bold" : "normal"; + const fontStyle = controlValues[`text${suffix}-italic`] + ? "italic" + : "normal"; + const textX = (controlValues[`text${suffix}-x`] || 50) / 100; + const textY = (controlValues[`text${suffix}-y`] || 50) / 100; + + // Convert hex to rgba + const hexToRgba = (hex, alpha) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (result) { + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + return `rgba(0, 0, 0, ${alpha})`; + }; + + // Set up text rendering + context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`; + context.textAlign = "center"; + context.textBaseline = "middle"; + + // Calculate text position + const x = renderData.width * textX; + const y = renderData.height * textY; + + // Draw the shadow using the shadow API + // This will create a shadow underneath whatever we draw + context.shadowColor = hexToRgba(shadowColor, shadowOpacity); + context.shadowBlur = shadowBlur; + context.shadowOffsetX = shadowOffsetX; + context.shadowOffsetY = shadowOffsetY; + + // Draw a solid shadow by filling with the shadow color + // The shadow API will create the blur effect + context.fillStyle = hexToRgba(shadowColor, shadowOpacity); + context.fillText(text, x, y); + + // Reset shadow for subsequent renders + context.shadowColor = "transparent"; + context.shadowBlur = 0; + context.shadowOffsetX = 0; + context.shadowOffsetY = 0; + } +} + +// Export two instances for text line 1 and text line 2 +export function register(generator) { + generator.registerEffect(new TextShadowEffect(1)); + generator.registerEffect(new TextShadowEffect(2)); +} diff --git a/static/js/button-generator/effects/text-standard.js b/static/js/button-generator/effects/text-standard.js index 9caaf58..fb90f87 100644 --- a/static/js/button-generator/effects/text-standard.js +++ b/static/js/button-generator/effects/text-standard.js @@ -1,4 +1,4 @@ -import { ButtonEffect } from '../effect-base.js'; +import { ButtonEffect } from "../effect-base.js"; /** * Standard text rendering effect @@ -6,180 +6,184 @@ import { ButtonEffect } from '../effect-base.js'; */ export class StandardTextEffect extends ButtonEffect { constructor(textLineNumber = 1) { - const suffix = textLineNumber === 1 ? '' : '2'; + const suffix = textLineNumber === 1 ? "" : "2"; super({ id: `text-standard${suffix}`, name: `Standard Text ${textLineNumber}`, - type: textLineNumber === 1 ? 'text' : 'text2', - category: textLineNumber === 1 ? 'Text Line 1' : 'Text Line 2', + type: textLineNumber === 1 ? "text" : "text2", + category: textLineNumber === 1 ? "Text Line 1" : "Text Line 2", renderOrder: 20, // After animations - textLineNumber: textLineNumber // Pass through config so defineControls can access it + textLineNumber: textLineNumber, // Pass through config so defineControls can access it }); this.textLineNumber = textLineNumber; } defineControls() { // Access from config since this is called before constructor completes - const textLineNumber = this.textLineNumber || this.config?.textLineNumber || 1; - const suffix = textLineNumber === 1 ? '' : '2'; + const textLineNumber = + this.textLineNumber || this.config?.textLineNumber || 1; + const suffix = textLineNumber === 1 ? "" : "2"; return [ { id: `button-text${suffix}`, - type: 'text', + type: "text", label: `Text Line ${textLineNumber}`, - defaultValue: textLineNumber === 1 ? 'RITUAL.SH' : '' - }, - { - id: `text${suffix}-enabled`, - type: 'checkbox', - label: `Enable Text Line ${textLineNumber}`, - defaultValue: textLineNumber === 1 + defaultValue: textLineNumber === 1 ? "RITUAL.SH" : "", }, { id: `font-size${suffix}`, - type: 'range', - label: 'Font Size', + type: "range", + label: "Font Size", min: 6, max: 24, - defaultValue: textLineNumber === 1 ? 14 : 12 + defaultValue: textLineNumber === 1 ? 14 : 12, }, { id: `text${suffix}-x`, - type: 'range', - label: 'Horizontal Position', + type: "range", + label: "Horizontal Position", min: 0, max: 100, defaultValue: 50, - description: 'Percentage from left' + description: "Percentage from left", }, { id: `text${suffix}-y`, - type: 'range', - label: 'Vertical Position', + type: "range", + label: "Vertical Position", min: 0, max: 100, defaultValue: textLineNumber === 1 ? 35 : 65, - description: 'Percentage from top' + description: "Percentage from top", }, { id: `text${suffix}-color-type`, - type: 'select', - label: 'Color Type', - defaultValue: 'solid', + type: "select", + label: "Color Type", + defaultValue: "solid", options: [ - { value: 'solid', label: 'Solid Color' }, - { value: 'gradient', label: 'Gradient' } - ] + { value: "solid", label: "Solid Color" }, + { value: "gradient", label: "Gradient" }, + ], }, { id: `text${suffix}-color`, - type: 'color', - label: 'Text Color', - defaultValue: '#ffffff', - showWhen: `text${suffix}-color-type` + type: "color", + label: "Text Color", + defaultValue: "#ffffff", + showWhen: `text${suffix}-color-type`, }, { id: `text${suffix}-gradient-color1`, - type: 'color', - label: 'Gradient Color 1', - defaultValue: '#ffffff', - showWhen: `text${suffix}-color-type` + type: "color", + label: "Gradient Color 1", + defaultValue: "#ffffff", + showWhen: `text${suffix}-color-type`, }, { id: `text${suffix}-gradient-color2`, - type: 'color', - label: 'Gradient Color 2', - defaultValue: '#00ffff', - showWhen: `text${suffix}-color-type` + type: "color", + label: "Gradient Color 2", + defaultValue: "#00ffff", + showWhen: `text${suffix}-color-type`, }, { id: `text${suffix}-gradient-angle`, - type: 'range', - label: 'Gradient Angle', + type: "range", + label: "Gradient Angle", min: 0, max: 360, defaultValue: 0, - showWhen: `text${suffix}-color-type` + showWhen: `text${suffix}-color-type`, }, { id: `text${suffix}-outline`, - type: 'checkbox', - label: 'Outline', - defaultValue: false + type: "checkbox", + label: "Outline", + defaultValue: false, }, { id: `outline${suffix}-color`, - type: 'color', - label: 'Outline Color', - defaultValue: '#000000', - showWhen: `text${suffix}-outline` + type: "color", + label: "Outline Color", + defaultValue: "#000000", + showWhen: `text${suffix}-outline`, }, { id: `font-family${suffix}`, - type: 'select', - label: 'Font', - defaultValue: 'Lato', + type: "select", + label: "Font", + defaultValue: "Lato", options: [ - { value: 'Lato', label: 'Lato' }, - { value: 'Roboto', label: 'Roboto' }, - { value: 'Open Sans', label: 'Open Sans' }, - { value: 'Montserrat', label: 'Montserrat' }, - { value: 'Oswald', label: 'Oswald' }, - { value: 'Bebas Neue', label: 'Bebas Neue' }, - { value: 'Roboto Mono', label: 'Roboto Mono' }, - { value: 'VT323', label: 'VT323' }, - { value: 'Press Start 2P', label: 'Press Start 2P' }, - { value: 'DSEG7-Classic', label: 'DSEG7' } - ] + { value: "Lato", label: "Lato" }, + { value: "Roboto", label: "Roboto" }, + { value: "Open Sans", label: "Open Sans" }, + { value: "Montserrat", label: "Montserrat" }, + { value: "Oswald", label: "Oswald" }, + { value: "Bebas Neue", label: "Bebas Neue" }, + { value: "Roboto Mono", label: "Roboto Mono" }, + { value: "VT323", label: "VT323" }, + { value: "Press Start 2P", label: "Press Start 2P" }, + { value: "DSEG7-Classic", label: "DSEG7" }, + ], }, { id: `font-bold${suffix}`, - type: 'checkbox', - label: 'Bold', - defaultValue: false + type: "checkbox", + label: "Bold", + defaultValue: false, }, { id: `font-italic${suffix}`, - type: 'checkbox', - label: 'Italic', - defaultValue: false - } + type: "checkbox", + label: "Italic", + defaultValue: false, + }, ]; } isEnabled(controlValues) { - const suffix = this.textLineNumber === 1 ? '' : '2'; + const suffix = this.textLineNumber === 1 ? "" : "2"; const text = controlValues[`button-text${suffix}`]; - const enabled = controlValues[`text${suffix}-enabled`]; - // Only render if text exists, is enabled, and no animations are active on this text + // Only render if text exists and no animations are active on this text const waveActive = controlValues[`animate-text-wave${suffix}`]; const rainbowActive = controlValues[`animate-text-rainbow${suffix}`]; + const spinActive = controlValues[`animate-text-spin${suffix}`]; - return text && enabled && !waveActive && !rainbowActive; + return text && text.trim() !== "" && !waveActive && !rainbowActive && !spinActive; } apply(context, controlValues, animState, renderData) { - const suffix = this.textLineNumber === 1 ? '' : '2'; + const suffix = this.textLineNumber === 1 ? "" : "2"; const text = controlValues[`button-text${suffix}`]; if (!text) 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 fontWeight = controlValues[`font-bold${suffix}`] ? "bold" : "normal"; + const fontStyle = controlValues[`font-italic${suffix}`] + ? "italic" + : "normal"; + const fontFamily = controlValues[`font-family${suffix}`] || "Arial"; const x = (controlValues[`text${suffix}-x`] / 100) * renderData.width; const y = (controlValues[`text${suffix}-y`] / 100) * renderData.height; // Set font context.font = `${fontStyle} ${fontWeight} ${fontSize}px "${fontFamily}"`; - context.textAlign = 'center'; - context.textBaseline = 'middle'; + context.textAlign = "center"; + context.textBaseline = "middle"; // Get colors - const colors = this.getTextColors(context, controlValues, text, x, y, fontSize); + const colors = this.getTextColors( + context, + controlValues, + text, + x, + y, + fontSize, + ); // Draw outline if enabled if (controlValues[`text${suffix}-outline`]) { @@ -197,17 +201,18 @@ export class StandardTextEffect extends ButtonEffect { * Get text colors (solid or gradient) */ getTextColors(context, controlValues, text, x, y, fontSize) { - const suffix = this.textLineNumber === 1 ? '' : '2'; - const colorType = controlValues[`text${suffix}-color-type`] || 'solid'; + const suffix = this.textLineNumber === 1 ? "" : "2"; + const colorType = controlValues[`text${suffix}-color-type`] || "solid"; let fillStyle, strokeStyle; - if (colorType === 'solid') { - fillStyle = controlValues[`text${suffix}-color`] || '#ffffff'; - strokeStyle = controlValues[`outline${suffix}-color`] || '#000000'; + 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 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; @@ -215,10 +220,16 @@ export class StandardTextEffect extends ButtonEffect { 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'); + 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'; + strokeStyle = controlValues[`outline${suffix}-color`] || "#000000"; } return { fillStyle, strokeStyle }; diff --git a/static/js/button-generator/effects/wave-text.js b/static/js/button-generator/effects/wave-text.js index cf36211..b394c61 100644 --- a/static/js/button-generator/effects/wave-text.js +++ b/static/js/button-generator/effects/wave-text.js @@ -87,18 +87,29 @@ export class WaveTextEffect extends ButtonEffect { // Get colors const colors = this.getTextColors(context, controlValues, text, baseX, baseY, fontSize, animState); + // Split text into grapheme clusters (handles emojis properly) + // Use Intl.Segmenter if available, otherwise fall back to spread operator + let chars; + if (typeof Intl !== 'undefined' && Intl.Segmenter) { + const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' }); + chars = Array.from(segmenter.segment(text), s => s.segment); + } else { + // Fallback: spread operator handles basic emoji + chars = [...text]; + } + // Measure total width for centering const totalWidth = context.measureText(text).width; let currentX = baseX - totalWidth / 2; // Draw each character with wave offset - for (let i = 0; i < text.length; i++) { - const char = text[i]; + for (let i = 0; i < chars.length; i++) { + const char = chars[i]; const charWidth = context.measureText(char).width; // Calculate wave offset for this character const phase = animState.getPhase(speed); - const charOffset = i / text.length; + const charOffset = i / chars.length; const waveY = Math.sin(phase + charOffset * Math.PI * 2) * amplitude; const charX = currentX + charWidth / 2; diff --git a/static/js/button-generator/main.js b/static/js/button-generator/main.js index 805edef..51233e8 100644 --- a/static/js/button-generator/main.js +++ b/static/js/button-generator/main.js @@ -15,8 +15,13 @@ import * as textureBg from "./effects/background-texture.js"; import * as emojiWallpaper from "./effects/background-emoji-wallpaper.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"; +//import * as bubbles from "./effects/background-bubbles.js"; +import * as aurora from "./effects/background-aurora.js"; +import * as fire from "./effects/background-fire.js"; import * as border from "./effects/border.js"; 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 spinText from "./effects/spin-text.js"; @@ -63,6 +68,7 @@ async function setupApp() { fps: 20, duration: 2, fonts: [ + "Arial", "Lato", "Roboto", "Open Sans", @@ -84,8 +90,13 @@ async function setupApp() { emojiWallpaper.register(generator); rainbowBg.register(generator); rain.register(generator); + starfield.register(generator); + //bubbles.register(generator); + aurora.register(generator); + fire.register(generator); border.register(generator); standardText.register(generator); + textShadow.register(generator); waveText.register(generator); rainbowText.register(generator); spinText.register(generator); diff --git a/static/js/button-generator/ui-builder.js b/static/js/button-generator/ui-builder.js index 2a051a4..00ce430 100644 --- a/static/js/button-generator/ui-builder.js +++ b/static/js/button-generator/ui-builder.js @@ -6,6 +6,120 @@ export class UIBuilder { constructor(containerElement) { this.container = containerElement; this.controlGroups = new Map(); // category -> { element, controls } + this.tooltip = null; + this.tooltipTimeout = null; + this.setupTooltip(); + } + + /** + * Create and setup the tooltip element + */ + setupTooltip() { + // Wait for DOM to be ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + this.createTooltipElement(); + }); + } else { + this.createTooltipElement(); + } + } + + createTooltipElement() { + this.tooltip = document.createElement('div'); + this.tooltip.className = 'control-tooltip'; + this.tooltip.style.cssText = ` + position: fixed; + background: linear-gradient(135deg, rgba(0, 120, 200, 0.98) 0%, rgba(0, 100, 180, 0.98) 100%); + color: #fff; + padding: 0.5rem 0.75rem; + border-radius: 4px; + font-size: 0.8rem; + pointer-events: none; + z-index: 10000; + max-width: 250px; + box-shadow: 0 0 20px rgba(0, 150, 255, 0.5), 0 4px 12px rgba(0, 0, 0, 0.4); + border: 1px solid rgba(0, 150, 255, 0.6); + opacity: 0; + transition: opacity 0.15s ease; + line-height: 1.4; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + `; + document.body.appendChild(this.tooltip); + } + + /** + * Show tooltip for an element + */ + showTooltip(element, text) { + if (!text || !this.tooltip) return; + + clearTimeout(this.tooltipTimeout); + + this.tooltip.textContent = text; + this.tooltip.style.opacity = '1'; + + // Position tooltip above the element + const rect = element.getBoundingClientRect(); + + // Set initial position to measure + this.tooltip.style.left = '0px'; + this.tooltip.style.top = '0px'; + this.tooltip.style.visibility = 'hidden'; + this.tooltip.style.display = 'block'; + + const tooltipRect = this.tooltip.getBoundingClientRect(); + + this.tooltip.style.visibility = 'visible'; + + let left = rect.left + rect.width / 2 - tooltipRect.width / 2; + let top = rect.top - tooltipRect.height - 10; + + // Keep tooltip on screen + const padding = 10; + if (left < padding) left = padding; + if (left + tooltipRect.width > window.innerWidth - padding) { + left = window.innerWidth - tooltipRect.width - padding; + } + if (top < padding) { + top = rect.bottom + 10; + } + + this.tooltip.style.left = `${left}px`; + this.tooltip.style.top = `${top}px`; + } + + /** + * Hide tooltip + */ + hideTooltip() { + if (!this.tooltip) return; + clearTimeout(this.tooltipTimeout); + this.tooltipTimeout = setTimeout(() => { + this.tooltip.style.opacity = '0'; + }, 100); + } + + /** + * Add tooltip handlers to an element + */ + addTooltipHandlers(element, description) { + if (!description) return; + + element.addEventListener('mouseenter', () => { + this.showTooltip(element, description); + }); + + element.addEventListener('mouseleave', () => { + this.hideTooltip(); + }); + + element.addEventListener('mousemove', () => { + // Update position on mouse move for better following + if (this.tooltip && this.tooltip.style.opacity === '1') { + this.showTooltip(element, description); + } + }); } /** @@ -133,19 +247,19 @@ export class UIBuilder { switch (type) { case 'checkbox': - return this.createCheckbox(id, label, defaultValue, showWhen); + return this.createCheckbox(id, label, defaultValue, showWhen, description); case 'range': return this.createRange(id, label, defaultValue, min, max, step, description, showWhen); case 'color': - return this.createColor(id, label, defaultValue, showWhen); + return this.createColor(id, label, defaultValue, showWhen, description); case 'select': - return this.createSelect(id, label, defaultValue, options, showWhen); + return this.createSelect(id, label, defaultValue, options, showWhen, description); case 'text': - return this.createTextInput(id, label, defaultValue); + return this.createTextInput(id, label, defaultValue, showWhen, description); default: console.warn(`Unknown control type: ${type}`); @@ -156,7 +270,7 @@ export class UIBuilder { /** * Create a checkbox control */ - createCheckbox(id, label, defaultValue, showWhen) { + createCheckbox(id, label, defaultValue, showWhen, description) { const wrapper = document.createElement('label'); wrapper.className = 'checkbox-label'; @@ -176,6 +290,9 @@ export class UIBuilder { wrapper.dataset.showWhen = showWhen; } + // Add tooltip handlers to the label wrapper + this.addTooltipHandlers(wrapper, description); + return wrapper; } @@ -188,9 +305,6 @@ export class UIBuilder { const labelEl = document.createElement('label'); labelEl.htmlFor = id; labelEl.innerHTML = `${label}: ${defaultValue}`; - if (description) { - labelEl.title = description; - } const input = document.createElement('input'); input.type = 'range'; @@ -218,13 +332,16 @@ export class UIBuilder { container.dataset.showWhen = showWhen; } + // Add tooltip handlers to the label + this.addTooltipHandlers(labelEl, description); + return container; } /** * Create a color picker control */ - createColor(id, label, defaultValue, showWhen) { + createColor(id, label, defaultValue, showWhen, description) { const container = document.createElement('div'); const labelEl = document.createElement('label'); @@ -244,13 +361,16 @@ export class UIBuilder { container.dataset.showWhen = showWhen; } + // Add tooltip handlers to the label + this.addTooltipHandlers(labelEl, description); + return container; } /** * Create a select dropdown control */ - createSelect(id, label, defaultValue, options, showWhen) { + createSelect(id, label, defaultValue, options, showWhen, description) { const container = document.createElement('div'); const labelEl = document.createElement('label'); @@ -278,13 +398,16 @@ export class UIBuilder { container.dataset.showWhen = showWhen; } + // Add tooltip handlers to the label + this.addTooltipHandlers(labelEl, description); + return container; } /** * Create a text input control */ - createTextInput(id, label, defaultValue) { + createTextInput(id, label, defaultValue, showWhen, description) { const container = document.createElement('div'); const labelEl = document.createElement('label'); @@ -300,6 +423,14 @@ export class UIBuilder { container.appendChild(labelEl); container.appendChild(input); + if (showWhen) { + container.style.display = 'none'; + container.dataset.showWhen = showWhen; + } + + // Add tooltip handlers to the label + this.addTooltipHandlers(labelEl, description); + return container; } @@ -348,6 +479,14 @@ export class UIBuilder { } else if (controlId && controlId.startsWith('text2-gradient-')) { control.style.display = triggerControl.value === 'gradient' ? 'block' : 'none'; } + } + // For border style controls + else if (triggerControlId === 'border-style') { + if (controlId === 'border-rainbow-speed') { + control.style.display = triggerControl.value === 'rainbow' ? 'block' : 'none'; + } else if (controlId === 'border-march-speed') { + control.style.display = triggerControl.value === 'marching-ants' ? 'block' : 'none'; + } } else { // Default: show when any value is selected control.style.display = triggerControl.value ? 'block' : 'none';