Start of the rewrite to a modular system
This commit is contained in:
parent
2bfdc30caa
commit
4ac45367e5
29 changed files with 4414 additions and 588 deletions
205
static/js/button-generator/effects/EXAMPLE.js
Normal file
205
static/js/button-generator/effects/EXAMPLE.js
Normal file
|
|
@ -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
|
||||
*/
|
||||
109
static/js/button-generator/effects/background-emoji-wallpaper.js
Normal file
109
static/js/button-generator/effects/background-emoji-wallpaper.js
Normal file
|
|
@ -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());
|
||||
}
|
||||
76
static/js/button-generator/effects/background-gradient.js
Normal file
76
static/js/button-generator/effects/background-gradient.js
Normal file
|
|
@ -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());
|
||||
}
|
||||
117
static/js/button-generator/effects/background-rain.js
Normal file
117
static/js/button-generator/effects/background-rain.js
Normal file
|
|
@ -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());
|
||||
}
|
||||
137
static/js/button-generator/effects/background-rainbow.js
Normal file
137
static/js/button-generator/effects/background-rainbow.js
Normal file
|
|
@ -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());
|
||||
}
|
||||
56
static/js/button-generator/effects/background-solid.js
Normal file
56
static/js/button-generator/effects/background-solid.js
Normal file
|
|
@ -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());
|
||||
}
|
||||
217
static/js/button-generator/effects/background-texture.js
Normal file
217
static/js/button-generator/effects/background-texture.js
Normal file
|
|
@ -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());
|
||||
}
|
||||
139
static/js/button-generator/effects/border.js
Normal file
139
static/js/button-generator/effects/border.js
Normal file
|
|
@ -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());
|
||||
}
|
||||
93
static/js/button-generator/effects/glitch.js
Normal file
93
static/js/button-generator/effects/glitch.js
Normal file
|
|
@ -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());
|
||||
}
|
||||
170
static/js/button-generator/effects/hologram.js
Normal file
170
static/js/button-generator/effects/hologram.js
Normal file
|
|
@ -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());
|
||||
}
|
||||
68
static/js/button-generator/effects/noise.js
Normal file
68
static/js/button-generator/effects/noise.js
Normal file
|
|
@ -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());
|
||||
}
|
||||
62
static/js/button-generator/effects/pulse.js
Normal file
62
static/js/button-generator/effects/pulse.js
Normal file
|
|
@ -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());
|
||||
}
|
||||
100
static/js/button-generator/effects/rainbow-text.js
Normal file
100
static/js/button-generator/effects/rainbow-text.js
Normal file
|
|
@ -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));
|
||||
}
|
||||
85
static/js/button-generator/effects/rgb-split.js
Normal file
85
static/js/button-generator/effects/rgb-split.js
Normal file
|
|
@ -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());
|
||||
}
|
||||
72
static/js/button-generator/effects/rotate.js
Normal file
72
static/js/button-generator/effects/rotate.js
Normal file
|
|
@ -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());
|
||||
}
|
||||
79
static/js/button-generator/effects/scanline.js
Normal file
79
static/js/button-generator/effects/scanline.js
Normal file
|
|
@ -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());
|
||||
}
|
||||
57
static/js/button-generator/effects/shimmer.js
Normal file
57
static/js/button-generator/effects/shimmer.js
Normal file
|
|
@ -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());
|
||||
}
|
||||
144
static/js/button-generator/effects/spin-text.js
Normal file
144
static/js/button-generator/effects/spin-text.js
Normal file
|
|
@ -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));
|
||||
}
|
||||
209
static/js/button-generator/effects/spotlight.js
Normal file
209
static/js/button-generator/effects/spotlight.js
Normal file
|
|
@ -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
|
||||
*/
|
||||
232
static/js/button-generator/effects/text-standard.js
Normal file
232
static/js/button-generator/effects/text-standard.js
Normal file
|
|
@ -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));
|
||||
}
|
||||
167
static/js/button-generator/effects/wave-text.js
Normal file
167
static/js/button-generator/effects/wave-text.js
Normal file
|
|
@ -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));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue