added multiply-effects functionality

This commit is contained in:
Dmitri 2026-05-21 12:48:04 +02:00
parent b4a42c7e3e
commit f3cee3c393
4 changed files with 209 additions and 37 deletions

View File

@ -10,9 +10,9 @@
import React from 'react';
import UiElementRenderer from '../UiElements/UiElementRenderer';
import { useElementEffects } from '../../hooks/useElementEffects';
import { useAppearAnimation } from '../../hooks/useAppearAnimation';
import {
buildTransitionStyle,
buildAppearAnimationStyle,
hasAnyEffects,
type ElementEffectProperties,
} from '../../lib/elementEffects';
@ -82,6 +82,12 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
isEditMode ? {} : effectProperties,
);
// Use appear animation hook (removes animation after completion to unlock properties)
const { animationStyle, onAnimationEnd } = useAppearAnimation(
effectProperties,
element.id, // Reset animation when element changes
);
// Clamp position to canvas bounds (0-100%)
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
@ -114,14 +120,6 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
};
}
// Add appear animation (ALWAYS - for WYSIWYG)
if (effectProperties.appearAnimation) {
positionStyle = {
...positionStyle,
...buildAppearAnimationStyle(effectProperties),
};
}
// Handle keyboard interaction for accessibility
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
@ -130,29 +128,66 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
}
};
// Check if appear animation uses transform (slide/scale need separate wrapper)
const needsAnimationWrapper =
effectProperties.appearAnimation &&
effectProperties.appearAnimation !== 'fade';
// Render content
const content = (
<UiElementRenderer
element={element}
resolveUrl={resolveUrl}
isSelected={isSelected}
isEditMode={isEditMode}
onGalleryCardClick={onGalleryCardClick}
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}
/>
);
// For slide/scale (uses transform), use separate wrapper to avoid conflict with positioning
if (needsAnimationWrapper) {
return (
<div
role='button'
tabIndex={0}
data-constructor-element-id={element.id}
className='absolute cursor-pointer'
style={positionStyle}
onMouseDown={isEditMode ? onMouseDown : undefined}
onClick={onClick}
onKeyDown={handleKeyDown}
{...(!isEditMode ? eventHandlers : {})}
>
<div style={animationStyle} onAnimationEnd={onAnimationEnd}>
{content}
</div>
</div>
);
}
// Fade or no animation: apply animation to positioning div
const combinedStyle = effectProperties.appearAnimation
? { ...positionStyle, ...animationStyle }
: positionStyle;
return (
<div
role='button'
tabIndex={0}
data-constructor-element-id={element.id}
className='absolute cursor-pointer'
style={positionStyle}
style={combinedStyle}
onMouseDown={isEditMode ? onMouseDown : undefined}
onClick={onClick}
onKeyDown={handleKeyDown}
onAnimationEnd={effectProperties.appearAnimation ? onAnimationEnd : undefined}
{...(!isEditMode ? eventHandlers : {})}
>
<UiElementRenderer
element={element}
resolveUrl={resolveUrl}
isSelected={isSelected}
isEditMode={isEditMode}
onGalleryCardClick={onGalleryCardClick}
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}
/>
{content}
</div>
);
};

View File

@ -9,9 +9,9 @@
import React from 'react';
import UiElementRenderer from './UiElements/UiElementRenderer';
import { useElementEffects } from '../hooks/useElementEffects';
import { useAppearAnimation } from '../hooks/useAppearAnimation';
import {
buildTransitionStyle,
buildAppearAnimationStyle,
hasAnyEffects,
type ElementEffectProperties,
} from '../lib/elementEffects';
@ -75,6 +75,12 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
// Use effects hook for interactive states
const { effectStyle, eventHandlers } = useElementEffects(effectProperties);
// Use appear animation hook (removes animation after completion to unlock properties)
const { animationStyle, onAnimationEnd } = useAppearAnimation(
effectProperties,
element.id, // Reset animation when element changes
);
// Build base position style
let positionStyle: React.CSSProperties = {
left: `${xPercent}%`,
@ -99,28 +105,56 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
positionStyle = { ...positionStyle, ...transitionStyle };
}
// Add appear animation if configured
if (effectProperties.appearAnimation) {
const animationStyle = buildAppearAnimationStyle(effectProperties);
positionStyle = { ...positionStyle, ...animationStyle };
// Check if appear animation uses transform (slide/scale need separate wrapper)
const needsAnimationWrapper =
effectProperties.appearAnimation &&
effectProperties.appearAnimation !== 'fade';
// Render content (with or without animation wrapper)
const content = (
<UiElementRenderer
element={element}
resolveUrl={resolveUrl}
onGalleryCardClick={onGalleryCardClick}
letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}
/>
);
// For fade animation (opacity only), apply to positioning div
// For slide/scale (uses transform), use separate wrapper to avoid conflict
if (needsAnimationWrapper) {
return (
<div
className='absolute cursor-pointer'
style={positionStyle}
onClick={onClick}
tabIndex={0}
{...eventHandlers}
>
<div style={animationStyle} onAnimationEnd={onAnimationEnd}>
{content}
</div>
</div>
);
}
// Fade or no animation: apply animation to positioning div
const combinedStyle = effectProperties.appearAnimation
? { ...positionStyle, ...animationStyle }
: positionStyle;
return (
<div
className='absolute cursor-pointer'
style={positionStyle}
style={combinedStyle}
onClick={onClick}
tabIndex={0}
onAnimationEnd={effectProperties.appearAnimation ? onAnimationEnd : undefined}
{...eventHandlers}
>
<UiElementRenderer
element={element}
resolveUrl={resolveUrl}
onGalleryCardClick={onGalleryCardClick}
letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}
/>
{content}
</div>
);
};

View File

@ -290,11 +290,9 @@
@keyframes element-fade-in {
from {
opacity: 0;
transform: translate3d(0, 0, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}

View File

@ -0,0 +1,105 @@
/**
* useAppearAnimation Hook
*
* Manages appear animation lifecycle to prevent conflicts with interactive effects.
*
* Problem: CSS animations with `animation-fill-mode: forwards` lock animated
* properties (opacity, transform), preventing CSS transitions from working
* on hover/focus/active states.
*
* Solution: Track animation completion via `animationend` event and remove
* the animation style after it completes, unlocking properties for transitions.
*
* Cross-browser: Uses standard `animationend` event supported by all modern
* browsers (Chrome, Firefox, Safari, Edge, iOS Safari, Android Chrome).
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import type { CSSProperties, AnimationEventHandler } from 'react';
import {
buildAppearAnimationStyle,
type ElementEffectProperties,
} from '../lib/elementEffects';
interface UseAppearAnimationResult {
/** Animation style to apply (empty after animation completes) */
animationStyle: CSSProperties;
/** Handler to attach to element's onAnimationEnd */
onAnimationEnd: AnimationEventHandler<HTMLElement>;
}
/**
* Hook for managing appear animation with proper cleanup.
*
* @param effects - Element effect properties containing appear animation config
* @param key - Optional key to reset animation (e.g., page slug for re-triggering on navigation)
* @returns Object with animationStyle and onAnimationEnd handler
*
* @example
* const { animationStyle, onAnimationEnd } = useAppearAnimation(effectProperties);
* return (
* <div
* style={{ ...baseStyle, ...animationStyle }}
* onAnimationEnd={onAnimationEnd}
* >
* {content}
* </div>
* );
*/
export function useAppearAnimation(
effects: Partial<ElementEffectProperties>,
key?: string | number,
): UseAppearAnimationResult {
// Track whether animation has completed
const [hasAnimationEnded, setHasAnimationEnded] = useState(false);
// Track the animation name to identify our animation in the event
const animationNameRef = useRef<string | null>(null);
// Reset animation state when key changes (e.g., navigating to new page)
useEffect(() => {
setHasAnimationEnded(false);
}, [key]);
// Build animation style and capture animation name
const animationStyle = (() => {
// If no appear animation or animation already ended, return empty
if (!effects.appearAnimation || hasAnimationEnded) {
animationNameRef.current = null;
return {};
}
const style = buildAppearAnimationStyle(effects);
// Extract animation name from the style for event matching
if (style.animation) {
const match = String(style.animation).match(/^([\w-]+)/);
animationNameRef.current = match ? match[1] : null;
}
return style;
})();
// Handler for animationend event
const onAnimationEnd: AnimationEventHandler<HTMLElement> = useCallback(
(event) => {
// Only handle our animation (ignore child animations)
// Check if the animation name matches our appear animation
const targetAnimationName = animationNameRef.current;
if (
targetAnimationName &&
event.animationName === targetAnimationName &&
event.target === event.currentTarget
) {
setHasAnimationEnded(true);
}
},
[],
);
return {
animationStyle,
onAnimationEnd,
};
}