first commit

This commit is contained in:
Roman Pyrih
2026-05-21 15:33:11 +02:00
commit acb036dbd9
8059 changed files with 2885104 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
<?php
namespace Elementor\Modules\Interactions;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Collects interaction data from all rendered documents and provides centralized access.
*/
class Interactions_Collector {
/**
* @var Interactions_Collector
*/
private static $instance = null;
/**
* @var array Stores interaction data keyed by element ID
* Format: [ 'element_id' => [ interaction_items... ] ]
*/
private $interactions_data = [];
/**
* Get singleton instance.
*
* @return Interactions_Collector
*/
public static function instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Register interaction data for an element.
*
* @param string $element_id The element ID (data-id attribute value)
* @param array $interactions The full interactions array from the element
*/
public function register( $element_id, $interactions ) {
if ( empty( $element_id ) || empty( $interactions ) ) {
return;
}
$this->interactions_data[ $element_id ] = $interactions;
}
/**
* Get all collected interaction data.
*
* @return array Format: [ 'element_id' => interactions_array ]
*/
public function get_all() {
return $this->interactions_data;
}
/**
* Get interaction data for a specific element.
*
* @param string $element_id The element ID
* @return array|null
*/
public function get( $element_id ) {
return $this->interactions_data[ $element_id ] ?? null;
}
/**
* Reset collected data (useful for testing or page reloads).
*/
public function reset() {
$this->interactions_data = [];
}
}

View File

@@ -0,0 +1,216 @@
<?php
namespace Elementor\Modules\Interactions;
use Elementor\Plugin;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Handles frontend-specific interaction logic including:
* - Collecting interactions from document elements during render
* - Outputting interaction data as JSON in the page footer
*
* This class is responsible for the frontend rendering pipeline of interactions,
* working with the Interactions_Collector for data storage and Adapter for data transformation.
*/
class Interactions_Frontend_Handler {
/**
* @var callable|null
*/
private $config_provider;
public function __construct( $config_provider = null ) {
$this->config_provider = is_callable( $config_provider ) ? $config_provider : null;
}
/**
* Collect interactions from document elements during frontend render.
*
* This method is hooked to 'elementor/frontend/builder_content_data' filter
* to capture interactions from all documents (header, footer, post content)
* as they are rendered.
*
* @param array $elements_data The document's elements data.
* @param int $post_id The document's post ID.
* @return array The unmodified elements data (pass-through filter).
*/
public function collect_document_interactions( $elements_data, $post_id ) {
// Only collect on frontend, not in editor
if ( Plugin::$instance->editor->is_edit_mode() ) {
return $elements_data;
}
if ( empty( $elements_data ) || ! is_array( $elements_data ) ) {
return $elements_data;
}
$collector = Interactions_Collector::instance();
// Recursively collect interactions from all elements
$this->collect_interactions_recursive( $elements_data, $collector );
return $elements_data;
}
/**
* Recursively iterate through all elements and collect interactions.
*
* @param array $elements Array of element data.
* @param Interactions_Collector $collector The collector instance.
*/
private function collect_interactions_recursive( $elements, $collector ) {
if ( ! is_array( $elements ) ) {
return;
}
foreach ( $elements as $element ) {
if ( ! is_array( $element ) ) {
continue;
}
// Check if this element has interactions
if ( ! empty( $element['id'] ) && isset( $element['interactions'] ) ) {
$element_id = $element['id'];
$interactions = $element['interactions'];
// Decode if it's a JSON string
if ( is_string( $interactions ) ) {
$decoded = json_decode( $interactions, true );
if ( json_last_error() === JSON_ERROR_NONE && is_array( $decoded ) ) {
$interactions = $decoded;
} else {
$interactions = null;
}
}
// Normalize the interactions format - ensure we have items array
if ( is_array( $interactions ) ) {
// If interactions has 'items' key, it's already in the right format
// If not, check if it's a direct array of items or has other structure
if ( ! isset( $interactions['items'] ) ) {
// Check if this looks like a direct array of interaction items
// (first element has 'trigger' or 'animation' or '$$type')
$first_item = reset( $interactions );
if ( is_array( $first_item ) && ( isset( $first_item['trigger'] ) || isset( $first_item['animation'] ) || isset( $first_item['$$type'] ) ) ) {
// It's a direct array of items, wrap it
$interactions = [ 'items' => $interactions ];
}
}
// Register with collector if we have valid items
$items = $interactions['items'] ?? [];
if ( ! empty( $items ) || ! empty( $interactions ) ) {
$collector->register( $element_id, $interactions );
}
}
}
// Recursively process child elements
if ( ! empty( $element['elements'] ) && is_array( $element['elements'] ) ) {
$this->collect_interactions_recursive( $element['elements'], $collector );
}
}
}
/**
* Output collected interaction data as a JSON script tag in the footer.
*
* This method is hooked to 'wp_footer' to output all collected interactions
* as a centralized JSON data block that the frontend JavaScript can consume.
*/
public function print_interactions_data() {
// Only output on frontend, not in editor
if ( Plugin::$instance->editor->is_edit_mode() ) {
return;
}
$collector = Interactions_Collector::instance();
$all_interactions = $collector->get_all();
if ( empty( $all_interactions ) ) {
return;
}
// Format: array of elements, each with elementId, dataId, and cleaned interactions
$elements_with_interactions = [];
foreach ( $all_interactions as $element_id => $interactions ) {
$items = $this->extract_interaction_items( $interactions );
if ( empty( $items ) ) {
continue;
}
// Build element entry with elementId, dataId, and cleaned interactions array
$elements_with_interactions[] = [
'elementId' => $element_id,
'dataId' => $element_id,
'interactions' => $items,
];
}
if ( empty( $elements_with_interactions ) ) {
return;
}
$this->enqueue_interactions_assets();
// Output as JSON script tag
$json_data = wp_json_encode( $elements_with_interactions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON data is already encoded
echo '<script type="application/json" id="' . Module::SCRIPT_ID_INTERACTIONS_DATA . '">' . $json_data . '</script>';
}
/**
* Extract interaction items from various data formats.
*
* Handles multiple formats:
* - v1 format: { items: [...] }
* - v2 format with $$type: { items: { $$type: '...', value: [...] } }
* - Direct arrays: [{ trigger: ..., animation: ... }, ...]
*
* @param array $interactions The interactions data.
* @return array The extracted items array.
*/
private function extract_interaction_items( $interactions ) {
if ( ! is_array( $interactions ) ) {
return [];
}
// Check if it has 'items' key (standard format)
if ( isset( $interactions['items'] ) ) {
$items = $interactions['items'];
return is_array( $items ) ? $items : [];
}
// Check if interactions itself is a direct array of items
// (first element has interaction-related keys)
$first_item = reset( $interactions );
if ( is_array( $first_item ) && (
isset( $first_item['trigger'] ) ||
isset( $first_item['animation'] ) ||
isset( $first_item['$$type'] )
) ) {
return $interactions;
}
return [];
}
private function enqueue_interactions_assets() {
wp_enqueue_script( Module::HANDLE_MOTION_JS );
wp_enqueue_script( Module::HANDLE_FRONTEND );
$config = $this->config_provider ? call_user_func( $this->config_provider ) : [];
wp_localize_script(
Module::HANDLE_FRONTEND,
Module::JS_CONFIG_OBJECT,
$config
);
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace Elementor\Modules\Interactions;
use Elementor\Core\Base\Module as BaseModule;
use Elementor\Core\Experiments\Manager as Experiments_Manager;
use Elementor\Modules\AtomicWidgets\Module as AtomicWidgetsModule;
use Elementor\Plugin;
use Elementor\Utils;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Module extends BaseModule {
const MODULE_NAME = 'e-interactions';
const EXPERIMENT_NAME = 'e_interactions';
const HANDLE_MOTION_JS = 'motion-js';
const HANDLE_SHARED_UTILS = 'elementor-interactions-shared-utils';
const HANDLE_FRONTEND = 'elementor-interactions';
const HANDLE_EDITOR = 'elementor-editor-interactions';
const JS_CONFIG_OBJECT = 'ElementorInteractionsConfig';
const SCRIPT_ID_INTERACTIONS_DATA = 'elementor-interactions-data';
public function get_name() {
return self::MODULE_NAME;
}
private $preset_animations;
private $frontend_handler;
private function get_presets() {
if ( ! $this->preset_animations ) {
$this->preset_animations = new Presets();
}
return $this->preset_animations;
}
private function get_frontend_handler() {
if ( ! $this->frontend_handler ) {
$this->frontend_handler = new Interactions_Frontend_Handler( fn () => $this->get_config() );
}
return $this->frontend_handler;
}
public static function get_experimental_data() {
return [
'name' => self::EXPERIMENT_NAME,
'title' => esc_html__( 'Interactions', 'elementor' ),
'description' => esc_html__( 'Enable element interactions.', 'elementor' ),
'hidden' => true,
'default' => Experiments_Manager::STATE_ACTIVE,
'release_status' => Experiments_Manager::RELEASE_STATUS_DEV,
];
}
public function is_experiment_active() {
return Plugin::$instance->experiments->is_feature_active( self::EXPERIMENT_NAME )
&& Plugin::$instance->experiments->is_feature_active( AtomicWidgetsModule::EXPERIMENT_NAME );
}
public function __construct() {
parent::__construct();
if ( ! $this->is_experiment_active() ) {
return;
}
add_action( 'elementor/frontend/after_register_scripts', fn () => $this->register_frontend_scripts() );
add_action( 'elementor/editor/before_enqueue_scripts', fn () => $this->enqueue_editor_scripts() );
add_action( 'elementor/preview/enqueue_scripts', fn () => $this->enqueue_preview_scripts() );
add_action( 'elementor/editor/after_enqueue_scripts', fn () => $this->enqueue_editor_scripts() );
// Collect interactions from documents before they render (header, footer, post content)
add_filter( 'elementor/frontend/builder_content_data', [ $this->get_frontend_handler(), 'collect_document_interactions' ], 10, 2 );
// Output centralized interaction data in footer
add_action( 'wp_footer', [ $this->get_frontend_handler(), 'print_interactions_data' ], 1 );
add_filter( 'elementor/document/save/data',
/**
* @throws \Exception
*/
function( $data, $document ) {
$validation = new Validation();
$document_after_sanitization = $validation->sanitize( $data );
$validation->validate();
return $document_after_sanitization;
},
10, 2 );
add_filter( 'elementor/document/save/data', function( $data, $document ) {
return ( new Parser( $document->get_main_id() ) )->assign_interaction_ids( $data );
}, 11, 2 );
}
public function get_config() {
return [
'constants' => $this->get_presets()->defaults(),
'breakpoints' => $this->get_active_breakpoints(),
];
}
private function get_active_breakpoints() {
$breakpoints_config = Plugin::$instance->breakpoints->get_breakpoints_config();
$active_breakpoints = Plugin::$instance->breakpoints->get_active_breakpoints();
$breakpoints = [];
foreach ( array_keys( $active_breakpoints ) as $breakpoint_label ) {
$breakpoints[ $breakpoint_label ] = $breakpoints_config[ $breakpoint_label ];
}
return $breakpoints;
}
private function register_frontend_scripts() {
$suffix = ( Utils::is_script_debug() || Utils::is_elementor_tests() ) ? '' : '.min';
wp_register_script(
self::HANDLE_MOTION_JS,
ELEMENTOR_ASSETS_URL . 'lib/motion/motion' . $suffix . '.js',
[],
'11.13.5',
true
);
wp_register_script(
self::HANDLE_SHARED_UTILS,
$this->get_js_assets_url( 'interactions-shared-utils' ),
[ self::HANDLE_MOTION_JS ],
'1.0.0',
true
);
wp_register_script(
self::HANDLE_FRONTEND,
$this->get_js_assets_url( 'interactions' ),
[ self::HANDLE_MOTION_JS, self::HANDLE_SHARED_UTILS ],
'1.0.0',
true
);
wp_register_script(
self::HANDLE_EDITOR,
$this->get_js_assets_url( 'editor-interactions' ),
[ self::HANDLE_MOTION_JS, self::HANDLE_SHARED_UTILS ],
'1.0.0',
true
);
}
public function enqueue_editor_scripts() {
wp_add_inline_script(
'elementor-common',
'window.' . self::JS_CONFIG_OBJECT . ' = ' . wp_json_encode( $this->get_config() ) . ';',
'before'
);
}
public function enqueue_preview_scripts() {
wp_enqueue_script( self::HANDLE_SHARED_UTILS );
// Ensure motion-js and editor-interactions handler are available in preview iframe
wp_enqueue_script( self::HANDLE_MOTION_JS );
wp_enqueue_script( self::HANDLE_EDITOR );
wp_localize_script(
self::HANDLE_EDITOR,
self::JS_CONFIG_OBJECT,
$this->get_config()
);
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace Elementor\Modules\Interactions;
use Elementor\Modules\AtomicWidgets\Utils\Utils;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Parser {
protected $post_id;
protected $ids_lookup = [];
public function __construct( $post_id ) {
$this->post_id = $post_id;
}
public function assign_interaction_ids( $data ) {
if ( isset( $data['elements'] ) && is_array( $data['elements'] ) ) {
$data['elements'] = $this->process_interactions_for( $data['elements'] );
}
return $data;
}
private function process_interactions_for( $elements ) {
if ( ! is_array( $elements ) ) {
return $elements;
}
foreach ( $elements as &$element ) {
if ( isset( $element['interactions'] ) ) {
$element['interactions'] = $this->maybe_assign_interaction_ids( $element['interactions'], $element['id'] );
}
if ( isset( $element['elements'] ) && is_array( $element['elements'] ) ) {
$element['elements'] = $this->process_interactions_for( $element['elements'] );
}
}
return $elements;
}
private function maybe_assign_interaction_ids( $interactions_json, $element_id ) {
$interactions = $this->decode_interactions( $interactions_json );
if ( ! isset( $interactions['items'] ) ) {
return [];
}
foreach ( $interactions['items'] as &$interaction ) {
if ( ! isset( $interaction['$$type'] ) || 'interaction-item' !== $interaction['$$type'] ) {
continue;
}
$existing_id = null;
if ( isset( $interaction['value']['interaction_id']['value'] ) ) {
$existing_id = $interaction['value']['interaction_id']['value'];
}
if ( $existing_id && $this->is_temp_id( $existing_id ) ) {
$interaction['value']['interaction_id'] = [
'$$type' => 'string',
'value' => $this->get_next_interaction_id( $element_id ),
];
} elseif ( $existing_id ) {
$this->ids_lookup[] = $existing_id;
} else {
$interaction['value']['interaction_id'] = [
'$$type' => 'string',
'value' => $this->get_next_interaction_id( $element_id ),
];
}
}
return wp_json_encode( $interactions );
}
private function is_temp_id( $id ) {
return is_string( $id ) && strpos( $id, 'temp-' ) === 0;
}
private function decode_interactions( $interactions ) {
if ( is_array( $interactions ) ) {
return $interactions;
}
if ( is_string( $interactions ) ) {
$decoded = json_decode( $interactions, true );
if ( json_last_error() === JSON_ERROR_NONE && is_array( $decoded ) ) {
return $decoded;
}
}
return [
'items' => [],
'version' => 1,
];
}
protected function get_next_interaction_id( $prefix ) {
$next_id = Utils::generate_id( "{$this->post_id}-{$prefix}-", $this->ids_lookup );
$this->ids_lookup[] = $next_id;
return $next_id;
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace Elementor\Modules\Interactions;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Presets {
const DEFAULT_DURATION = 600;
const DEFAULT_DELAY = 0;
const DEFAULT_SLIDE_DISTANCE = 100;
const DEFAULT_SCALE_START = 0;
const DEFAULT_RELATIVE_TO = 'viewport';
const DEFAULT_END = 15;
const DEFAULT_START = 85;
const BASE_TRIGGERS = [ 'load', 'scrollIn' ];
const ADDITIONAL_TRIGGERS = [ 'scrollOut', 'scrollOn', 'hover', 'click' ];
const DEFAULT_EASING = 'easeIn';
const BASE_EFFECTS = [ 'fade', 'slide', 'scale' ];
const ADDITIONAL_EFFECTS = [ 'custom' ];
const TYPES = [ 'in', 'out' ];
const DIRECTIONS = [ 'left', 'right', 'top', 'bottom', '' ];
const BASE_EASING = [ 'easeIn' ];
const ADDITIONAL_EASING = [ 'easeOut', 'easeInOut', 'backIn', 'backInOut', 'backOut', 'linear' ];
const DEFAULT_REPEAT = '';
const REPEAT_OPTIONS = [ 'loop', 'times', '' ];
public static function easing_options() {
return array_merge( self::BASE_EASING, self::ADDITIONAL_EASING );
}
public static function effects_options() {
return array_merge( self::BASE_EFFECTS, self::ADDITIONAL_EFFECTS );
}
public static function triggers_options() {
return array_merge( self::BASE_TRIGGERS, self::ADDITIONAL_TRIGGERS );
}
public function defaults() {
return [
'defaultDuration' => self::DEFAULT_DURATION,
'defaultDelay' => self::DEFAULT_DELAY,
'slideDistance' => self::DEFAULT_SLIDE_DISTANCE,
'scaleStart' => self::DEFAULT_SCALE_START,
'defaultEasing' => self::DEFAULT_EASING,
'relativeTo' => self::DEFAULT_RELATIVE_TO,
'repeat' => self::DEFAULT_REPEAT,
'start' => self::DEFAULT_START,
'end' => self::DEFAULT_END,
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Elementor\Modules\Interactions\Props;
use Elementor\Modules\AtomicWidgets\PropTypes\Base\Object_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Primitives\Boolean_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Primitives\Number_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Primitives\String_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Size_Prop_Type;
use Elementor\Modules\Interactions\Presets;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Animation_Config_Prop_Type extends Object_Prop_Type {
public static function get_key(): string {
return 'config-v2';
}
protected function define_shape(): array {
return [
'replay' => Boolean_Prop_Type::make()->meta( 'pro', true )->description( 'Whether to replay the animation' ),
'easing' => String_Prop_Type::make()->meta( 'enum', Presets::easing_options() )->default( Presets::DEFAULT_EASING )->meta( 'pro', Presets::ADDITIONAL_EASING )->description( 'The easing function to use for the animation' ),
'relativeTo' => String_Prop_Type::make()->meta( 'pro', true )->description( 'The container scope used by scroll-based interactions' ),
'repeat' => String_Prop_Type::make()->meta( 'enum', Presets::REPEAT_OPTIONS )->default( Presets::DEFAULT_REPEAT )->meta( 'pro', true )->description( 'Repeat mode for interactions that can run multiple times' ),
'times' => Number_Prop_Type::make()->meta( 'pro', true )->description( 'Total number of times to play when repeat mode is "times"' ),
'start' => Size_Prop_Type::make()->units( '%' )->default_unit( '%' )->meta( 'pro', true )->description( 'The start to use for the animation' ),
'end' => Size_Prop_Type::make()->units( '%' )->default_unit( '%' )->meta( 'pro', true )->description( 'The end to use for the animation' ),
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Elementor\Modules\Interactions\Props;
use Elementor\Modules\AtomicWidgets\PropTypes\Base\Object_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Primitives\String_Prop_Type;
use Elementor\Modules\Interactions\Presets;
use Elementor\Modules\Interactions\Utils\Prop_Shape_Filter_For_Pro;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Animation_Preset_Prop_Type extends Object_Prop_Type {
public static function get_key(): string {
return 'animation-preset-props';
}
protected function define_shape(): array {
return [
'effect' => String_Prop_Type::make()->meta( 'enum', Presets::effects_options() )->meta( 'pro', Presets::ADDITIONAL_EFFECTS )->description( 'The effect to use for the animation' ),
'type' => String_Prop_Type::make()->meta( 'enum', Presets::TYPES )->description( 'The type to use for the animation' ),
'direction' => String_Prop_Type::make()->meta( 'enum', Presets::DIRECTIONS )->description( 'The direction to use for the animation' ),
'timing_config' => Timing_Config_Prop_Type::make()->description( 'The timing config to use for the animation' ),
'config' => Animation_Config_Prop_Type::make()->description( 'The config to use for the animation' ),
'custom_effect' => Custom_Effect_Prop_Type::make()->meta( 'pro', true ),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Elementor\Modules\Interactions\Props;
use Elementor\Modules\AtomicWidgets\PropTypes\Base\Object_Prop_Type;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Custom_Effect_Prop_Type extends Object_Prop_Type {
public static function get_key(): string {
return 'custom-effect';
}
protected function define_shape(): array {
return [
'keyframes' => Keyframes_Prop_Type::make()->required(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Elementor\Modules\Interactions\Props;
use Elementor\Modules\AtomicWidgets\PropTypes\Base\Array_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Contracts\Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Primitives\String_Prop_Type;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Excluded_Breakpoints_Prop_Type extends Array_Prop_Type {
public static function get_key(): string {
return 'excluded-breakpoints';
}
protected function define_item_type(): Prop_Type {
return String_Prop_Type::make();
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Elementor\Modules\Interactions\Props;
use Elementor\Modules\AtomicWidgets\PropTypes\Base\Object_Prop_Type;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Interaction_Breakpoints_Prop_Type extends Object_Prop_Type {
public static function get_key(): string {
return 'interaction-breakpoints';
}
protected function define_shape(): array {
return [
'excluded' => Excluded_Breakpoints_Prop_Type::make()->description( 'The excluded breakpoints' ),
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Elementor\Modules\Interactions\Props;
use Elementor\Modules\AtomicWidgets\PropTypes\Base\Object_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Primitives\String_Prop_Type;
use Elementor\Modules\Interactions\Presets;
use Elementor\Modules\Interactions\Utils\Prop_Shape_Filter_For_Pro;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Interaction_Item_Prop_Type extends Object_Prop_Type {
public static function get_key(): string {
return 'interaction-item';
}
protected function define_shape(): array {
return [
'interaction_id' => String_Prop_Type::make()->description( 'The interaction id to use for the animation' ),
'trigger' => String_Prop_Type::make()->meta( 'enum', Presets::triggers_options() )->meta( 'pro', Presets::ADDITIONAL_TRIGGERS )->description( 'The trigger to use for the animation' ),
'animation' => Animation_Preset_Prop_Type::make()->description( 'The animation to use for the interaction' ),
'breakpoints' => Interaction_Breakpoints_Prop_Type::make()->description( 'The breakpoints to use for the animation' ),
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Elementor\Modules\Interactions\Props;
use Elementor\Modules\AtomicWidgets\PropTypes\Base\Object_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Size_Prop_Type;
use Elementor\Modules\AtomicWidgets\Styles\Size_Constants;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Keyframe_Stop_Prop_Type extends Object_Prop_Type {
public static function get_key(): string {
return 'keyframe-stop';
}
protected function define_shape(): array {
return [
'stop' => Size_Prop_Type::make()
->default_unit( Size_Constants::UNIT_PERCENT )
->required(),
'settings' => Keyframe_Stop_Settings_Prop_Type::make()
->required(),
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Elementor\Modules\Interactions\Props;
use Elementor\Modules\AtomicWidgets\PropTypes\Base\Object_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Size_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Transform\Functions\Transform_Move_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Transform\Functions\Transform_Rotate_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Transform\Functions\Transform_Scale_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Transform\Functions\Transform_Skew_Prop_Type;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Keyframe_Stop_Settings_Prop_Type extends Object_Prop_Type {
public static function get_key(): string {
return 'keyframe-stop-settings';
}
protected function define_shape(): array {
return [
'opacity' => Size_Prop_Type::make(),
'move' => Transform_Move_Prop_Type::make(),
'rotate' => Transform_Rotate_Prop_Type::make(),
'scale' => Transform_Scale_Prop_Type::make(),
'skew' => Transform_Skew_Prop_Type::make(),
];
}
protected function validate_value( $value ): bool {
if ( ! is_array( $value ) ) {
return false;
}
$allowed_keys = array_keys( $this->get_shape() );
$value_keys = array_keys( $value );
if ( array_diff( $value_keys, $allowed_keys ) !== [] ) {
return false;
}
return parent::validate_value( $value );
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Elementor\Modules\Interactions\Props;
use Elementor\Modules\AtomicWidgets\PropTypes\Base\Array_Prop_Type;
use Elementor\Modules\AtomicWidgets\PropTypes\Contracts\Prop_Type;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Keyframes_Prop_Type extends Array_Prop_Type {
public static function get_key(): string {
return 'keyframes';
}
protected function define_item_type(): Prop_Type {
return Keyframe_Stop_Prop_Type::make();
}
protected function validate_value( $value ): bool {
$is_empty_array = empty( $value ) && is_array( $value );
if ( $is_empty_array ) {
return false;
}
return parent::validate_value( $value );
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Elementor\Modules\Interactions\Props;
use Elementor\Modules\AtomicWidgets\PropTypes\Size_Prop_Type;
use Elementor\Modules\AtomicWidgets\Styles\Size_Constants;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Time_Size_Prop_Type extends Size_Prop_Type {
public static function make() {
return parent::make()->units( Size_Constants::time() )->default_unit( Size_Constants::UNIT_MILLI_SECOND );
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Elementor\Modules\Interactions\Props;
use Elementor\Modules\AtomicWidgets\PropTypes\Base\Object_Prop_Type;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Timing_Config_Prop_Type extends Object_Prop_Type {
public static function get_key(): string {
return 'timing-config';
}
protected function define_shape(): array {
return [
'duration' => Time_Size_Prop_Type::make()->description( 'The duration to use for the animation' ),
'delay' => Time_Size_Prop_Type::make()->description( 'The delay to use for the animation' ),
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Elementor\Modules\Interactions\Schema;
use Elementor\Modules\Interactions\Props\Interaction_Item_Prop_Type;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Interactions_Schema {
public static function get() {
return apply_filters( 'elementor/atomic-widgets/interactions/schema', static::get_interactions_schema() );
}
public static function get_interactions_schema(): array {
return [
'version' => 1,
'items' => [ Interaction_Item_Prop_Type::make()->description( 'Interaction item' ) ],
];
}
}

View File

@@ -0,0 +1,416 @@
<?php
namespace Elementor\Modules\Interactions;
use Elementor\Modules\Interactions\Validators\Breakpoints_Value as BreakpointsValueValidator;
use Elementor\Modules\Interactions\Validators\Custom_Effect_Value;
use Elementor\Modules\Interactions\Validators\String_Value as StringValueValidator;
use Elementor\Modules\Interactions\Validators\Trigger_Value as TriggerValueValidator;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Validation {
private $elements_to_interactions_counter = [];
private $max_number_of_interactions = 5;
private const VALID_EFFECTS = [ 'fade', 'slide', 'scale', 'custom' ];
private const VALID_TYPES = [ 'in', 'out' ];
private const VALID_DIRECTIONS = [ '', 'left', 'right', 'top', 'bottom' ];
private const VALID_REPEAT_MODES = [ '', 'loop', 'times' ];
public function sanitize( $document ) {
return $this->sanitize_document_data( $document );
}
public function validate() {
foreach ( $this->elements_to_interactions_counter as $element_id => $number_of_interactions ) {
if ( $number_of_interactions > $this->max_number_of_interactions ) {
throw new \Exception(
sprintf(
// translators: %1$s: element ID, %2$d: maximum number of interactions allowed.
esc_html__( 'Element %1$s has more than %2$d interactions', 'elementor' ),
esc_html( $element_id ),
esc_html( $this->max_number_of_interactions )
)
);
}
}
return true;
}
private function sanitize_document_data( $data ) {
if ( isset( $data['elements'] ) && is_array( $data['elements'] ) ) {
$data['elements'] = $this->sanitize_elements_interactions( $data['elements'] );
}
return $data;
}
private function sanitize_elements_interactions( $elements ) {
if ( ! is_array( $elements ) ) {
return $elements;
}
foreach ( $elements as &$element ) {
if ( isset( $element['interactions'] ) ) {
$element['interactions'] = $this->sanitize_interactions( $element['interactions'], $element['id'] );
}
if ( isset( $element['elements'] ) && is_array( $element['elements'] ) ) {
$element['elements'] = $this->sanitize_elements_interactions( $element['elements'] );
}
}
return $elements;
}
private function decode_interactions( $interactions ) {
if ( is_array( $interactions ) ) {
if ( isset( $interactions['items']['$$type'] ) && 'array' === $interactions['items']['$$type'] ) {
return isset( $interactions['items']['value'] ) ? $interactions['items']['value'] : [];
}
return isset( $interactions['items'] ) ? $interactions['items'] : [];
}
if ( is_string( $interactions ) ) {
$decoded = json_decode( $interactions, true );
if ( json_last_error() === JSON_ERROR_NONE && is_array( $decoded ) ) {
if ( isset( $decoded['items']['$$type'] ) && 'array' === $decoded['items']['$$type'] ) {
return isset( $decoded['items']['value'] ) ? $decoded['items']['value'] : [];
}
return isset( $decoded['items'] ) ? $decoded['items'] : [];
}
}
return [];
}
private function increment_interactions_counter_for( $element_id ) {
if ( ! array_key_exists( $element_id, $this->elements_to_interactions_counter ) ) {
$this->elements_to_interactions_counter[ $element_id ] = 0;
}
++$this->elements_to_interactions_counter[ $element_id ];
return $this;
}
private function sanitize_interactions( $interactions, $element_id ) {
$sanitized = [
'items' => [],
'version' => 1,
];
$list_of_interactions = $this->decode_interactions( $interactions );
foreach ( $list_of_interactions as $interaction ) {
if ( $this->is_valid_interaction_item( $interaction ) ) {
$sanitized['items'][] = $interaction;
$this->increment_interactions_counter_for( $element_id );
}
}
if ( empty( $sanitized['items'] ) ) {
return [];
}
return wp_json_encode( $sanitized );
}
private function is_valid_interaction_item( $item ) {
if ( ! is_array( $item ) ) {
return false;
}
// Validate PropType format: { $$type: 'interaction-item', value: { ... } }
if ( ! isset( $item['$$type'] ) || 'interaction-item' !== $item['$$type'] ) {
return false;
}
if ( ! isset( $item['value'] ) || ! is_array( $item['value'] ) ) {
return false;
}
$value = $item['value'];
// Validate required fields exist
if ( isset( $value['interaction_id'] ) && ! $this->is_valid_string_prop( $value, 'interaction_id' ) ) {
return false;
}
if ( ! $this->is_valid_trigger_prop( $value ) ) {
return false;
}
if ( ! $this->is_valid_animation_prop( $value ) ) {
return false;
}
if ( ! $this->is_valid_breakpoints_prop( $value ) ) {
return false;
}
return true;
}
private function is_valid_trigger_prop( $data ) {
if ( ! array_key_exists( 'trigger', $data ) ) {
return false;
}
return TriggerValueValidator::is_valid( $data['trigger'] );
}
private function is_valid_breakpoints_prop( $data ) {
if ( array_key_exists( 'breakpoints', $data ) ) {
return BreakpointsValueValidator::is_valid( $data['breakpoints'] );
}
return true;
}
private function is_valid_string_prop( $data, $key, $allowed_values = null ) {
if ( ! isset( $data[ $key ] ) ) {
return false;
}
return StringValueValidator::is_valid( $data[ $key ], $allowed_values );
}
private function is_valid_boolean_prop( $data, $key ) {
if ( ! isset( $data[ $key ] ) || ! is_array( $data[ $key ] ) ) {
return false;
}
$prop = $data[ $key ];
if ( ! isset( $prop['$$type'] ) || 'boolean' !== $prop['$$type'] ) {
return false;
}
if ( ! isset( $prop['value'] ) || ! is_bool( $prop['value'] ) ) {
return false;
}
return true;
}
private function is_valid_number_prop( $data, $key ) {
if ( ! isset( $data[ $key ] ) || ! is_array( $data[ $key ] ) ) {
return false;
}
$prop = $data[ $key ];
if ( ! isset( $prop['$$type'] ) || 'number' !== $prop['$$type'] ) {
return false;
}
if ( ! isset( $prop['value'] ) || ! is_numeric( $prop['value'] ) ) {
return false;
}
return true;
}
private function is_valid_number_prop_in_range( $data, $key, $min = null, $max = null ) {
if ( ! $this->is_valid_number_prop( $data, $key ) ) {
return false;
}
$value = (float) $data[ $key ]['value'];
if ( null !== $min && $value < $min ) {
return false;
}
if ( null !== $max && $value > $max ) {
return false;
}
return true;
}
private function is_valid_config_prop( $data ) {
if ( ! isset( $data['config'] ) || ! is_array( $data['config'] ) ) {
return false;
}
$config_value = $data['config']['value'];
if ( isset( $config_value['replay'] ) && ! $this->is_valid_boolean_prop( $config_value, 'replay' ) ) {
return false;
}
if ( isset( $config_value['easing'] ) && ! $this->is_valid_string_prop( $config_value, 'easing' ) ) {
return false;
}
if ( isset( $config_value['relativeTo'] ) && ! $this->is_valid_string_prop( $config_value, 'relativeTo' ) ) {
return false;
}
if ( isset( $config_value['repeat'] ) && ! $this->is_valid_string_prop( $config_value, 'repeat', self::VALID_REPEAT_MODES ) ) {
return false;
}
if ( isset( $config_value['times'] ) && ! $this->is_valid_number_prop_in_range( $config_value, 'times', 1 ) ) {
return false;
}
if ( isset( $config_value['start'] ) && ! $this->is_valid_size_prop_in_range( $config_value, 'start', 0, 100 ) ) {
return false;
}
if ( isset( $config_value['end'] ) && ! $this->is_valid_size_prop_in_range( $config_value, 'end', 0, 100 ) ) {
return false;
}
return true;
}
private function is_valid_animation_prop( $data ) {
if ( ! isset( $data['animation'] ) || ! is_array( $data['animation'] ) ) {
return false;
}
$animation = $data['animation'];
if ( ! isset( $animation['$$type'] ) || 'animation-preset-props' !== $animation['$$type'] ) {
return false;
}
if ( ! isset( $animation['value'] ) || ! is_array( $animation['value'] ) ) {
return false;
}
$animation_value = $animation['value'];
// Validate effect
if ( ! $this->is_valid_string_prop( $animation_value, 'effect', self::VALID_EFFECTS ) ) {
return false;
}
// Validate type
if ( ! $this->is_valid_string_prop( $animation_value, 'type', self::VALID_TYPES ) ) {
return false;
}
// Validate direction (can be empty string)
if ( ! $this->is_valid_string_prop( $animation_value, 'direction', self::VALID_DIRECTIONS ) ) {
return false;
}
// Validate timing_config
if ( ! $this->is_valid_timing_config( $animation_value ) ) {
return false;
}
if ( isset( $animation_value['config'] ) && ! $this->is_valid_config_prop( $animation_value ) ) {
return false;
}
if ( ! Custom_Effect_Value::is_valid( $animation_value ) ) {
return false;
}
return true;
}
private function is_valid_timing_config( $data ) {
if ( ! isset( $data['timing_config'] ) || ! is_array( $data['timing_config'] ) ) {
return false;
}
$timing = $data['timing_config'];
if ( ! isset( $timing['$$type'] ) || 'timing-config' !== $timing['$$type'] ) {
return false;
}
if ( ! isset( $timing['value'] ) || ! is_array( $timing['value'] ) ) {
return false;
}
$timing_value = $timing['value'];
// Validate duration (accepts both 'number' and 'size' formats)
if ( ! $this->is_valid_timing_value( $timing_value, 'duration', 0 ) ) {
return false;
}
// Validate delay (accepts both 'number' and 'size' formats)
if ( ! $this->is_valid_timing_value( $timing_value, 'delay', 0 ) ) {
return false;
}
return true;
}
/**
* Validate timing value that can be either 'number' or 'size' type.
* - number format: {$$type: 'number', value: 123}
* - size format: {$$type: 'size', value: {size: 123, unit: 'ms'}}
*/
private function is_valid_timing_value( $data, $key, $min = null, $max = null ) {
if ( ! isset( $data[ $key ] ) || ! is_array( $data[ $key ] ) ) {
return false;
}
$prop = $data[ $key ];
if ( ! isset( $prop['$$type'] ) ) {
return false;
}
// Accept 'number' format
if ( 'number' === $prop['$$type'] ) {
return $this->is_valid_number_prop_in_range( $data, $key, $min, $max );
}
// Accept 'size' format
if ( 'size' === $prop['$$type'] ) {
return $this->is_valid_size_prop_in_range( $data, $key, $min, $max );
}
return false;
}
/**
* Validate size prop: {$$type: 'size', value: {size: X, unit: 'ms'}}
*/
private function is_valid_size_prop_in_range( $data, $key, $min = null, $max = null ) {
if ( ! isset( $data[ $key ] ) || ! is_array( $data[ $key ] ) ) {
return false;
}
$prop = $data[ $key ];
if ( ! isset( $prop['$$type'] ) || 'size' !== $prop['$$type'] ) {
return false;
}
if ( ! isset( $prop['value'] ) || ! is_array( $prop['value'] ) ) {
return false;
}
if ( ! isset( $prop['value']['size'] ) || ! is_numeric( $prop['value']['size'] ) ) {
return false;
}
$value = (float) $prop['value']['size'];
if ( null !== $min && $value < $min ) {
return false;
}
if ( null !== $max && $value > $max ) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Elementor\Modules\Interactions\Validators;
use Elementor\Modules\Interactions\Validators\String_Value as StringValueValidator;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Breakpoints_Value {
public static function is_valid( $breakpoints_prop_value ) {
if ( ! is_array( $breakpoints_prop_value ) ) {
return false;
}
if ( ! isset( $breakpoints_prop_value['$$type'] ) || 'interaction-breakpoints' !== $breakpoints_prop_value['$$type'] ) {
return false;
}
if ( ! isset( $breakpoints_prop_value['value'] ) || ! is_array( $breakpoints_prop_value['value'] ) ) {
return false;
}
return self::validate_value( $breakpoints_prop_value['value'] );
}
private static function validate_value( $value ) {
if ( ! is_array( $value ) ) {
return false;
}
if ( ! isset( $value['excluded'] ) || ! is_array( $value['excluded'] ) ) {
return false;
}
return self::validate_excluded( $value['excluded'] );
}
private static function validate_excluded( $excluded ) {
if ( ! is_array( $excluded ) ) {
return false;
}
if ( ! isset( $excluded['$$type'] ) || 'excluded-breakpoints' !== $excluded['$$type'] ) {
return false;
}
if ( ! isset( $excluded['value'] ) || ! is_array( $excluded['value'] ) ) {
return false;
}
return self::validate_excluded_value( $excluded['value'] );
}
private static function validate_excluded_value( $value ) {
if ( ! is_array( $value ) ) {
return false;
}
foreach ( $value as $breakpoint_value ) {
if ( ! StringValueValidator::is_valid( $breakpoint_value ) ) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Elementor\Modules\Interactions\Validators;
use Elementor\Modules\AtomicWidgets\Parsers\Props_Parser;
use Elementor\Modules\Interactions\Props\Custom_Effect_Prop_Type;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* TODO: At least a value validator interface to enforce is_valid fxn for consistency
*/
class Custom_Effect_Value {
public static function is_valid( array $animation_value ): bool {
$effect_value = $animation_value['effect']['value'] ?? null;
if ( 'custom' !== $effect_value ) {
return true;
}
if ( ! isset( $animation_value['custom_effect'] ) ) {
return false;
}
$props_parser = Props_Parser::make( [
'custom_effect' => Custom_Effect_Prop_Type::make(),
] );
$result = $props_parser->parse( [ 'custom_effect' => $animation_value['custom_effect'] ] );
return $result->is_valid();
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Elementor\Modules\Interactions\Validators;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class String_Value {
public static function is_valid( $prop_value, $allowed_values = null ) {
if ( ! is_array( $prop_value ) ) {
return false;
}
if ( ! isset( $prop_value['$$type'] ) || 'string' !== $prop_value['$$type'] ) {
return false;
}
if ( ! isset( $prop_value['value'] ) || ! is_string( $prop_value['value'] ) ) {
return false;
}
if ( null !== $allowed_values && ! in_array( $prop_value['value'], $allowed_values, true ) ) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Elementor\Modules\Interactions\Validators;
use Elementor\Modules\Interactions\Validators\String_Value as StringValueValidator;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class Trigger_Value {
private const VALID_TRIGGERS = [
'load',
'scrollIn',
'scrollOut',
'scrollOn',
'hover',
'click',
];
public static function is_valid( $trigger_prop_value ) {
return StringValueValidator::is_valid( $trigger_prop_value, static::VALID_TRIGGERS );
}
}