data attributes on the element root. * Mirrors src/frontend/lib/motion-config.ts exactly (same keys, same order, defaults omitted), so the live page and * the editor canvas carry identical markup. The runtime is the `src/frontend/motion.ts` entry, enqueued by * Frontend only when an element rendered with these attributes (Renderer::uses_feature('motion')). */ final class MotionAttrs { public const DEVICES = ['desktop', 'tablet', 'mobile']; public const EFFECTS = ['y', 'x', 'opacity', 'blur', 'rotate', 'scale']; private const DIRS = [ 'y' => ['up', 'down'], 'x' => ['left', 'right'], 'opacity' => ['in', 'out', 'in-out', 'out-in'], 'blur' => ['in', 'out', 'in-out', 'out-in'], 'rotate' => ['right', 'left'], 'scale' => ['up', 'down', 'up-down', 'down-up'], ]; /** [settings suffix, default, max] of the speed / level field. */ private const AMOUNT = [ 'y' => ['speed', 4, 10], 'x' => ['speed', 4, 10], 'opacity' => ['level', 10, 10], 'blur' => ['level', 7, 15], 'rotate' => ['speed', 1, 10], 'scale' => ['speed', 4, 10], ]; private const RANGE = [ 'y' => [0, 100], 'x' => [0, 100], 'opacity' => [20, 80], 'blur' => [20, 80], 'rotate' => [0, 100], 'scale' => [0, 100], ]; /** @return array|null */ public static function config(array $s): ?array { $out = []; if (self::on($s['_scroll_fx'] ?? false)) { $scroll = []; foreach (self::EFFECTS as $key) { if (!self::on($s['_fx_' . $key] ?? false)) { continue; } [$suffix, $def, $max] = self::AMOUNT[$key]; $scroll[$key] = [ 'dir' => self::pick($s['_fx_' . $key . '_dir'] ?? null, self::DIRS[$key]), 'speed' => self::num($s['_fx_' . $key . '_' . $suffix] ?? null, $def, 0, $max), 'start' => self::num($s['_fx_' . $key . '_start'] ?? null, self::RANGE[$key][0], 0, 100), 'end' => self::num($s['_fx_' . $key . '_end'] ?? null, self::RANGE[$key][1], 0, 100), ]; } if ($scroll) { $out['scroll'] = $scroll; $devices = self::devices($s['_fx_devices'] ?? null); if (count($devices) < count(self::DEVICES)) { $out['devices'] = $devices; } } } if (self::on($s['_mouse_fx'] ?? false)) { $mouse = []; if (self::on($s['_mouse_track'] ?? false)) { $mouse['track'] = ['dir' => self::pick($s['_mouse_track_dir'] ?? null, ['opposite', 'direct']), 'speed' => self::num($s['_mouse_track_speed'] ?? null, 1, 0, 10)]; } if (self::on($s['_mouse_tilt'] ?? false)) { $mouse['tilt'] = [ 'dir' => self::pick($s['_mouse_tilt_dir'] ?? null, ['direct', 'opposite']), 'max' => self::num($s['_mouse_tilt_max'] ?? null, 10, 0, 45), 'perspective' => self::num($s['_mouse_tilt_perspective'] ?? null, 1000, 100, 5000), ]; if (self::on($s['_mouse_tilt_glare'] ?? false)) { $mouse['tilt']['glare'] = true; } } if ($mouse) { $out['mouse'] = $mouse; } } // Scroll-linked keyframes (T212), mirrors keyframesConfig() in src/frontend/lib/scroll-keyframes.ts. $kf = self::keyframes($s['_scroll_kf'] ?? null); if ($kf !== null) { $out['kf'] = $kf; } return $out ? $out : null; } public const KF_PROPS = ['x', 'y', 'scale', 'rotate', 'tiltX', 'tiltY', 'opacity', 'blur']; private const KF_RANGE = [ 'x' => [-2000, 2000], 'y' => [-2000, 2000], 'scale' => [0, 10], 'rotate' => [-3600, 3600], 'tiltX' => [-180, 180], 'tiltY' => [-180, 180], 'opacity' => [0, 1], 'blur' => [0, 100], ]; private const KF_EASINGS = ['ease', 'ease-in', 'ease-out', 'ease-in-out']; public const MAX_KEYFRAMES = 20; /** * `_scroll_kf` = {enabled, anchor, start, end, devices, keyframes: [{at, x, y, scale, rotate, tiltX, tiltY, * opacity, blur, easing}]} -> {start, end, anchor?, devices?, frames} (null when off or < 2 keyframes). * * @return array|null */ public static function keyframes($value): ?array { if (!is_array($value) || array_is_list($value) && $value !== [] || !self::on($value['enabled'] ?? false)) { return null; } $by_at = []; $raw = is_array($value['keyframes'] ?? null) ? array_slice(array_values($value['keyframes']), 0, self::MAX_KEYFRAMES * 2) : []; foreach ($raw as $k) { if (!is_array($k)) { continue; } $at = self::knum($k['at'] ?? null, 0, 100, 1); if ($at === null) { continue; } $frame = ['at' => $at]; foreach (self::KF_PROPS as $p) { [$min, $max] = self::KF_RANGE[$p]; $n = self::knum($k[$p] ?? null, $min, $max, in_array($p, ['opacity', 'scale'], true) ? 3 : 2); if ($n !== null) { $frame[$p] = $n; } } if (isset($k['easing']) && is_string($k['easing']) && in_array($k['easing'], self::KF_EASINGS, true)) { $frame['easing'] = $k['easing']; } $key = (string) $at; unset($by_at[$key]); $by_at[$key] = $frame; } $frames = array_values($by_at); usort($frames, static fn(array $a, array $b): int => $a['at'] <=> $b['at']); $frames = array_slice($frames, 0, self::MAX_KEYFRAMES); if (count($frames) < 2) { return null; } $out = [ 'start' => self::knum($value['start'] ?? null, 0, 100, 2) ?? 0, 'end' => self::knum($value['end'] ?? null, 0, 100, 2) ?? 100, ]; $anchor = isset($value['anchor']) && in_array($value['anchor'], ['self', 'parent', 'page'], true) ? $value['anchor'] : 'self'; if ($anchor !== 'self') { $out['anchor'] = $anchor; } if (isset($value['devices']) && is_array($value['devices'])) { $devices = self::devices($value['devices']); if (count($devices) < count(self::DEVICES)) { $out['devices'] = $devices; } } $out['frames'] = $frames; return $out; } /** Number rounded to `$digits` (null when not numeric); whole numbers become ints (JSON `4`, not `4.0`). */ private static function knum($v, $min, $max, int $digits) { 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 null; } $n = round(min($max, max($min, (float) $v)), $digits); if ($n == 0.0) { return 0; } return floor($n) === $n ? (int) $n : $n; } /** * @param array $s * @return array */ public static function attrs(array $s): array { $attrs = []; $config = self::config($s); if ($config !== null) { $attrs['data-zx-motion'] = (string) wp_json_encode($config, JSON_UNESCAPED_SLASHES); } $sticky = isset($s['_sticky']) && in_array($s['_sticky'], ['top', 'bottom'], true) ? $s['_sticky'] : ''; if ($sticky !== '') { $attrs['data-zx-sticky'] = $sticky; if (self::on($s['_sticky_parent'] ?? false)) { $attrs['data-zx-sticky-parent'] = ''; } $fx = self::num($s['_sticky_effects_offset'] ?? null, 0, 0, 100000); if ($fx > 0) { $attrs['data-zx-sticky-fx'] = (string) (int) round($fx); } $devices = self::devices($s['_sticky_devices'] ?? null); if (count($devices) < count(self::DEVICES)) { $attrs['data-zx-sticky-devices'] = implode(' ', $devices); } } return $attrs; } /** @return string[] */ public static function devices($value): array { if (!is_array($value)) { return self::DEVICES; } return array_values(array_filter(self::DEVICES, static fn(string $d): bool => in_array($d, $value, true))); } private static function on($v): bool { return $v === true || $v === 1 || $v === '1' || $v === 'true'; } private static function pick($v, array $list): string { return is_string($v) && in_array($v, $list, true) ? $v : $list[0]; } /** Rounded to 2 decimals; whole numbers are ints so the JSON matches JavaScript's (`4`, not `4.0`). */ private static function num($v, $def, $min, $max) { if (is_string($v) && trim($v) !== '' && is_numeric(trim($v))) { $v = (float) trim($v); } if (!is_int($v) && !is_float($v)) { return $def; } if (is_float($v) && !is_finite($v)) { return $def; } $n = round(min($max, max($min, (float) $v)), 2); return floor($n) === $n ? (int) $n : $n; } }