diff --git a/assets/config/animations.json b/assets/config/animations.json new file mode 100644 index 0000000..317e7e3 --- /dev/null +++ b/assets/config/animations.json @@ -0,0 +1,52 @@ +{ + "defaults": { + "duration": 1, + "ease": "power2.out", + "distance": 24, + "stagger": 0.2 + }, + "css": { + "fade": "opacity: 0;", + "fade-up": "opacity: 0;", + "fade-down": "opacity: 0;", + "fade-left": "opacity: 0;", + "fade-right": "opacity: 0;", + "zoom-in": "opacity: 0; transform: scale(0.8);" + }, + "types": { + "fade": { + "mappings": { + "[class*=\"is-style-ext-preset--image--natural-1--image-\"]": "fade", + "[class*=\"is-style-ext-preset--group--natural-1--item-card-1--\"]": "fade", + "[class*=\"is-style-ext-preset--button--natural-1--button-\"]": "fade", + "[class*=\"is-style-ext-preset--group--natural-1--header-1\"]": "fade", + "[class*=\"is-style-ext-preset--group--natural-1--footer-1\"]": "fade", + "[class*=\"is-style-ext-preset--media-text--natural-1\"]": "fade", + "[class*=\"is-style-ext-preset--cover--natural-1--cover-overlay-1\"]": "fade" + + } + }, + "fade-up": { + "mappings": { + "[class*=\"is-style-ext-preset--image--natural-1--image-\"]": "fade-up", + "[class*=\"is-style-ext-preset--group--natural-1--item-card-1--\"]": "fade-up", + "[class*=\"is-style-ext-preset--button--natural-1--button-\"]": "fade-up", + "[class*=\"is-style-ext-preset--group--natural-1--header-1\"]": "fade-up", + "[class*=\"is-style-ext-preset--group--natural-1--footer-1\"]": "fade-up", + "[class*=\"is-style-ext-preset--media-text--natural-1\"]": "fade-up", + "[class*=\"is-style-ext-preset--cover--natural-1--cover-overlay-1\"]": "fade-up" + } + }, + "zoom-in": { + "mappings": { + "[class*=\"is-style-ext-preset--image--natural-1--image-\"]": "zoom-in", + "[class*=\"is-style-ext-preset--group--natural-1--item-card-1--\"]": "zoom-in", + "[class*=\"is-style-ext-preset--button--natural-1--button-\"]": "zoom-in", + "[class*=\"is-style-ext-preset--group--natural-1--header-1\"]": "fade", + "[class*=\"is-style-ext-preset--group--natural-1--footer-1\"]": "fade", + "[class*=\"is-style-ext-preset--media-text--natural-1\"]": "zoom-in", + "[class*=\"is-style-ext-preset--cover--natural-1--cover-overlay-1\"]": "fade" + } + } + } +} \ No newline at end of file diff --git a/assets/js/animations.js b/assets/js/animations.js new file mode 100644 index 0000000..d70dfe4 --- /dev/null +++ b/assets/js/animations.js @@ -0,0 +1,164 @@ +(function () { + 'use strict'; + + function normalizeSelector(selector) { + if (!selector || typeof selector !== 'string') return null; + const trimmed = selector.trim(); + if (!trimmed) return null; + + // If the DB stores plain class names like "is-style-fade-up", + // treat them as classes. If user stores ".foo" or "#bar", keep as-is. + if (trimmed.startsWith('.') || trimmed.startsWith('#') || trimmed.startsWith('[')) { + return trimmed; + } + + return `.${trimmed}`; + } + + function getConfig() { + const cfg = window.ExtendableAnimations || {}; + const defaults = cfg.defaults || {}; + + return { + map: cfg.map && typeof cfg.map === 'object' ? cfg.map : {}, + defaults: { + duration: Number.isFinite(defaults.duration) ? defaults.duration : 0.7, + ease: typeof defaults.ease === 'string' ? defaults.ease : 'power2.out', + distance: Number.isFinite(defaults.distance) ? defaults.distance : 24, + }, + }; + } + + function buildAnimationProps(animationKey, defaults) { + const distance = defaults.distance; + const delay = defaults.delay || 0; + + // Base fade-in + const base = { + from: { opacity: 0 }, + to: { opacity: 1, duration: defaults.duration, ease: defaults.ease, delay: delay }, + }; + + switch ((animationKey || '').toLowerCase()) { + case 'fade-up': + return { + from: { opacity: 0, y: distance }, + to: { opacity: 1, y: 0, duration: defaults.duration, ease: defaults.ease, delay: delay }, + }; + case 'fade-down': + return { + from: { opacity: 0, y: -distance }, + to: { opacity: 1, y: 0, duration: defaults.duration, ease: defaults.ease, delay: delay }, + }; + case 'fade-left': + return { + from: { opacity: 0, x: distance }, + to: { opacity: 1, x: 0, duration: defaults.duration, ease: defaults.ease, delay: delay }, + }; + case 'fade-right': + return { + from: { opacity: 0, x: -distance }, + to: { opacity: 1, x: 0, duration: defaults.duration, ease: defaults.ease, delay: delay }, + }; + case 'zoom-in': + return { + from: { opacity: 0, scale: 0.8 }, + to: { opacity: 1, scale: 1, duration: defaults.duration, ease: defaults.ease, delay: delay }, + }; + case 'fade': + default: + return base; + } + } + + function animateOnScroll(element, animationKey, defaults) { + if (!element || element.nodeType !== 1) return; + if (element.dataset && element.dataset.extendableAnimated === '1') return; + + const gsap = window.gsap; + if (!gsap) return; + + const props = buildAnimationProps(animationKey, defaults); + + // Set initial state once. + gsap.set(element, props.from); + + const ScrollTrigger = window.ScrollTrigger; + if (ScrollTrigger && typeof gsap.registerPlugin === 'function') { + gsap.registerPlugin(ScrollTrigger); + + gsap.to(element, { + ...props.to, + scrollTrigger: { + trigger: element, + start: 'top 85%', + once: true, + }, + }); + + element.dataset.extendableAnimated = '1'; + return; + } + + // Fallback without ScrollTrigger: IntersectionObserver. + if ('IntersectionObserver' in window) { + const io = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + gsap.to(element, props.to); + element.dataset.extendableAnimated = '1'; + io.disconnect(); + }); + }, + { root: null, threshold: 0.15 } + ); + + io.observe(element); + return; + } + + // Last resort: animate immediately. + gsap.to(element, props.to); + element.dataset.extendableAnimated = '1'; + } + + function run() { + const { map, defaults } = getConfig(); + + // Nothing configured: do nothing. + const keys = Object.keys(map); + if (!keys.length) return; + + const gsap = window.gsap; + if (!gsap) return; + + keys.forEach((selectorOrClass) => { + const selector = normalizeSelector(selectorOrClass); + if (!selector) return; + + const animationKey = map[selectorOrClass]; + const elements = document.querySelectorAll(selector); + if (!elements || !elements.length) return; + + // Use stagger if multiple elements + if (elements.length > 1) { + const staggerDelay = defaults.stagger || 0.2; + elements.forEach((el, index) => { + if (!el.dataset.extendableAnimated) { + const customDefaults = { ...defaults, delay: index * staggerDelay }; + animateOnScroll(el, animationKey, customDefaults); + } + }); + } else { + elements.forEach((el) => animateOnScroll(el, animationKey, defaults)); + } + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', run); + } else { + run(); + } +})(); diff --git a/bin/build-zip.sh b/bin/build-zip.sh index 79d2f92..c879aa6 100755 --- a/bin/build-zip.sh +++ b/bin/build-zip.sh @@ -33,6 +33,8 @@ assets=$(ls assets/css/*.css) fonts=$(ls assets/fonts/**/*.{woff2,txt}) tribeEvents=$(ls tribe-events/*.css) assetsJs=$(ls assets/js/*.js) +vendorJs=$(find assets/vendor -type f -name "*.js" 2>/dev/null || true) +configJson=$(find assets/config -type f -name "*.json" 2>/dev/null || true) zip -r ./build/extendable.zip \ $toplevelFiles \ @@ -48,6 +50,8 @@ zip -r ./build/extendable.zip \ $fonts \ $tribeEvents \ $assetsJs \ + $vendorJs \ + $configJson \ unzip ./build/extendable.zip -d "./build/extendable/" diff --git a/functions.php b/functions.php index 263e5a4..d116642 100644 --- a/functions.php +++ b/functions.php @@ -259,6 +259,117 @@ function extendable_enqueue_navigation_customizations() { endif; add_action( 'wp_enqueue_scripts', 'extendable_enqueue_navigation_customizations' ); +/** + * Animations + * + * @since Extendable 2.0.34 + * @return void + */ +function extendable_enqueue_animations() { + + $animation_option = get_option( 'extendable_animation_type', "fade" ); + + if ( is_admin() || + ! apply_filters( 'extendable_enable_animations', true ) || + false === $animation_option || + empty( $animation_option ) ) { + return; + } + + $config_file = get_template_directory() . '/assets/config/animations.json'; + + if ( ! file_exists( $config_file ) || ! is_readable( $config_file ) ) { + return; + } + + $config = json_decode( file_get_contents( $config_file ), true ); + + if ( ! is_array( $config ) ) { + return; + } + + $type = sanitize_key( $animation_option ); + + if ( ! isset( $config['types'][ $type ] ) ) { + $type = 'fade'; + } + + $mappings = $config['types'][ $type ]['mappings'] ?? array(); + $defaults = $config['defaults'] ?? array(); + $css_config = $config['css'] ?? array(); + + $sanitized = array(); + foreach ( $mappings as $selector => $animation ) { + $clean_selector = sanitize_text_field( trim( $selector ) ); + $clean_animation = sanitize_key( trim( $animation ) ); + + if ( ! empty( $clean_selector ) && ! empty( $clean_animation ) ) { + $sanitized[ $clean_selector ] = $clean_animation; + } + } + + if ( empty( $sanitized ) ) { + return; + } + + $gsap_path = get_template_directory() . '/assets/vendor/gsap/'; + $gsap_uri = get_template_directory_uri() . '/assets/vendor/gsap/'; + + $gsap_scripts = array( + 'extendable-gsap' => array( + 'file' => 'gsap.min.js', + 'deps' => array() + ), + 'extendable-gsap-scrolltrigger' => array( + 'file' => 'ScrollTrigger.min.js', + 'deps' => array( 'extendable-gsap' ) + ) + ); + + foreach ( $gsap_scripts as $handle => $script_config ) { + $file_path = $gsap_path . $script_config['file']; + + if ( file_exists( $file_path ) ) { + wp_enqueue_script( + $handle, + $gsap_uri . $script_config['file'], + $script_config['deps'], + filemtime( $file_path ), + true + ); + } + } + + wp_enqueue_script( + 'extendable-animations', + get_template_directory_uri() . '/assets/js/animations.js', + array( 'extendable-gsap-scrolltrigger' ), + EXTENDABLE_THEME_VERSION, + true + ); + + wp_localize_script( 'extendable-animations', 'ExtendableAnimations', array( + 'map' => $sanitized, + 'defaults' => array_map( 'sanitize_text_field', $defaults ), + )); + + // Generate FOUC prevention CSS + $animation_css = ''; + foreach ( $sanitized as $selector => $animation ) { + $css_rule = isset( $css_config[ $animation ] ) && ! empty( $css_config[ $animation ] ) + ? sanitize_text_field( $css_config[ $animation ] ) + : 'opacity: 0;'; + + $animation_css .= sprintf( '%s { %s } ', esc_attr( $selector ), $css_rule ); + } + + if ( ! empty( $animation_css ) ) { + wp_add_inline_style( 'extendable-style', $animation_css ); + } +} +add_action( 'wp_enqueue_scripts', 'extendable_enqueue_animations' ); + + /** * Set default template for new pages in the block editor (auto-drafts) *