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 React from 'react';
import UiElementRenderer from '../UiElements/UiElementRenderer'; import UiElementRenderer from '../UiElements/UiElementRenderer';
import { useElementEffects } from '../../hooks/useElementEffects'; import { useElementEffects } from '../../hooks/useElementEffects';
import { useAppearAnimation } from '../../hooks/useAppearAnimation';
import { import {
buildTransitionStyle, buildTransitionStyle,
buildAppearAnimationStyle,
hasAnyEffects, hasAnyEffects,
type ElementEffectProperties, type ElementEffectProperties,
} from '../../lib/elementEffects'; } from '../../lib/elementEffects';
@ -82,6 +82,12 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
isEditMode ? {} : effectProperties, 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%) // Clamp position to canvas bounds (0-100%)
const clamp = (value: number, min: number, max: number) => const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max); 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 // Handle keyboard interaction for accessibility
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') { 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 ( return (
<div <div
role='button' role='button'
tabIndex={0} tabIndex={0}
data-constructor-element-id={element.id} data-constructor-element-id={element.id}
className='absolute cursor-pointer' className='absolute cursor-pointer'
style={positionStyle} style={combinedStyle}
onMouseDown={isEditMode ? onMouseDown : undefined} onMouseDown={isEditMode ? onMouseDown : undefined}
onClick={onClick} onClick={onClick}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onAnimationEnd={effectProperties.appearAnimation ? onAnimationEnd : undefined}
{...(!isEditMode ? eventHandlers : {})} {...(!isEditMode ? eventHandlers : {})}
> >
<UiElementRenderer {content}
element={element}
resolveUrl={resolveUrl}
isSelected={isSelected}
isEditMode={isEditMode}
onGalleryCardClick={onGalleryCardClick}
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}
/>
</div> </div>
); );
}; };

View File

@ -9,9 +9,9 @@
import React from 'react'; import React from 'react';
import UiElementRenderer from './UiElements/UiElementRenderer'; import UiElementRenderer from './UiElements/UiElementRenderer';
import { useElementEffects } from '../hooks/useElementEffects'; import { useElementEffects } from '../hooks/useElementEffects';
import { useAppearAnimation } from '../hooks/useAppearAnimation';
import { import {
buildTransitionStyle, buildTransitionStyle,
buildAppearAnimationStyle,
hasAnyEffects, hasAnyEffects,
type ElementEffectProperties, type ElementEffectProperties,
} from '../lib/elementEffects'; } from '../lib/elementEffects';
@ -75,6 +75,12 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
// Use effects hook for interactive states // Use effects hook for interactive states
const { effectStyle, eventHandlers } = useElementEffects(effectProperties); 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 // Build base position style
let positionStyle: React.CSSProperties = { let positionStyle: React.CSSProperties = {
left: `${xPercent}%`, left: `${xPercent}%`,
@ -99,28 +105,56 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
positionStyle = { ...positionStyle, ...transitionStyle }; positionStyle = { ...positionStyle, ...transitionStyle };
} }
// Add appear animation if configured // Check if appear animation uses transform (slide/scale need separate wrapper)
if (effectProperties.appearAnimation) { const needsAnimationWrapper =
const animationStyle = buildAppearAnimationStyle(effectProperties); effectProperties.appearAnimation &&
positionStyle = { ...positionStyle, ...animationStyle }; 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 ( return (
<div <div
className='absolute cursor-pointer' className='absolute cursor-pointer'
style={positionStyle} style={combinedStyle}
onClick={onClick} onClick={onClick}
tabIndex={0} tabIndex={0}
onAnimationEnd={effectProperties.appearAnimation ? onAnimationEnd : undefined}
{...eventHandlers} {...eventHandlers}
> >
<UiElementRenderer {content}
element={element}
resolveUrl={resolveUrl}
onGalleryCardClick={onGalleryCardClick}
letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}
/>
</div> </div>
); );
}; };

View File

@ -290,11 +290,9 @@
@keyframes element-fade-in { @keyframes element-fade-in {
from { from {
opacity: 0; opacity: 0;
transform: translate3d(0, 0, 0);
} }
to { to {
opacity: 1; 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,
};
}