diff --git a/frontend/src/components/Constructor/CanvasElement.tsx b/frontend/src/components/Constructor/CanvasElement.tsx index 41a9ffe..1b50c94 100644 --- a/frontend/src/components/Constructor/CanvasElement.tsx +++ b/frontend/src/components/Constructor/CanvasElement.tsx @@ -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 = ({ 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 = ({ }; } - // 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 = ({ } }; + // Check if appear animation uses transform (slide/scale need separate wrapper) + const needsAnimationWrapper = + effectProperties.appearAnimation && + effectProperties.appearAnimation !== 'fade'; + + // Render content + const content = ( + + ); + + // For slide/scale (uses transform), use separate wrapper to avoid conflict with positioning + if (needsAnimationWrapper) { + return ( +
+
+ {content} +
+
+ ); + } + + // Fade or no animation: apply animation to positioning div + const combinedStyle = effectProperties.appearAnimation + ? { ...positionStyle, ...animationStyle } + : positionStyle; + return (
- + {content}
); }; diff --git a/frontend/src/components/RuntimeElement.tsx b/frontend/src/components/RuntimeElement.tsx index 06aa9ca..ededabe 100644 --- a/frontend/src/components/RuntimeElement.tsx +++ b/frontend/src/components/RuntimeElement.tsx @@ -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 = ({ // 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 = ({ 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 = ( + + ); + + // For fade animation (opacity only), apply to positioning div + // For slide/scale (uses transform), use separate wrapper to avoid conflict + if (needsAnimationWrapper) { + return ( +
+
+ {content} +
+
+ ); } + // Fade or no animation: apply animation to positioning div + const combinedStyle = effectProperties.appearAnimation + ? { ...positionStyle, ...animationStyle } + : positionStyle; + return (
- + {content}
); }; diff --git a/frontend/src/css/main.css b/frontend/src/css/main.css index 0df2ecb..2f6302a 100644 --- a/frontend/src/css/main.css +++ b/frontend/src/css/main.css @@ -290,11 +290,9 @@ @keyframes element-fade-in { from { opacity: 0; - transform: translate3d(0, 0, 0); } to { opacity: 1; - transform: translate3d(0, 0, 0); } } diff --git a/frontend/src/hooks/useAppearAnimation.ts b/frontend/src/hooks/useAppearAnimation.ts new file mode 100644 index 0000000..e1ad744 --- /dev/null +++ b/frontend/src/hooks/useAppearAnimation.ts @@ -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; +} + +/** + * 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 ( + *
+ * {content} + *
+ * ); + */ +export function useAppearAnimation( + effects: Partial, + 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(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 = 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, + }; +}