ritual.sh/static/js/button-generator/effects/wave-text.js
2026-01-13 12:34:41 +00:00

189 lines
6.5 KiB
JavaScript

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));
}