feat: Add festive visual effects (#2252)
<!-- CURSOR_SUMMARY --> > [!NOTE] > Introduces an effects system (fireworks, ghost, hearts, shamrocks, snow), a new `effect` action, and optional date-based loading effects, integrated into the card with docs, schema, editor, and tests. > > - **Effects System**: > - Add `EffectsController` and `advanced-camera-card-effects` host with lazy-loaded effect modules (`fireworks`, `ghost`, `hearts`, `shamrocks`, `snow`). > - New base effect component with fade-in/out; SCSS and z-index updates. > - **Actions**: > - New custom action `advanced_camera_card_action: effect` with `effect_action` (`start`|`stop`|`toggle`). > - Wire into `ActionFactory` and add `EffectAction` executor. > - **Card Integration**: > - Mount effects host in `advanced-camera-card` and expose `getEffectsControllerAPI()` via `CardController`. > - Loading component can trigger date-based effects (e.g., New Year fireworks) when enabled. > - **Configuration & Editor**: > - Add `performance.features.card_loading_effects` (default `true`) alongside `card_loading_indicator`. > - Update schemas, defaults, low-performance profile (disables effects), and editor toggles. > - **Docs & Examples**: > - Document `effect` action parameters and add menu example. > - Update performance docs with new option. > - **Localization & Tests**: > - Update i18n strings for new performance option. > - Add comprehensive tests for effects controller, action, factory, config defaults/profiles, and utilities. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit cffd4304fe3bd22340316f9cec53e341379b1756. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { CSSResultGroup, LitElement, PropertyValues, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import effectBaseStyle from '../../scss/effect-base.scss';
|
||||
import { forceReflow } from '../../utils/basic';
|
||||
|
||||
export abstract class BaseEffectComponent extends LitElement {
|
||||
@property({ type: Boolean })
|
||||
public fadeIn = true;
|
||||
|
||||
protected firstUpdated(): void {
|
||||
if (this.fadeIn) {
|
||||
this._startFadeIn();
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
// Skip if this is the initial property setting (handled by firstUpdated).
|
||||
if (changedProps.get('fadeIn') !== undefined) {
|
||||
if (!this.fadeIn) {
|
||||
this._setOpacity(1);
|
||||
} else {
|
||||
this._startFadeIn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public startFadeOut(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const handler = (ev: TransitionEvent) => {
|
||||
if (ev.propertyName === 'opacity') {
|
||||
this.removeEventListener('transitionend', handler);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
this.addEventListener('transitionend', handler);
|
||||
this._setOpacity(0);
|
||||
});
|
||||
}
|
||||
|
||||
private _startFadeIn(): void {
|
||||
this._setOpacity(0);
|
||||
forceReflow(this);
|
||||
this._setOpacity(1);
|
||||
}
|
||||
|
||||
private _setOpacity(opacity: number): void {
|
||||
this.style.opacity = `${opacity}`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(effectBaseStyle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { LitElement, unsafeCSS } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { EffectsController } from '../../components-lib/effects/effects-controller';
|
||||
import { EffectOptions } from '../../components-lib/effects/types';
|
||||
import effectsStyle from '../../scss/effects.scss';
|
||||
import { EffectName, EffectsControllerAPI } from '../../types';
|
||||
|
||||
@customElement('advanced-camera-card-effects')
|
||||
export class AdvancedCameraCardEffects
|
||||
extends LitElement
|
||||
implements EffectsControllerAPI
|
||||
{
|
||||
protected _controller = new EffectsController();
|
||||
|
||||
public async startEffect(effect: EffectName, options?: EffectOptions): Promise<void> {
|
||||
await this._controller.startEffect(effect, options);
|
||||
}
|
||||
|
||||
public stopEffect(effect: EffectName): void {
|
||||
this._controller.stopEffect(effect);
|
||||
}
|
||||
|
||||
public async toggleEffect(effect: EffectName, options?: EffectOptions): Promise<void> {
|
||||
await this._controller.toggleEffect(effect, options);
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this._controller.setContainer(this.renderRoot);
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return unsafeCSS(effectsStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-effects': AdvancedCameraCardEffects;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import fireworkBurstStyle from '../../scss/firework-burst.scss';
|
||||
import './firework-particle';
|
||||
|
||||
const BASE_PARTICLE_COUNT = 36;
|
||||
|
||||
const FIREWORK_COLORS = [
|
||||
'#ff2222', // Bright Red
|
||||
'#ffdd00', // Bright Gold
|
||||
'#22ff22', // Bright Green
|
||||
'#22aaff', // Bright Blue
|
||||
'#ff22ff', // Bright Magenta
|
||||
'#ffffff', // White
|
||||
'#ffaa00', // Bright Orange
|
||||
'#22ffff', // Bright Cyan
|
||||
'#ff66aa', // Pink
|
||||
'#aaaaff', // Lavender
|
||||
];
|
||||
|
||||
type BurstType = 'standard' | 'ring' | 'palm';
|
||||
|
||||
interface ParticleConfig {
|
||||
id: number;
|
||||
angle: number;
|
||||
distance: number;
|
||||
color: string;
|
||||
size: string;
|
||||
duration: string;
|
||||
delay: string;
|
||||
gravity: number;
|
||||
}
|
||||
|
||||
@customElement('advanced-camera-card-firework-burst')
|
||||
export class AdvancedCameraCardFireworkBurst extends LitElement {
|
||||
@property({ type: String })
|
||||
public posX = '50%';
|
||||
|
||||
@property({ type: String })
|
||||
public posY = '50%';
|
||||
|
||||
@property({ type: String })
|
||||
public delay = '0s';
|
||||
|
||||
@property({ type: Number })
|
||||
public scale = 1.0;
|
||||
|
||||
@property({ type: String })
|
||||
public burstType: BurstType = 'standard';
|
||||
|
||||
private _particles: ParticleConfig[] = [];
|
||||
private _color: string = '';
|
||||
private _initialized = false;
|
||||
|
||||
private _initializeParticles(): void {
|
||||
if (this._initialized) {
|
||||
return;
|
||||
}
|
||||
this._initialized = true;
|
||||
|
||||
this._color = FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)];
|
||||
|
||||
switch (this.burstType) {
|
||||
case 'ring':
|
||||
this._initializeRingParticles();
|
||||
break;
|
||||
case 'palm':
|
||||
this._initializePalmParticles();
|
||||
break;
|
||||
default:
|
||||
this._initializeStandardParticles();
|
||||
}
|
||||
}
|
||||
|
||||
private _initializeStandardParticles(): void {
|
||||
const particleCount = Math.round(BASE_PARTICLE_COUNT * this.scale);
|
||||
|
||||
this._particles = Array.from({ length: particleCount }, (_, i) => {
|
||||
const baseAngle = (360 / particleCount) * i;
|
||||
const angleVariation = (Math.random() - 0.5) * 20;
|
||||
|
||||
return {
|
||||
id: i,
|
||||
angle: baseAngle + angleVariation,
|
||||
distance: (Math.random() * 80 + 100) * this.scale,
|
||||
color: this._color,
|
||||
size: `${(Math.random() * 12 + 18) * this.scale}px`,
|
||||
duration: `${Math.random() * 0.5 + 1.8}s`,
|
||||
delay: `${Math.random() * 0.08}s`,
|
||||
gravity: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private _initializeRingParticles(): void {
|
||||
const particleCount = Math.round(BASE_PARTICLE_COUNT * this.scale * 1.5);
|
||||
const ringDistance = (120 + Math.random() * 40) * this.scale;
|
||||
|
||||
this._particles = Array.from({ length: particleCount }, (_, i) => {
|
||||
const baseAngle = (360 / particleCount) * i;
|
||||
|
||||
return {
|
||||
id: i,
|
||||
angle: baseAngle,
|
||||
distance: ringDistance + (Math.random() - 0.5) * 10,
|
||||
color: this._color,
|
||||
size: `${(Math.random() * 8 + 14) * this.scale}px`,
|
||||
duration: `${Math.random() * 0.3 + 1.5}s`,
|
||||
delay: `${Math.random() * 0.02}s`,
|
||||
gravity: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private _initializePalmParticles(): void {
|
||||
const particleCount = Math.round(18 * this.scale);
|
||||
|
||||
this._particles = Array.from({ length: particleCount }, (_, i) => {
|
||||
const baseAngle = (360 / particleCount) * i;
|
||||
const angleVariation = (Math.random() - 0.5) * 15;
|
||||
|
||||
return {
|
||||
id: i,
|
||||
angle: baseAngle + angleVariation,
|
||||
distance: (Math.random() * 60 + 140) * this.scale,
|
||||
color: this._color,
|
||||
size: `${(Math.random() * 10 + 20) * this.scale}px`,
|
||||
duration: `${Math.random() * 0.8 + 2.2}s`,
|
||||
delay: `${Math.random() * 0.05}s`,
|
||||
gravity: 80 + Math.random() * 60,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
this._initializeParticles();
|
||||
return html`
|
||||
${repeat(
|
||||
this._particles,
|
||||
(p) => p.id,
|
||||
(p) => html`
|
||||
<advanced-camera-card-firework-particle
|
||||
.angle=${p.angle}
|
||||
.distance=${p.distance}
|
||||
.color=${p.color}
|
||||
.size=${p.size}
|
||||
.duration=${p.duration}
|
||||
.delay=${p.delay}
|
||||
.gravity=${p.gravity}
|
||||
></advanced-camera-card-firework-particle>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this.style.setProperty('--pos-x', this.posX);
|
||||
this.style.setProperty('--pos-y', this.posY);
|
||||
this.style.setProperty('--delay', this.delay);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(fireworkBurstStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-firework-burst': AdvancedCameraCardFireworkBurst;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import fireworkParticleStyle from '../../scss/firework-particle.scss';
|
||||
|
||||
@customElement('advanced-camera-card-firework-particle')
|
||||
export class AdvancedCameraCardFireworkParticle extends LitElement {
|
||||
@property({ type: Number })
|
||||
public angle = 0;
|
||||
|
||||
@property({ type: Number })
|
||||
public distance = 100;
|
||||
|
||||
@property({ type: String })
|
||||
public color = '#ffcc00';
|
||||
|
||||
@property({ type: String })
|
||||
public size = '4px';
|
||||
|
||||
@property({ type: String })
|
||||
public duration = '1.5s';
|
||||
|
||||
@property({ type: String })
|
||||
public delay = '0s';
|
||||
|
||||
@property({ type: Number })
|
||||
public gravity = 0;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<span class="spark">✦</span>`;
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
const radians = (this.angle * Math.PI) / 180;
|
||||
const endX = Math.cos(radians) * this.distance;
|
||||
const endY = Math.sin(radians) * this.distance + this.gravity;
|
||||
|
||||
this.style.setProperty('--end-x', `${endX}px`);
|
||||
this.style.setProperty('--end-y', `${endY}px`);
|
||||
this.style.setProperty('--color', this.color);
|
||||
this.style.setProperty('--size', this.size);
|
||||
this.style.setProperty('--duration', this.duration);
|
||||
this.style.setProperty('--delay', this.delay);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(fireworkParticleStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-firework-particle': AdvancedCameraCardFireworkParticle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { html, TemplateResult } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { Timer } from '../../utils/timer';
|
||||
import { BaseEffectComponent } from './base';
|
||||
import './firework-burst';
|
||||
|
||||
const INITIAL_BURST_COUNT = 3;
|
||||
const MIN_BURST_COUNT = 2;
|
||||
const MAX_BURST_COUNT = 5;
|
||||
const BURST_CYCLE_SECONDS = 2.0;
|
||||
const MAX_BURST_DELAY_SECONDS = 1.2;
|
||||
|
||||
type BurstType = 'standard' | 'ring' | 'palm';
|
||||
|
||||
interface BurstConfig {
|
||||
id: number;
|
||||
posX: string;
|
||||
posY: string;
|
||||
delay: string;
|
||||
scale: number;
|
||||
burstType: BurstType;
|
||||
}
|
||||
|
||||
@customElement('advanced-camera-card-effect-fireworks')
|
||||
export class AdvancedCameraCardEffectFireworks extends BaseEffectComponent {
|
||||
@state()
|
||||
private _bursts: BurstConfig[] = [];
|
||||
|
||||
private _burstIdCounter = 0;
|
||||
private _timer = new Timer();
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._startFireworks();
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._stopFireworks();
|
||||
}
|
||||
|
||||
private _startFireworks(): void {
|
||||
this._createBursts(INITIAL_BURST_COUNT);
|
||||
|
||||
this._timer.startRepeated(BURST_CYCLE_SECONDS, () => {
|
||||
const count =
|
||||
Math.floor(Math.random() * (MAX_BURST_COUNT - MIN_BURST_COUNT + 1)) +
|
||||
MIN_BURST_COUNT;
|
||||
this._createBursts(count);
|
||||
});
|
||||
}
|
||||
|
||||
private _stopFireworks(): void {
|
||||
this._timer.stop();
|
||||
}
|
||||
|
||||
private _createBursts(count: number): void {
|
||||
const newBursts: BurstConfig[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
newBursts.push(this._createBurstConfig());
|
||||
}
|
||||
this._bursts = newBursts;
|
||||
}
|
||||
|
||||
private _createBurstConfig(): BurstConfig {
|
||||
// 20% chance of a big burst (scale 1.5-2.0), otherwise normal (scale 0.8-1.2)
|
||||
const isBig = Math.random() < 0.2;
|
||||
const scale = isBig ? Math.random() * 0.5 + 1.5 : Math.random() * 0.4 + 0.8;
|
||||
|
||||
// Burst type distribution: 60% standard, 25% ring, 15% palm
|
||||
const typeRoll = Math.random();
|
||||
let burstType: BurstType;
|
||||
if (typeRoll < 0.6) {
|
||||
burstType = 'standard';
|
||||
} else if (typeRoll < 0.85) {
|
||||
burstType = 'ring';
|
||||
} else {
|
||||
burstType = 'palm';
|
||||
}
|
||||
|
||||
return {
|
||||
id: this._burstIdCounter++,
|
||||
posX: `${Math.random() * 80 + 10}%`,
|
||||
posY: `${Math.random() * 60 + 20}%`,
|
||||
delay: `${Math.random() * MAX_BURST_DELAY_SECONDS}s`,
|
||||
scale,
|
||||
burstType,
|
||||
};
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${repeat(
|
||||
this._bursts,
|
||||
(burst) => burst.id,
|
||||
(burst) => html`
|
||||
<advanced-camera-card-firework-burst
|
||||
.posX=${burst.posX}
|
||||
.posY=${burst.posY}
|
||||
.delay=${burst.delay}
|
||||
.scale=${burst.scale}
|
||||
.burstType=${burst.burstType}
|
||||
></advanced-camera-card-firework-burst>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-effect-fireworks': AdvancedCameraCardEffectFireworks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { BaseEffectComponent } from './base';
|
||||
import ghostStyle from '../../scss/ghost.scss';
|
||||
|
||||
@customElement('advanced-camera-card-effect-ghost')
|
||||
export class AdvancedCameraCardEffectGhost extends BaseEffectComponent {
|
||||
protected render(): TemplateResult {
|
||||
return html`<span class="ghost">👻</span>`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(ghostStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-effect-ghost': AdvancedCameraCardEffectGhost;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import heartStyle from '../../scss/heart.scss';
|
||||
|
||||
@customElement('advanced-camera-card-heart')
|
||||
export class AdvancedCameraCardHeart extends LitElement {
|
||||
@property({ type: String })
|
||||
public char = '❤️';
|
||||
|
||||
@property({ type: String })
|
||||
public size = '1em';
|
||||
|
||||
@property({ type: Number })
|
||||
public hue = 340;
|
||||
|
||||
@property({ type: Number })
|
||||
public saturation = 80;
|
||||
|
||||
@property({ type: Number })
|
||||
public lightness = 55;
|
||||
|
||||
@property({ type: Number })
|
||||
public maxOpacity = 1;
|
||||
|
||||
@property({ type: String })
|
||||
public pulseDuration = '3s';
|
||||
|
||||
@property({ type: String })
|
||||
public pulseDelay = '0s';
|
||||
|
||||
@property({ type: String })
|
||||
public startX = '0%';
|
||||
|
||||
@property({ type: String })
|
||||
public startY = '0%';
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('animationiteration', this._handleAnimationIteration);
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener('animationiteration', this._handleAnimationIteration);
|
||||
}
|
||||
|
||||
private _handleAnimationIteration = (ev: AnimationEvent): void => {
|
||||
if (ev.animationName === 'pulse') {
|
||||
this.startX = `${Math.random() * 100}%`;
|
||||
this.startY = `${Math.random() * 100}%`;
|
||||
}
|
||||
};
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`${this.char}`;
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this.style.setProperty('--size', this.size);
|
||||
this.style.setProperty('--hue', `${this.hue}`);
|
||||
this.style.setProperty('--saturation', `${this.saturation}`);
|
||||
this.style.setProperty('--lightness', `${this.lightness}`);
|
||||
this.style.setProperty('--max-opacity', `${this.maxOpacity}`);
|
||||
this.style.setProperty('--pulse-duration', this.pulseDuration);
|
||||
this.style.setProperty('--pulse-delay', this.pulseDelay);
|
||||
this.style.setProperty('--start-x', this.startX);
|
||||
this.style.setProperty('--start-y', this.startY);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(heartStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-heart': AdvancedCameraCardHeart;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { html, TemplateResult } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { BaseEffectComponent } from './base';
|
||||
import './heart';
|
||||
|
||||
const HEART_CHARS = ['❤️', '💖', '💕', '💗', '💓'];
|
||||
const HEART_MAX_COUNT = 50;
|
||||
|
||||
interface HeartConfig {
|
||||
id: number;
|
||||
char: string;
|
||||
|
||||
size: string;
|
||||
|
||||
hue: number;
|
||||
saturation: number;
|
||||
lightness: number;
|
||||
|
||||
maxOpacity: number;
|
||||
|
||||
pulseDuration: string;
|
||||
pulseDelay: string;
|
||||
|
||||
startX: string;
|
||||
startY: string;
|
||||
}
|
||||
|
||||
@customElement('advanced-camera-card-effect-hearts')
|
||||
export class AdvancedCameraCardEffectHearts extends BaseEffectComponent {
|
||||
private _hearts: HeartConfig[];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._hearts = Array.from({ length: HEART_MAX_COUNT }, (_, i) => {
|
||||
const duration = Math.random() * 4 + 4;
|
||||
const delay = -Math.random() * duration * 0.8;
|
||||
|
||||
return {
|
||||
id: i,
|
||||
char: HEART_CHARS[Math.floor(Math.random() * HEART_CHARS.length)],
|
||||
size: `${Math.random() * 1.5 + 0.5}em`,
|
||||
hue: Math.random() * 40 + 320,
|
||||
saturation: Math.random() * 40 + 60,
|
||||
lightness: Math.random() * 20 + 45,
|
||||
maxOpacity: Math.random() * 0.5 + 0.2,
|
||||
pulseDuration: `${duration}s`,
|
||||
pulseDelay: `${delay}s`,
|
||||
startX: `${Math.random() * 100}%`,
|
||||
startY: `${Math.random() * 100}%`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${repeat(
|
||||
this._hearts,
|
||||
(heart) => heart.id,
|
||||
(heart) => html`
|
||||
<advanced-camera-card-heart
|
||||
.char=${heart.char}
|
||||
.size=${heart.size}
|
||||
.hue=${heart.hue}
|
||||
.saturation=${heart.saturation}
|
||||
.lightness=${heart.lightness}
|
||||
.maxOpacity=${heart.maxOpacity}
|
||||
.pulseDuration=${heart.pulseDuration}
|
||||
.pulseDelay=${heart.pulseDelay}
|
||||
.startX=${heart.startX}
|
||||
.startY=${heart.startY}
|
||||
></advanced-camera-card-heart>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-effect-hearts': AdvancedCameraCardEffectHearts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import shamrockStyle from '../../scss/shamrock.scss';
|
||||
|
||||
@customElement('advanced-camera-card-shamrock')
|
||||
export class AdvancedCameraCardShamrock extends LitElement {
|
||||
@property({ type: String })
|
||||
public char = '☘️';
|
||||
|
||||
@property({ type: String })
|
||||
public size = '1em';
|
||||
|
||||
@property({ type: Number })
|
||||
public maxOpacity = 1;
|
||||
|
||||
@property({ type: String })
|
||||
public pulseDuration = '3s';
|
||||
|
||||
@property({ type: String })
|
||||
public pulseDelay = '0s';
|
||||
|
||||
@property({ type: String })
|
||||
public startX = '0%';
|
||||
|
||||
@property({ type: String })
|
||||
public startY = '0%';
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('animationiteration', this._handleAnimationIteration);
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener('animationiteration', this._handleAnimationIteration);
|
||||
}
|
||||
|
||||
private _handleAnimationIteration = (ev: AnimationEvent): void => {
|
||||
if (ev.animationName === 'shamrock-pulse') {
|
||||
this.startX = `${Math.random() * 100}%`;
|
||||
this.startY = `${Math.random() * 100}%`;
|
||||
}
|
||||
};
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`${this.char}`;
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this.style.setProperty('--size', this.size);
|
||||
this.style.setProperty('--max-opacity', `${this.maxOpacity}`);
|
||||
this.style.setProperty('--pulse-duration', this.pulseDuration);
|
||||
this.style.setProperty('--pulse-delay', this.pulseDelay);
|
||||
this.style.setProperty('--start-x', this.startX);
|
||||
this.style.setProperty('--start-y', this.startY);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(shamrockStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-shamrock': AdvancedCameraCardShamrock;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { html, TemplateResult } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { BaseEffectComponent } from './base';
|
||||
import './shamrock';
|
||||
|
||||
const SHAMROCK_COUNT = 10;
|
||||
|
||||
interface ShamrockConfig {
|
||||
id: number;
|
||||
|
||||
size: string;
|
||||
|
||||
maxOpacity: number;
|
||||
|
||||
pulseDuration: string;
|
||||
pulseDelay: string;
|
||||
|
||||
startX: string;
|
||||
startY: string;
|
||||
}
|
||||
|
||||
@customElement('advanced-camera-card-effect-shamrocks')
|
||||
export class AdvancedCameraCardEffectShamrocks extends BaseEffectComponent {
|
||||
private _shamrocks: ShamrockConfig[];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._shamrocks = Array.from({ length: SHAMROCK_COUNT }, (_, i) => {
|
||||
const duration = Math.random() * 3 + 5;
|
||||
const delay = -Math.random() * duration * 0.9;
|
||||
|
||||
return {
|
||||
id: i,
|
||||
size: `${Math.random() * 12 + 8}em`,
|
||||
maxOpacity: Math.random() * 0.3 + 0.5,
|
||||
pulseDuration: `${duration}s`,
|
||||
pulseDelay: `${delay}s`,
|
||||
startX: `${Math.random() * 80 + 10}%`,
|
||||
startY: `${Math.random() * 80 + 10}%`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${repeat(
|
||||
this._shamrocks,
|
||||
(shamrock) => shamrock.id,
|
||||
(shamrock) => html`
|
||||
<advanced-camera-card-shamrock
|
||||
.size=${shamrock.size}
|
||||
.maxOpacity=${shamrock.maxOpacity}
|
||||
.pulseDuration=${shamrock.pulseDuration}
|
||||
.pulseDelay=${shamrock.pulseDelay}
|
||||
.startX=${shamrock.startX}
|
||||
.startY=${shamrock.startY}
|
||||
></advanced-camera-card-shamrock>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-effect-shamrocks': AdvancedCameraCardEffectShamrocks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { html, TemplateResult } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { BaseEffectComponent } from './base';
|
||||
import './snowflake';
|
||||
|
||||
const SNOWFLAKE_CHARS = ['❄', '❅', '❆'];
|
||||
const MAX_SNOWFLAKES = 50;
|
||||
|
||||
interface SnowflakeConfig {
|
||||
id: number;
|
||||
char: string;
|
||||
size: string;
|
||||
maxOpacity: number;
|
||||
fallDuration: string;
|
||||
fallDelay: string;
|
||||
startX: string;
|
||||
endX: string;
|
||||
}
|
||||
|
||||
@customElement('advanced-camera-card-effect-snow')
|
||||
export class AdvancedCameraCardEffectSnow extends BaseEffectComponent {
|
||||
private _snowflakes: SnowflakeConfig[];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._snowflakes = Array.from({ length: MAX_SNOWFLAKES }, (_, i) => {
|
||||
const duration = Math.random() * 10 + 10;
|
||||
const delay = -Math.random() * duration * 0.8;
|
||||
return {
|
||||
id: i,
|
||||
char: SNOWFLAKE_CHARS[Math.floor(Math.random() * SNOWFLAKE_CHARS.length)],
|
||||
size: `${Math.random() * 1.5 + 0.5}em`,
|
||||
maxOpacity: Math.random() * 0.5 + 0.5,
|
||||
fallDuration: `${duration}s`,
|
||||
fallDelay: `${delay}s`,
|
||||
startX: `${Math.random() * 100}%`,
|
||||
endX: `${Math.random() * 100}%`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${repeat(
|
||||
this._snowflakes,
|
||||
(snowflake) => snowflake.id,
|
||||
(snowflake) => html`
|
||||
<advanced-camera-card-snowflake
|
||||
.char=${snowflake.char}
|
||||
.size=${snowflake.size}
|
||||
.maxOpacity=${snowflake.maxOpacity}
|
||||
.fallDuration=${snowflake.fallDuration}
|
||||
.fallDelay=${snowflake.fallDelay}
|
||||
.startX=${snowflake.startX}
|
||||
.endX=${snowflake.endX}
|
||||
></advanced-camera-card-snowflake>
|
||||
`,
|
||||
)}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-effect-snow': AdvancedCameraCardEffectSnow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import snowflakeStyle from '../../scss/snowflake.scss';
|
||||
|
||||
@customElement('advanced-camera-card-snowflake')
|
||||
export class AdvancedCameraCardSnowflake extends LitElement {
|
||||
@property({ type: String })
|
||||
public char = '❄';
|
||||
|
||||
@property({ type: String })
|
||||
public size = '1em';
|
||||
|
||||
@property({ type: Number })
|
||||
public maxOpacity = 1;
|
||||
|
||||
@property({ type: String })
|
||||
public fallDuration = '10s';
|
||||
|
||||
@property({ type: String })
|
||||
public fallDelay = '0s';
|
||||
|
||||
@property({ type: String })
|
||||
public startX = '0%';
|
||||
|
||||
@property({ type: String })
|
||||
public endX = '0%';
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`${this.char}`;
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
this.style.setProperty('--max-opacity', `${this.maxOpacity}`);
|
||||
this.style.setProperty('--fall-duration', this.fallDuration);
|
||||
this.style.setProperty('--fall-delay', this.fallDelay);
|
||||
this.style.setProperty('--start-x', this.startX);
|
||||
this.style.setProperty('--end-x', this.endX);
|
||||
this.style.fontSize = this.size;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(snowflakeStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-snowflake': AdvancedCameraCardSnowflake;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,76 @@
|
||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import loadingStyle from '../scss/loading.scss';
|
||||
import { EffectName, EffectsControllerAPI } from '../types';
|
||||
import { getReleaseVersion } from '../utils/diagnostics';
|
||||
import './icon';
|
||||
|
||||
// Map of "MM-DD" to effect name for special dates.
|
||||
const DATE_EFFECTS: Record<string, EffectName> = {
|
||||
// New Year's Day
|
||||
'01-01': 'fireworks',
|
||||
|
||||
// Valentine's Day
|
||||
'02-14': 'hearts',
|
||||
|
||||
// St. Patrick's Day
|
||||
'03-17': 'shamrocks',
|
||||
|
||||
// Halloween
|
||||
'10-31': 'ghost',
|
||||
|
||||
// Christmas
|
||||
'12-25': 'snow',
|
||||
};
|
||||
|
||||
const getDateEffect = (): EffectName | null => {
|
||||
const now = new Date();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
return DATE_EFFECTS[`${month}-${day}`] ?? null;
|
||||
};
|
||||
|
||||
@customElement('advanced-camera-card-loading')
|
||||
export class AdvancedCameraCardLoading extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public effectsControllerAPI?: EffectsControllerAPI | null;
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public loaded = false;
|
||||
|
||||
private _effectName: EffectName | null = null;
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._stopEffect();
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
const effect = getDateEffect();
|
||||
if (!effect) {
|
||||
this._stopEffect();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.loaded) {
|
||||
this._startEffect(effect);
|
||||
} else {
|
||||
this._stopEffect();
|
||||
}
|
||||
}
|
||||
|
||||
private _startEffect(effect: EffectName): void {
|
||||
this.effectsControllerAPI?.startEffect(effect, { fadeIn: false });
|
||||
this._effectName = effect;
|
||||
}
|
||||
|
||||
private _stopEffect(): void {
|
||||
if (this._effectName) {
|
||||
this.effectsControllerAPI?.stopEffect(this._effectName);
|
||||
}
|
||||
this._effectName = null;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<advanced-camera-card-icon
|
||||
.icon=${{ icon: 'iris' }}
|
||||
|
||||
Reference in New Issue
Block a user