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'; // Check if ticker is active - if so, ticker handles rendering const tickerActive = controlValues[`animate-text-ticker${suffix}`]; if (tickerActive) { return; } // Check flash visibility - if flash is active and text is invisible, don't render const flashActive = controlValues[`animate-text-flash${suffix}`]; if (flashActive && renderData[`textFlashVisible${suffix}`] === false) { return; } // Get text configuration const text = controlValues[`button-text${suffix}`] || ''; if (!text || text.trim() === '') return; 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); // 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 < 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 / chars.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)); }