`data-zx-ix` attribute on the element root. * * Mirrors src/frontend/lib/interactions-config.ts exactly (same normalisation, same key order), so the live page * and the editor canvas carry identical markup. The runtime is the lazy `src/frontend/interactions.ts` entry, * enqueued on wp_footer only when a rendered element got the attribute. * * Also sanitizes the stored values of the `interactions` and `scrollKeyframes` control types (Sanitizer). */ final class Interactions { public const ENTRY = 'src/frontend/interactions.ts'; public const TRIGGERS = ['load', 'scroll', 'hover', 'leave', 'click']; public const ACTION_TYPES = ['animate', 'toggleClass', 'addClass', 'removeClass', 'show', 'hide', 'toggle', 'scrollTo', 'openPopup', 'closePopup']; public const TARGETS = ['self', 'element', 'selector']; public const PRESETS = ['fadeIn', 'fadeOut', 'slideInUp', 'slideInDown', 'slideInLeft', 'slideInRight', 'zoomIn', 'zoomOut', 'pulse', 'shake', 'bounce', 'spin', 'flash', 'custom']; public const EASINGS = ['linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out']; public const PROP_KEYS = ['opacity', 'x', 'y', 'scale', 'rotate', 'blur', 'bg']; private const PROP_RANGE = [ 'opacity' => [0, 1], 'x' => [-5000, 5000], 'y' => [-5000, 5000], 'scale' => [0, 20], 'rotate' => [-3600, 3600], 'blur' => [0, 200], ]; public const MAX_INTERACTIONS = 20; public const MAX_ACTIONS = 20; public const MAX_MS = 60000; public const DEFAULT_DURATION = 600; public const DEFAULT_THRESHOLD = 20; private ?Assets $assets; private bool $used = false; public function __construct(?Assets $assets = null) { $this->assets = $assets; } public function register(): void { add_filter('zadex/render/attrs', [$this, 'filter_attrs'], 10, 3); add_action('wp_footer', [$this, 'enqueue'], 5); } /** * @param array $attrs * @param array $element * @param array $settings * @return array */ public function filter_attrs($attrs, $element = [], $settings = []) { if (!is_array($attrs) || !is_array($settings)) { return $attrs; } $ix = self::attrs($settings); if ($ix) { $this->used = true; $attrs = array_merge($attrs, $ix); } return $attrs; } public function used(): bool { return $this->used; } /** Loads the runtime when an element used it (never in the editor preview: the canvas drives it itself). */ public function enqueue(): void { $is_preview = \Zadex\Editor\EditorLoader::is_preview(); if (!$this->used || $is_preview || $this->assets === null) { return; } $this->assets->enqueue_entry(self::ENTRY, 'zadex-interactions'); } /** @return array */ public static function attrs(array $settings): array { $list = self::normalize($settings['_interactions'] ?? null); if (!$list) { return []; } return ['data-zx-ix' => (string) wp_json_encode($list, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)]; } /** @return array> */ public static function normalize($value): array { if (!is_array($value) || !array_is_list($value)) { return []; } $out = []; foreach (array_slice($value, 0, self::MAX_INTERACTIONS) as $raw) { if (!is_array($raw)) { continue; } $actions = []; foreach (array_slice(is_array($raw['actions'] ?? null) ? array_values($raw['actions']) : [], 0, self::MAX_ACTIONS) as $a) { $clean = self::normalize_action($a); if ($clean !== null) { $actions[] = $clean; } } if (!$actions) { continue; } $trigger = self::pick($raw['trigger'] ?? null, self::TRIGGERS, 'click'); $ix = ['trigger' => $trigger]; if ($trigger === 'scroll') { $ix['threshold'] = (int) round((float) self::num($raw['threshold'] ?? null, self::DEFAULT_THRESHOLD, 0, 100)); } if (self::on($raw['once'] ?? false)) { $ix['once'] = true; } $ix['actions'] = $actions; $out[] = $ix; } return $out; } /** @return array|null */ public static function normalize_action($a): ?array { if (!is_array($a)) { return null; } $type = self::pick($a['type'] ?? null, self::ACTION_TYPES, 'animate'); $target = self::pick($a['target'] ?? null, self::TARGETS, 'self'); $popup_type = $type === 'openPopup' || $type === 'closePopup'; $out = ['target' => 'self']; if (!$popup_type) { if ($target === 'element') { $id = self::clean_element_id($a['targetId'] ?? ($a['id'] ?? null)); if ($id === '') { $target = 'self'; } else { $out['id'] = $id; } } elseif ($target === 'selector') { $sel = self::clean_selector($a['selector'] ?? ($a['sel'] ?? null)); if ($sel === '') { $target = 'self'; } else { $out['sel'] = $sel; } } $out['target'] = $target; } $out['type'] = $type; if ($type === 'animate') { $preset = self::pick($a['preset'] ?? null, self::PRESETS, 'fadeIn'); $out['preset'] = $preset; if ($preset === 'custom') { $from = self::clean_props($a['from'] ?? null); $to = self::clean_props($a['to'] ?? null); if ($from === null && $to === null) { return null; } if ($from !== null) { $out['from'] = $from; } if ($to !== null) { $out['to'] = $to; } } } elseif (in_array($type, ['toggleClass', 'addClass', 'removeClass'], true)) { $cls = self::clean_classes($a['className'] ?? ($a['cls'] ?? null)); if ($cls === '') { return null; } $out['cls'] = $cls; } elseif ($popup_type) { $raw = $a['popupId'] ?? ($a['popup'] ?? null); $popup = is_string($raw) || is_int($raw) || is_float($raw) ? (preg_replace('/[^0-9]/', '', (string) $raw) ?? '') : ''; if ($type === 'openPopup' && $popup === '') { return null; } if ($popup !== '') { $out['popup'] = $popup; } } $animated = in_array($type, ['animate', 'show', 'hide', 'toggle'], true); $out['delay'] = (int) round((float) self::num($a['delay'] ?? null, 0, 0, self::MAX_MS)); $out['duration'] = $animated ? (int) round((float) self::num($a['duration'] ?? null, self::DEFAULT_DURATION, 0, self::MAX_MS)) : 0; $out['easing'] = $animated ? self::clean_easing($a['easing'] ?? null) : 'ease'; return $out; } /* ---- stored value sanitizers (Sanitizer, control types `interactions` / `scrollKeyframes`) ---- */ /** * Keeps the editor shape (ids, field names, half-configured actions) but every field cleaned. * * @return array> */ public static function sanitize($value): array { if (!is_array($value)) { return []; } $out = []; foreach (array_slice(array_values($value), 0, self::MAX_INTERACTIONS) as $raw) { if (!is_array($raw)) { continue; } $ix = [ '_id' => self::clean_uid($raw['_id'] ?? ''), 'trigger' => self::pick($raw['trigger'] ?? null, self::TRIGGERS, 'click'), 'threshold' => (int) round((float) self::num($raw['threshold'] ?? null, self::DEFAULT_THRESHOLD, 0, 100)), 'once' => self::on($raw['once'] ?? false), 'actions' => [], ]; foreach (array_slice(is_array($raw['actions'] ?? null) ? array_values($raw['actions']) : [], 0, self::MAX_ACTIONS) as $a) { if (!is_array($a)) { continue; } $action = [ '_id' => self::clean_uid($a['_id'] ?? ''), 'target' => self::pick($a['target'] ?? null, self::TARGETS, 'self'), 'targetId' => self::clean_element_id($a['targetId'] ?? null), 'selector' => self::clean_selector($a['selector'] ?? null), 'type' => self::pick($a['type'] ?? null, self::ACTION_TYPES, 'animate'), 'preset' => self::pick($a['preset'] ?? null, self::PRESETS, 'fadeIn'), 'className' => self::clean_classes($a['className'] ?? null), 'popupId' => isset($a['popupId']) && is_scalar($a['popupId']) ? (preg_replace('/[^0-9]/', '', (string) $a['popupId']) ?? '') : '', 'delay' => (int) round((float) self::num($a['delay'] ?? null, 0, 0, self::MAX_MS)), 'duration' => (int) round((float) self::num($a['duration'] ?? null, self::DEFAULT_DURATION, 0, self::MAX_MS)), 'easing' => self::clean_easing($a['easing'] ?? null), ]; foreach (['from', 'to'] as $side) { $props = self::clean_props($a[$side] ?? null); if ($props !== null) { $action[$side] = $props; } } $ix['actions'][] = $action; } $out[] = $ix; } return $out; } /** `scrollKeyframes` control value (T212), editor shape kept. */ public static function sanitize_keyframes($value): array { if (!is_array($value)) { return []; } $out = [ 'enabled' => self::on($value['enabled'] ?? false), 'anchor' => in_array($value['anchor'] ?? '', ['self', 'parent', 'page'], true) ? $value['anchor'] : 'self', 'start' => self::num($value['start'] ?? null, 0, 0, 100), 'end' => self::num($value['end'] ?? null, 100, 0, 100), ]; if (isset($value['devices']) && is_array($value['devices'])) { $out['devices'] = MotionAttrs::devices($value['devices']); } $frames = []; foreach (array_slice(is_array($value['keyframes'] ?? null) ? array_values($value['keyframes']) : [], 0, MotionAttrs::MAX_KEYFRAMES * 2) as $k) { if (!is_array($k)) { continue; } $frame = ['_id' => self::clean_uid($k['_id'] ?? ''), 'at' => self::num($k['at'] ?? null, 0, 0, 100)]; foreach (MotionAttrs::KF_PROPS as $p) { if (isset($k[$p]) && $k[$p] !== '' && is_numeric($k[$p])) { $frame[$p] = $k[$p] + 0; } } if (isset($k['easing']) && in_array($k['easing'], self::EASINGS, true)) { $frame['easing'] = $k['easing']; } $frames[] = $frame; } $out['keyframes'] = $frames; return $out; } /* ---- field cleaners (same rules as the TypeScript twin) ---- */ public static function clean_element_id($v): string { return is_string($v) && preg_match('/^[a-z][a-z0-9]{2,15}$/', $v) ? $v : ''; } public static function clean_classes($v): string { if (!is_string($v)) { return ''; } $out = []; foreach (preg_split('/\s+/', $v) ?: [] as $c) { $c = preg_replace('/[^A-Za-z0-9_-]/', '', $c) ?? ''; if ($c !== '' && preg_match('/^[A-Za-z_-]/', $c)) { $out[] = $c; } if (count($out) >= 10) { break; } } return implode(' ', $out); } public static function clean_selector($v): string { if (!is_string($v)) { return ''; } $v = trim(preg_replace('/[<{};\\\\\x00-\x1f\x7f]/', '', $v) ?? ''); return function_exists('mb_substr') ? mb_substr($v, 0, 200) : substr($v, 0, 200); } public static function clean_easing($v): string { if (!is_string($v)) { return 'ease'; } $s = strtolower(trim($v)); if (in_array($s, self::EASINGS, true)) { return $s; } if (preg_match('/^cubic-bezier\(\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*\)$/', $s, $m)) { [$a, $b, $c, $d] = array_map('floatval', array_slice($m, 1)); if ($a >= 0 && $a <= 1 && $c >= 0 && $c <= 1 && abs($b) <= 10 && abs($d) <= 10) { return 'cubic-bezier(' . self::js_number($a) . ', ' . self::js_number($b) . ', ' . self::js_number($c) . ', ' . self::js_number($d) . ')'; } } return 'ease'; } public static function clean_color($v): string { if (!is_string($v)) { return ''; } $s = trim($v); if (preg_match('/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i', $s) || preg_match('/^(rgba?|hsla?|oklch|oklab|color|color-mix)\([\w\s%.,\/\-#()]+\)$/i', $s) || preg_match('/^var\(--[a-z0-9-]+\)$/i', $s) || preg_match('/^[a-z]+$/i', $s)) { return $s; } return ''; } /** @return array|null */ private static function clean_props($v): ?array { if (!is_array($v) || (array_is_list($v) && $v !== [])) { return null; } $out = []; foreach (self::PROP_KEYS as $key) { if (!array_key_exists($key, $v) || $v[$key] === null || $v[$key] === '') { continue; } if ($key === 'bg') { $c = self::clean_color($v[$key]); if ($c !== '') { $out['bg'] = $c; } continue; } [$min, $max] = self::PROP_RANGE[$key]; $n = self::num($v[$key], null, $min, $max, in_array($key, ['opacity', 'scale'], true) ? 3 : 2); if ($n !== null) { $out[$key] = $n; } } return $out ? $out : null; } private static function clean_uid($v): string { return is_string($v) ? substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $v) ?? '', 0, 32) : ''; } private static function on($v): bool { return $v === true || $v === 1 || $v === '1' || $v === 'true'; } private static function pick($v, array $list, string $def): string { return is_string($v) && in_array($v, $list, true) ? $v : $def; } /** Clamped + rounded number (whole numbers as int, like JSON from JavaScript); `$def` when not numeric. */ private static function num($v, $def, $min, $max, int $digits = 2) { if (is_string($v) && trim($v) !== '' && is_numeric(trim($v))) { $v = (float) trim($v); } if (is_bool($v) || (!is_int($v) && !is_float($v)) || (is_float($v) && !is_finite($v))) { return $def; } $n = round(min($max, max($min, (float) $v)), $digits); if ($n == 0.0) { return 0; } return floor($n) === $n ? (int) $n : $n; } /** Number as JavaScript's String(n) prints it (no trailing `.0`). */ private static function js_number(float $n): string { if (floor($n) === $n && abs($n) < 1e15) { return (string) (int) $n; } return rtrim(rtrim(sprintf('%.10F', $n), '0'), '.'); } }