| 1 |
<?php |
| 2 |
namespace ABlocks\Classes; |
| 3 |
|
| 4 |
if ( ! defined( 'ABSPATH' ) ) { |
| 5 |
exit; |
| 6 |
} |
| 7 |
|
| 8 |
use ABlocks\Controls\Typography; |
| 9 |
use ABlocks\Controls\Color; |
| 10 |
use ABlocks\Controls\Dimensions; |
| 11 |
use ABlocks\Controls\Alignment; |
| 12 |
use ABlocks\Helper; |
| 13 |
|
| 14 |
/** |
| 15 |
* Shared "bucket -> CSS" transformer for the atomic style system, so a block's |
| 16 |
* local class and a reusable global class compile identically. |
| 17 |
* |
| 18 |
* A bucket is one set of style props for a given (breakpoint x interaction |
| 19 |
* state). Where the bucket lives is StyleBuckets' concern; which props exist |
| 20 |
* and what CSS they become is StylesSchema's. This class only turns one |
| 21 |
* bucket's props into declarations, and walks the tree to build rules. |
| 22 |
*/ |
| 23 |
class AtomicStyles { |
| 24 |
|
| 25 |
/** |
| 26 |
* Revision of the CSS this compiler emits. Bump it whenever the emitted |
| 27 |
* output changes for the same stored styles. |
| 28 |
* |
| 29 |
* Front-end pages do not compile per request: each page is baked once |
| 30 |
* into an uploads stylesheet, and Assets::build_revision() decides when |
| 31 |
* that file is stale. It was keyed on ABLOCKS_VERSION alone, so an |
| 32 |
* emission change with no version bump never reached pages baked before |
| 33 |
* it. The centring-margin fix (emitted_value() / flex_child_css()) showed |
| 34 |
* up in the editor, which compiles live, while every existing page kept |
| 35 |
* serving the old `margin-left:auto` and its wide flex-row gap. |
| 36 |
* |
| 37 |
* 2: centring margins resolved per parent layout. |
| 38 |
* 3: a bucket that sets a border width/colour but no type keeps the type it |
| 39 |
* inherits instead of `solid`. |
| 40 |
*/ |
| 41 |
const OUTPUT_REVISION = 3; |
| 42 |
|
| 43 |
/** |
| 44 |
* Compile a full bucket tree for one selector base into CSS. |
| 45 |
* |
| 46 |
* The stored state key IS the pseudo-selector, so it is appended directly; |
| 47 |
* the breakpoint comes from the bucket's device via the one media-query |
| 48 |
* builder. Buckets emit widest-first, which is what makes a narrower |
| 49 |
* breakpoint win in cascade mode. |
| 50 |
*/ |
| 51 |
public static function compile_variants( $selector_base, $styles ) { |
| 52 |
return self::rules_to_css( self::compile_rules( $styles ), $selector_base ); |
| 53 |
} |
| 54 |
|
| 55 |
/** |
| 56 |
* Compile a bucket tree into a normalised rule list: |
| 57 |
* |
| 58 |
* [ [ media-query, state-selector, [ [ prop, value ], … ] ], … ] |
| 59 |
* |
| 60 |
* Values are cast to strings and the structure is a plain list, so the JSON |
| 61 |
* encoding is byte-identical to the JS mirror's `JSON.stringify` — that is |
| 62 |
* what makes the style hash reproducible across the editor and the front end. |
| 63 |
* |
| 64 |
* `$alignment` (the block's own alignment attribute, which lives outside the |
| 65 |
* bucket tree and is still device-suffixed) folds into each device's normal |
| 66 |
* state. It has to participate in the hash: two blocks with identical styles |
| 67 |
* but different alignment are not interchangeable. |
| 68 |
*/ |
| 69 |
public static function compile_rules( $styles, $alignment = [] ) { |
| 70 |
$rules = []; |
| 71 |
$devices = Helper::get_responsive_devices(); |
| 72 |
$has_styles = StyleBuckets::has_schema_version( $styles ); |
| 73 |
|
| 74 |
$devices = array_values( $devices ); |
| 75 |
|
| 76 |
foreach ( $devices as $index => $device ) { |
| 77 |
$media = Helper::breakpoint_media_query( $device ); |
| 78 |
$bucket_key = StyleBuckets::device_bucket_key( $device ); |
| 79 |
$cascade = self::cascade_devices( $devices, $index ); |
| 80 |
|
| 81 |
foreach ( StyleBuckets::state_keys() as $state ) { |
| 82 |
$declarations = []; |
| 83 |
|
| 84 |
if ( $has_styles ) { |
| 85 |
$props = StyleBuckets::read_bucket( $styles, $bucket_key, $state ); |
| 86 |
if ( ! empty( $props ) ) { |
| 87 |
// The border type in force from the rest of the cascade, |
| 88 |
// so a bucket that only changes width/colour keeps it |
| 89 |
// rather than falling back to `solid`. |
| 90 |
$inherited_style = StyleBuckets::inherited_prop( $styles, $bucket_key, $state, 'borderStyle', $cascade ); |
| 91 |
$declarations = self::apply_background_reset( |
| 92 |
self::state_declarations( $props, is_scalar( $inherited_style ) ? (string) $inherited_style : '' ), |
| 93 |
'' === $state && '' === $bucket_key |
| 94 |
); |
| 95 |
} |
| 96 |
} |
| 97 |
|
| 98 |
// Alignment applies to the normal state, last so it beats a |
| 99 |
// `textAlign` style prop set at the same level. |
| 100 |
if ( '' === $state && ! empty( $alignment ) ) { |
| 101 |
$declarations = array_merge( |
| 102 |
$declarations, |
| 103 |
Alignment::get_css( $alignment, 'text-align', $device['suffix'] ) |
| 104 |
); |
| 105 |
} |
| 106 |
|
| 107 |
if ( empty( $declarations ) ) { |
| 108 |
continue; |
| 109 |
} |
| 110 |
|
| 111 |
$pairs = []; |
| 112 |
foreach ( $declarations as $property => $value ) { |
| 113 |
if ( '' === $value || null === $value ) { |
| 114 |
continue; |
| 115 |
} |
| 116 |
$pairs[] = [ (string) $property, (string) $value ]; |
| 117 |
} |
| 118 |
|
| 119 |
if ( ! empty( $pairs ) ) { |
| 120 |
$rules[] = [ $media, $state, $pairs ]; |
| 121 |
} |
| 122 |
} |
| 123 |
} |
| 124 |
|
| 125 |
return $rules; |
| 126 |
} |
| 127 |
|
| 128 |
/** |
| 129 |
* The devices a device's rules cascade from on the page: those that also |
| 130 |
* apply at its width and are emitted before it, widest-first, ending at the |
| 131 |
* device itself. Mirror of the JS `cascadeDevices()` (atomic-shared/hash.js), |
| 132 |
* which builds it from `devicesApplyingAt( representativeWidth() )`. |
| 133 |
* |
| 134 |
* @param array $devices Ordered device list (widest-first, base first). |
| 135 |
* @param int $index The device's position in it. |
| 136 |
* @return array Devices to inherit from. |
| 137 |
*/ |
| 138 |
private static function cascade_devices( $devices, $index ) { |
| 139 |
$own = (array) $devices[ $index ]; |
| 140 |
// A device's representative width: its upper bound, else its lower |
| 141 |
// bound, else "very wide" for the base device. |
| 142 |
$width = ! empty( $own['max'] ) ? (int) $own['max'] : ( ! empty( $own['min'] ) ? (int) $own['min'] : 99999 ); |
| 143 |
|
| 144 |
$out = []; |
| 145 |
foreach ( array_slice( $devices, 0, $index + 1 ) as $device ) { |
| 146 |
list( $min, $max ) = Helper::breakpoint_bounds( $device ); |
| 147 |
if ( ( $min > 0 && $width < $min ) || ( $max > 0 && $width > $max ) ) { |
| 148 |
continue; |
| 149 |
} |
| 150 |
$out[] = $device; |
| 151 |
} |
| 152 |
return $out; |
| 153 |
} |
| 154 |
|
| 155 |
/** |
| 156 |
* FNV-1a 32-bit over the normalised rule list. |
| 157 |
* |
| 158 |
* Deliberately not md5: the editor has to compute the identical hash at save |
| 159 |
* time, and FNV-1a is a handful of lines in both languages rather than a |
| 160 |
* crypto dependency in the editor bundle. |
| 161 |
*/ |
| 162 |
public static function style_hash( $rules ) { |
| 163 |
$json = wp_json_encode( $rules, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); |
| 164 |
$hash = 2166136261; |
| 165 |
$len = strlen( $json ); |
| 166 |
|
| 167 |
for ( $i = 0; $i < $len; $i++ ) { |
| 168 |
$hash ^= ord( $json[ $i ] ); |
| 169 |
$hash = ( $hash * 16777619 ) & 0xFFFFFFFF; |
| 170 |
} |
| 171 |
|
| 172 |
return str_pad( dechex( $hash ), 8, '0', STR_PAD_LEFT ); |
| 173 |
} |
| 174 |
|
| 175 |
/** The shared style class for a bucket tree, or '' when it compiles to nothing. */ |
| 176 |
public static function style_class( $styles, $alignment = [] ) { |
| 177 |
$rules = self::compile_rules( $styles, $alignment ); |
| 178 |
return empty( $rules ) ? '' : 'ablocks-s-' . self::style_hash( $rules ); |
| 179 |
} |
| 180 |
|
| 181 |
/** Hashes already emitted this request, so each rule set is written once. */ |
| 182 |
private static $emitted = []; |
| 183 |
|
| 184 |
/** Forget what has been emitted (test/CLI helper). */ |
| 185 |
public static function reset_emitted() { |
| 186 |
self::$emitted = []; |
| 187 |
} |
| 188 |
|
| 189 |
/** |
| 190 |
* Register a block's compiled styles and return its shared class plus the |
| 191 |
* CSS that still needs emitting — empty on every block after the first with |
| 192 |
* the same rule set, which is where the duplicate-CSS reduction comes from. |
| 193 |
* |
| 194 |
* The class is repeated in the selector (0-2-0) so a block's own styles beat |
| 195 |
* an applied global class (0-1-0) without resorting to `!important`, which |
| 196 |
* would make global classes unable to override anything. |
| 197 |
*/ |
| 198 |
public static function register_styles( $styles, $alignment = [] ) { |
| 199 |
$rules = self::compile_rules( $styles, $alignment ); |
| 200 |
if ( empty( $rules ) ) { |
| 201 |
return [ 'class' => '', 'css' => '' ]; |
| 202 |
} |
| 203 |
|
| 204 |
$hash = self::style_hash( $rules ); |
| 205 |
$class = 'ablocks-s-' . $hash; |
| 206 |
|
| 207 |
if ( isset( self::$emitted[ $hash ] ) ) { |
| 208 |
return [ 'class' => $class, 'css' => '' ]; |
| 209 |
} |
| 210 |
self::$emitted[ $hash ] = true; |
| 211 |
|
| 212 |
return [ 'class' => $class, 'css' => self::rules_to_css( $rules, '.' . $class . '.' . $class ) ]; |
| 213 |
} |
| 214 |
|
| 215 |
/** |
| 216 |
* Point a block's saved markup at its current style class. |
| 217 |
* |
| 218 |
* The editor stamps `ablocks-s-{hash}` into the markup at save time, but the |
| 219 |
* front end emits rules for the hash of what the compiler produces NOW. When |
| 220 |
* the compiled output changes for the same stored styles (OUTPUT_REVISION), |
| 221 |
* a post saved before carries a class no rule targets any more and the |
| 222 |
* block renders unstyled until someone re-saves it. Only the block's own |
| 223 |
* opening tag is touched — inner blocks are rendered, and fixed, on their |
| 224 |
* own — and only when it lacks the current class. |
| 225 |
* |
| 226 |
* @param string $content The block's saved markup. |
| 227 |
* @param array $styles The block's styles attribute. |
| 228 |
* @param array $alignment The block's alignment attribute. |
| 229 |
* @return string The markup, with a stale style class replaced. |
| 230 |
*/ |
| 231 |
public static function refresh_style_class( $content, $styles, $alignment = [] ) { |
| 232 |
if ( ! is_string( $content ) || false === strpos( $content, 'ablocks-s-' ) ) { |
| 233 |
return $content; |
| 234 |
} |
| 235 |
if ( ! preg_match( '/^\s*<[a-zA-Z][^>]*>/', $content, $tag ) ) { |
| 236 |
return $content; |
| 237 |
} |
| 238 |
$open = $tag[0]; |
| 239 |
if ( ! preg_match( '/(?<=[\s"\'])ablocks-s-[0-9a-z]+(?=[\s"\'])/', $open, $stale ) ) { |
| 240 |
return $content; |
| 241 |
} |
| 242 |
|
| 243 |
$current = self::style_class( $styles, $alignment ); |
| 244 |
if ( '' === $current || preg_match( '/(?<=[\s"\'])' . preg_quote( $current, '/' ) . '(?=[\s"\'])/', $open ) ) { |
| 245 |
return $content; |
| 246 |
} |
| 247 |
|
| 248 |
$fixed = preg_replace( '/(?<=[\s"\'])' . preg_quote( $stale[0], '/' ) . '(?=[\s"\'])/', $current, $open, 1 ); |
| 249 |
return substr_replace( $content, $fixed, strpos( $content, $open ), strlen( $open ) ); |
| 250 |
} |
| 251 |
|
| 252 |
/** Render a normalised rule list against a selector base. */ |
| 253 |
public static function rules_to_css( $rules, $selector_base ) { |
| 254 |
$css = ''; |
| 255 |
foreach ( $rules as $rule ) { |
| 256 |
list( $media, $state, $pairs ) = $rule; |
| 257 |
$declarations = ''; |
| 258 |
foreach ( $pairs as $pair ) { |
| 259 |
// Escaped here rather than at the schema, so every kind of prop |
| 260 |
// — scalar, range, colour, typography, effect, overlay — passes |
| 261 |
// through one guard on its way out. This runs after style_hash() |
| 262 |
// has already read $rules, so the hash the editor writes into |
| 263 |
// the markup is unaffected. |
| 264 |
$declarations .= Helper::esc_css_value( $pair[0] ) . ':' . Helper::esc_css_value( self::emitted_value( $pair[0], $pair[1] ) ) . ';'; |
| 265 |
} |
| 266 |
$body = $selector_base . $state . '{' . $declarations . '}'; |
| 267 |
|
| 268 |
// A wrapping container's own children must size from their content, |
| 269 |
// or the line can never be over-subscribed and `flex-wrap` never |
| 270 |
// breaks one. That is a statement about THIS container's children, |
| 271 |
// so it is emitted as a child rule here rather than as an inherited |
| 272 |
// custom property: a custom property inherits down the whole tree, |
| 273 |
// so a wrapping container silently re-sized the children of every |
| 274 |
// non-wrapping container nested inside it — measured, a |
| 275 |
// non-wrapping inner container's children came out `flex-basis: |
| 276 |
// auto` (content-sized) instead of `0%` (equal share). |
| 277 |
// |
| 278 |
// Derived from the pairs rather than stored, so it costs nothing in |
| 279 |
// the bucket tree, and — because this runs after style_hash() has |
| 280 |
// read $rules — the hash in already-saved markup is unaffected. |
| 281 |
$body .= self::wrap_child_css( $pairs, $selector_base . $state ); |
| 282 |
$body .= self::flex_child_css( $pairs, $selector_base . $state ); |
| 283 |
|
| 284 |
$css .= ( '' !== $media ) ? $media . '{' . $body . '}' : $body; |
| 285 |
} |
| 286 |
return $css; |
| 287 |
} |
| 288 |
|
| 289 |
/** |
| 290 |
* The value a declaration is emitted with, which is its compiled value |
| 291 |
* except for the centring guard's auto margins (see state_declarations()). |
| 292 |
* |
| 293 |
* Those centre a width-capped box in normal flow, but in a flex row an |
| 294 |
* auto margin swallows the free space on the main axis and overrides the |
| 295 |
* parent's justify-content: two 180px children of a centred, 24px-gap row |
| 296 |
* came out ~116px apart, the leftover space split into all four margins. |
| 297 |
* Emitted through a custom property instead, which the parent resolves |
| 298 |
* for its own children (flex_child_css()) and which falls back to the |
| 299 |
* same `auto` everywhere else. An `auto` here can only be the guard's: the |
| 300 |
* Dimensions control always appends a unit, and the guard stands down |
| 301 |
* when the author set a horizontal margin. |
| 302 |
* |
| 303 |
* Rewritten at emission, after style_hash() has read the pairs, so the |
| 304 |
* class in already-saved markup is unaffected. |
| 305 |
* |
| 306 |
* Mirrors emittedValue() in atomic-shared/styles.js. |
| 307 |
* |
| 308 |
* @param string $property The CSS property. |
| 309 |
* @param string $value The compiled value. |
| 310 |
* @return string The value to emit. |
| 311 |
*/ |
| 312 |
public static function emitted_value( $property, $value ) { |
| 313 |
if ( 'auto' === $value && ( 'margin-left' === $property || 'margin-right' === $property ) ) { |
| 314 |
return 'var(--ablocks-center-margin,auto)'; |
| 315 |
} |
| 316 |
return $value; |
| 317 |
} |
| 318 |
|
| 319 |
/** |
| 320 |
* The child rule a bucket that sets `display` or `flex-direction` needs, |
| 321 |
* or ''. Resolves --ablocks-center-margin (see emitted_value()) for this |
| 322 |
* container's own children: 0 across a flex row's main axis, the `auto` |
| 323 |
* fallback in a flex column (where horizontal is the cross axis and |
| 324 |
* centring cannot open a gap) and in any other display. |
| 325 |
* |
| 326 |
* Display and direction are separate variables because they may come |
| 327 |
* from different breakpoints — a row at Desktop turned into a column at |
| 328 |
* Tablet only compiles `flex-direction` there — and the cascade has to |
| 329 |
* combine them. The inner var() is substituted on the child itself, so |
| 330 |
* it reads the direction set for that same child. |
| 331 |
* |
| 332 |
* Custom properties inherit, so the grandchildren are reset to the |
| 333 |
* guaranteed-invalid value; `:where()` keeps that at 0-0-0, below any |
| 334 |
* nested container's own child rule. Grid is left alone on purpose: an |
| 335 |
* auto margin there centres an item in its own cell and opens no gap. |
| 336 |
* |
| 337 |
* Mirrors flexChildCss() in atomic-shared/styles.js. |
| 338 |
* |
| 339 |
* @param array $pairs The bucket's declaration pairs. |
| 340 |
* @param string $selector The already-composed selector for this bucket. |
| 341 |
* @return string A CSS rule, or ''. |
| 342 |
*/ |
| 343 |
public static function flex_child_css( $pairs, $selector ) { |
| 344 |
$declarations = ''; |
| 345 |
foreach ( $pairs as $pair ) { |
| 346 |
if ( 'display' === $pair[0] ) { |
| 347 |
$declarations .= ( 'flex' === $pair[1] || 'inline-flex' === $pair[1] ) |
| 348 |
? '--ablocks-center-margin:var(--ablocks-column-margin,0);' |
| 349 |
: '--ablocks-center-margin:initial;'; |
| 350 |
} elseif ( 'flex-direction' === $pair[0] ) { |
| 351 |
$declarations .= ( 'column' === $pair[1] || 'column-reverse' === $pair[1] ) |
| 352 |
? '--ablocks-column-margin:auto;' |
| 353 |
: '--ablocks-column-margin:initial;'; |
| 354 |
} |
| 355 |
} |
| 356 |
if ( '' === $declarations ) { |
| 357 |
return ''; |
| 358 |
} |
| 359 |
return $selector . '>*{' . $declarations . '}' |
| 360 |
. ':where(' . $selector . '>*>*){--ablocks-center-margin:initial;--ablocks-column-margin:initial;}'; |
| 361 |
} |
| 362 |
|
| 363 |
/** |
| 364 |
* The child rule a wrapping container needs, or ''. |
| 365 |
* |
| 366 |
* Scoped to container children only, matching the base stylesheets — a leaf |
| 367 |
* block is sized by its own block, not by the row it sits in. `:where()` |
| 368 |
* keeps the selector at the same specificity as those base rules, and this |
| 369 |
* <style> is injected after them, so it wins on order alone. |
| 370 |
* |
| 371 |
* Mirrors wrapChildCss() in atomic-shared/styles.js. |
| 372 |
* |
| 373 |
* @param array $pairs The bucket's declaration pairs. |
| 374 |
* @param string $selector The already-composed selector for this bucket. |
| 375 |
* @return string A CSS rule, or ''. |
| 376 |
*/ |
| 377 |
public static function wrap_child_css( $pairs, $selector ) { |
| 378 |
$wraps = false; |
| 379 |
foreach ( $pairs as $pair ) { |
| 380 |
if ( 'flex-wrap' === $pair[0] |
| 381 |
&& ( 'wrap' === $pair[1] || 'wrap-reverse' === $pair[1] ) ) { |
| 382 |
$wraps = true; |
| 383 |
} |
| 384 |
} |
| 385 |
if ( ! $wraps ) { |
| 386 |
return ''; |
| 387 |
} |
| 388 |
return $selector . self::WRAP_CHILD_SELECTOR . '{flex-basis:auto;}'; |
| 389 |
} |
| 390 |
|
| 391 |
/** The child combinator both compilers append for a wrapping container. */ |
| 392 |
const WRAP_CHILD_SELECTOR = '>:where(.ablocks-atomic-div,.ablocks-atomic-flex,.ablocks-atomic-grid)'; |
| 393 |
|
| 394 |
/** |
| 395 |
* A block-specific rule emitted only for the buckets that compile a given |
| 396 |
* CSS property — media query and state preserved. |
| 397 |
* |
| 398 |
* Lets one block react to a declaration the shared compiler produced |
| 399 |
* without that reaction leaking to every other atomic block, and without a |
| 400 |
* second copy of the bucket/breakpoint walk. Used by the SVG block, whose |
| 401 |
* graphic must stop filling its wrapper once the author has asked for the |
| 402 |
* wrapper to position it. |
| 403 |
* |
| 404 |
* @param array $styles The block's styles object. |
| 405 |
* @param string $selector_base The block's own selector. |
| 406 |
* @param string $property The compiled CSS property to look for. |
| 407 |
* @param string $suffix Appended to the selector (e.g. ' svg'). |
| 408 |
* @param string $declarations The declarations to emit. |
| 409 |
* @param string|null $unless_property Skip a bucket that ALSO compiles this |
| 410 |
* property — an explicit value there is |
| 411 |
* more specific than the reaction being |
| 412 |
* conditioned on, and must win outright |
| 413 |
* rather than being overridden by it. |
| 414 |
* @return string CSS, or ''. |
| 415 |
*/ |
| 416 |
public static function conditional_rules( $styles, $selector_base, $property, $suffix, $declarations, $unless_property = null ) { |
| 417 |
$css = ''; |
| 418 |
foreach ( self::compile_rules( $styles ) as $rule ) { |
| 419 |
list( $media, $state, $pairs ) = $rule; |
| 420 |
$found = false; |
| 421 |
$skip = false; |
| 422 |
foreach ( $pairs as $pair ) { |
| 423 |
if ( $pair[0] === $property ) { |
| 424 |
$found = true; |
| 425 |
} |
| 426 |
if ( null !== $unless_property && $pair[0] === $unless_property ) { |
| 427 |
$skip = true; |
| 428 |
} |
| 429 |
} |
| 430 |
if ( ! $found || $skip ) { |
| 431 |
continue; |
| 432 |
} |
| 433 |
$body = $selector_base . $state . $suffix . '{' . $declarations . '}'; |
| 434 |
$css .= ( '' !== $media ) ? $media . '{' . $body . '}' : $body; |
| 435 |
} |
| 436 |
return $css; |
| 437 |
} |
| 438 |
|
| 439 |
/** Whether a bucket is the base one (base device, normal state). */ |
| 440 |
public static function is_base_bucket( $bucket ) { |
| 441 |
return '' === $bucket['state'] |
| 442 |
&& '' === StyleBuckets::device_bucket_key( $bucket['device'] ); |
| 443 |
} |
| 444 |
|
| 445 |
/** |
| 446 |
* Whether a backgroundColor value is itself a gradient function (the |
| 447 |
* Background tab's Color control can now produce one — see |
| 448 |
* ABlocksColorControl's `isGradient` picker). Such a value is not valid |
| 449 |
* CSS under `background-color`; it belongs under `background-image` |
| 450 |
* instead, the same property the legacy `backgroundGradient` scalar and |
| 451 |
* `backgroundOverlay` layers already use. Mirrors JS `isGradientValue()`. |
| 452 |
*/ |
| 453 |
public static function is_gradient_value( $value ) { |
| 454 |
return is_string( $value ) && ( 0 === strpos( $value, 'linear-gradient(' ) || 0 === strpos( $value, 'radial-gradient(' ) ); |
| 455 |
} |
| 456 |
|
| 457 |
/** |
| 458 |
* A bucket that sets a solid background colour and no gradient of its own |
| 459 |
* must clear any gradient inherited from a lower-precedence bucket: |
| 460 |
* `background-color` and `background-image` are separate properties, so the |
| 461 |
* gradient would otherwise stay painted on top of the solid colour. |
| 462 |
* |
| 463 |
* Skipped for the base bucket on purpose — resetting there would also wipe a |
| 464 |
* gradient supplied by an applied global class, which the block never asked |
| 465 |
* to override. |
| 466 |
*/ |
| 467 |
public static function apply_background_reset( $declarations, $is_base_bucket ) { |
| 468 |
if ( $is_base_bucket || ! is_array( $declarations ) ) { |
| 469 |
return $declarations; |
| 470 |
} |
| 471 |
|
| 472 |
$has_color = isset( $declarations['background-color'] ) && '' !== $declarations['background-color']; |
| 473 |
$has_image = isset( $declarations['background-image'] ) && '' !== $declarations['background-image']; |
| 474 |
|
| 475 |
if ( $has_color && ! $has_image ) { |
| 476 |
$declarations['background-image'] = 'unset'; |
| 477 |
} |
| 478 |
|
| 479 |
return $declarations; |
| 480 |
} |
| 481 |
|
| 482 |
/** |
| 483 |
* Compile one bucket of style props into a CSS declarations map. |
| 484 |
* |
| 485 |
* Props inside a bucket carry no device suffix — the bucket key is the |
| 486 |
* device — so this reads them directly. Every prop the atomic system |
| 487 |
* understands is declared once in StylesSchema; this walks that descriptor |
| 488 |
* rather than enumerating props itself, so the JS editor compiler and this |
| 489 |
* one cannot drift on which props exist, what CSS property each maps to, or |
| 490 |
* what order they emit in. |
| 491 |
*/ |
| 492 |
public static function state_declarations( $props, $inherited_border_style = '' ) { |
| 493 |
$css = []; |
| 494 |
if ( ! is_array( $props ) ) { |
| 495 |
return $css; |
| 496 |
} |
| 497 |
|
| 498 |
foreach ( StylesSchema::props() as $entry ) { |
| 499 |
$prop = $entry['prop']; |
| 500 |
|
| 501 |
switch ( $entry['kind'] ) { |
| 502 |
|
| 503 |
case 'typography': |
| 504 |
if ( ! empty( $props['typography'] ) ) { |
| 505 |
$global = ! empty( $props['typographyGlobal'] ) ? $props['typographyGlobal'] : ''; |
| 506 |
// false: no font-stack expansion — the JS mirror cannot |
| 507 |
// reproduce it, and these declarations are hashed. |
| 508 |
$css = array_merge( $css, Typography::get_css( $props['typography'], '', '', $global, false ) ); |
| 509 |
} |
| 510 |
break; |
| 511 |
|
| 512 |
case 'color': |
| 513 |
$value = self::read_scalar( $props, $prop ); |
| 514 |
if ( '' !== $value ) { |
| 515 |
$css_value = Color::get_css( $value ); |
| 516 |
// backgroundColor is the one colour prop whose value can be |
| 517 |
// a gradient function; every other colour prop (textColor, |
| 518 |
// borderColor) keeps writing its own CSS property as before. |
| 519 |
if ( 'backgroundColor' === $prop && self::is_gradient_value( $css_value ) ) { |
| 520 |
$css['background-image'] = $css_value; |
| 521 |
} else { |
| 522 |
$css[ $entry['css'] ] = $css_value; |
| 523 |
} |
| 524 |
} |
| 525 |
break; |
| 526 |
|
| 527 |
case 'scalar': |
| 528 |
$value = self::read_scalar( $props, $prop ); |
| 529 |
if ( '' !== $value ) { |
| 530 |
$css[ $entry['css'] ] = $value; |
| 531 |
} |
| 532 |
break; |
| 533 |
|
| 534 |
case 'range': |
| 535 |
$value = self::read_range( $props, $prop ); |
| 536 |
if ( '' !== $value ) { |
| 537 |
$css[ $entry['css'] ] = $value; |
| 538 |
} |
| 539 |
break; |
| 540 |
|
| 541 |
case 'effect': |
| 542 |
// Repeatable lists (shadow / transform / transition / filters). |
| 543 |
$value = StyleEffects::to_css( $prop, isset( $props[ $prop ] ) ? $props[ $prop ] : null ); |
| 544 |
if ( '' !== $value ) { |
| 545 |
$css[ $entry['css'] ] = $value; |
| 546 |
} |
| 547 |
break; |
| 548 |
|
| 549 |
case 'overlay': |
| 550 |
// Background overlay layers -> background-image + the four |
| 551 |
// positional properties, emitted together. |
| 552 |
$css = array_merge( $css, StyleBackground::to_declarations( isset( $props[ $prop ] ) ? $props[ $prop ] : null ) ); |
| 553 |
break; |
| 554 |
|
| 555 |
case 'clip': |
| 556 |
// `background-clip: text` still needs the -webkit- longhand. |
| 557 |
$value = self::read_scalar( $props, $prop ); |
| 558 |
if ( '' !== $value ) { |
| 559 |
$css[ '-webkit-' . $entry['css'] ] = $value; |
| 560 |
$css[ $entry['css'] ] = $value; |
| 561 |
} |
| 562 |
break; |
| 563 |
|
| 564 |
case 'border': |
| 565 |
$css = array_merge( $css, self::border_css( $props, $inherited_border_style ) ); |
| 566 |
break; |
| 567 |
|
| 568 |
case 'dimensions': |
| 569 |
$css = array_merge( $css, self::spacing_css( $props, $prop ) ); |
| 570 |
break; |
| 571 |
} |
| 572 |
} |
| 573 |
|
| 574 |
// An explicit width, held against a flex row too narrow for it, held |
| 575 |
// against a row with space to spare, held against the base stylesheets' |
| 576 |
// `flex-basis: 0%` on every container child, kept from overflowing a |
| 577 |
// parent narrower than it, and centred in whatever is left — all five |
| 578 |
// mirror the JS compiler's stateToPairs(), which carries the full |
| 579 |
// reasoning. Appended after the schema loop in both, so the declaration |
| 580 |
// order the style hash is taken over stays identical. |
| 581 |
$has_width = '' !== self::read_range( $props, 'width' ); |
| 582 |
$has_max_width = '' !== self::read_range( $props, 'maxWidth' ); |
| 583 |
|
| 584 |
if ( $has_width ) { |
| 585 |
// No `flex-shrink: 0` — see the JS note. Pinning shrink to 0 is |
| 586 |
// what let a child escape its parent, and `flex-basis: auto` below |
| 587 |
// already holds the width whenever the row has room for it. |
| 588 |
if ( '' === self::read_scalar( $props, 'flexGrow' ) ) { |
| 589 |
$css['flex-grow'] = '0'; |
| 590 |
} |
| 591 |
if ( '' === self::read_scalar( $props, 'flexBasis' ) ) { |
| 592 |
$css['flex-basis'] = 'auto'; |
| 593 |
} |
| 594 |
if ( ! $has_max_width ) { |
| 595 |
$css['max-width'] = '100%'; |
| 596 |
} |
| 597 |
} |
| 598 |
|
| 599 |
// Centring answers to EITHER cap — see the JS note. Kept as its own |
| 600 |
// condition rather than folded into the block above so the declaration |
| 601 |
// order both compilers hash over stays identical. |
| 602 |
if ( ( $has_width || $has_max_width ) && ! self::has_horizontal_margin( $props ) ) { |
| 603 |
$css['margin-left'] = 'auto'; |
| 604 |
$css['margin-right'] = 'auto'; |
| 605 |
} |
| 606 |
|
| 607 |
return $css; |
| 608 |
} |
| 609 |
|
| 610 |
/** |
| 611 |
* Whether the author set a left/right margin of their own — `common` covers |
| 612 |
* the linked case, where one value drives all four sides. |
| 613 |
* |
| 614 |
* @param array $props The bucket's props. |
| 615 |
* @return bool Whether a horizontal margin is set. |
| 616 |
*/ |
| 617 |
private static function has_horizontal_margin( $props ) { |
| 618 |
$margin = isset( $props['margin'] ) ? $props['margin'] : null; |
| 619 |
if ( ! is_array( $margin ) ) { |
| 620 |
return false; |
| 621 |
} |
| 622 |
foreach ( [ 'common', 'left', 'right' ] as $side ) { |
| 623 |
if ( isset( $margin[ $side ] ) && '' !== $margin[ $side ] ) { |
| 624 |
return true; |
| 625 |
} |
| 626 |
} |
| 627 |
return false; |
| 628 |
} |
| 629 |
|
| 630 |
/** A scalar prop from this bucket. Absent means "inherit", not "empty". */ |
| 631 |
private static function read_scalar( $props, $base ) { |
| 632 |
return ( isset( $props[ $base ] ) && '' !== $props[ $base ] ) ? $props[ $base ] : ''; |
| 633 |
} |
| 634 |
|
| 635 |
/** An aBlocks Range object ({ value, valueUnit }) -> "<value><unit>". */ |
| 636 |
private static function read_range( $props, $base ) { |
| 637 |
$obj = isset( $props[ $base ] ) ? $props[ $base ] : ''; |
| 638 |
|
| 639 |
if ( is_array( $obj ) ) { |
| 640 |
if ( ! isset( $obj['value'] ) || '' === $obj['value'] ) { |
| 641 |
return ''; |
| 642 |
} |
| 643 |
$unit = ( isset( $obj['valueUnit'] ) && '' !== $obj['valueUnit'] ) ? $obj['valueUnit'] : 'px'; |
| 644 |
return $obj['value'] . $unit; |
| 645 |
} |
| 646 |
|
| 647 |
return ( is_string( $obj ) && '' !== $obj ) ? $obj : ''; |
| 648 |
} |
| 649 |
|
| 650 |
/** |
| 651 |
* The border group: Range width/radius plus scalar style/colour. |
| 652 |
* |
| 653 |
* CSS paints no border without a style, so a bucket that sets only a width |
| 654 |
* OR only a colour still gets one. Colour-only is the common case — a hover |
| 655 |
* bucket that recolours an existing border — and it rendered nothing before |
| 656 |
* this fallback covered it. |
| 657 |
*/ |
| 658 |
private static function border_css( $props, $inherited_style = '' ) { |
| 659 |
$css = []; |
| 660 |
|
| 661 |
$width = self::read_range( $props, 'borderWidth' ); |
| 662 |
$style = self::read_scalar( $props, 'borderStyle' ); |
| 663 |
$color = self::read_scalar( $props, 'borderColor' ); |
| 664 |
$radius = self::read_range( $props, 'borderRadius' ); |
| 665 |
|
| 666 |
// Per-side widths are overrides layered on the uniform one, so they are |
| 667 |
// emitted after it and win by cascade order. |
| 668 |
$side_widths = []; |
| 669 |
$has_side_width = false; |
| 670 |
foreach ( StylesSchema::BORDER_SIDES as $side ) { |
| 671 |
$value = self::read_range( $props, 'borderWidth' . $side ); |
| 672 |
$side_widths[ strtolower( $side ) ] = $value; |
| 673 |
if ( '' !== $value ) { |
| 674 |
$has_side_width = true; |
| 675 |
} |
| 676 |
} |
| 677 |
|
| 678 |
if ( '' !== $width ) { |
| 679 |
$css['border-width'] = $width; |
| 680 |
} |
| 681 |
foreach ( $side_widths as $side => $value ) { |
| 682 |
if ( '' !== $value ) { |
| 683 |
$css[ 'border-' . $side . '-width' ] = $value; |
| 684 |
} |
| 685 |
} |
| 686 |
|
| 687 |
// A width on any single side needs a style too, or it paints nothing. |
| 688 |
if ( '' !== $width || $has_side_width || '' !== $color ) { |
| 689 |
// Without a type of its own the bucket carries the one it inherits: |
| 690 |
// this rule's border-style would otherwise override a wider |
| 691 |
// device's (or the normal state's) `dashed` with a `solid` nobody |
| 692 |
// chose. `solid` is only the default when nothing up the cascade |
| 693 |
// sets a type either. |
| 694 |
if ( '' !== $style ) { |
| 695 |
$css['border-style'] = $style; |
| 696 |
} else { |
| 697 |
$css['border-style'] = '' !== $inherited_style ? $inherited_style : 'solid'; |
| 698 |
} |
| 699 |
} elseif ( '' !== $style ) { |
| 700 |
// A style on its own is meaningful (e.g. `none` to remove a border). |
| 701 |
$css['border-style'] = $style; |
| 702 |
} |
| 703 |
|
| 704 |
if ( '' !== $color ) { |
| 705 |
$css['border-color'] = Color::get_css( $color ); |
| 706 |
} |
| 707 |
|
| 708 |
if ( '' !== $radius ) { |
| 709 |
$css['border-radius'] = $radius; |
| 710 |
} |
| 711 |
foreach ( StylesSchema::BORDER_CORNERS as $corner => $property ) { |
| 712 |
$value = self::read_range( $props, 'borderRadius' . $corner ); |
| 713 |
if ( '' !== $value ) { |
| 714 |
$css[ $property ] = $value; |
| 715 |
} |
| 716 |
} |
| 717 |
|
| 718 |
return $css; |
| 719 |
} |
| 720 |
|
| 721 |
/** |
| 722 |
* Compile padding/margin for one bucket via the aBlocks Dimensions control's |
| 723 |
* own get_css, so the output is identical to every other block's spacing. |
| 724 |
* The device argument is always '' — the bucket already is the device. |
| 725 |
*/ |
| 726 |
private static function spacing_css( $props, $prop ) { |
| 727 |
$obj = isset( $props[ $prop ] ) && is_array( $props[ $prop ] ) ? $props[ $prop ] : []; |
| 728 |
if ( empty( $obj ) ) { |
| 729 |
return []; |
| 730 |
} |
| 731 |
return Dimensions::get_css( $obj, $prop, '' ); |
| 732 |
} |
| 733 |
|
| 734 |
/** |
| 735 |
* Shared "Advanced" tab output: free-form Custom CSS (with a `selector` |
| 736 |
* placeholder for this block) + per-device visibility. $base is the block's |
| 737 |
* own selector. Mirrors the JS editor preview. |
| 738 |
*/ |
| 739 |
public static function advanced_css( $base, $attributes ) { |
| 740 |
$css = ''; |
| 741 |
|
| 742 |
// Custom CSS — `selector` resolves to this block; strip any </style> so |
| 743 |
// authored CSS can't break out of the inline <style> tag. |
| 744 |
$custom = isset( $attributes['customCSS'] ) ? (string) $attributes['customCSS'] : ''; |
| 745 |
if ( '' !== trim( $custom ) ) { |
| 746 |
$custom = str_replace( 'selector', $base, $custom ); |
| 747 |
$custom = preg_replace( '#</\s*style#i', '', $custom ); |
| 748 |
$css .= $custom; |
| 749 |
} |
| 750 |
|
| 751 |
/* |
| 752 |
* Responsive visibility — hide on desktop / tablet / mobile ranges. |
| 753 |
* |
| 754 |
* These deliberately stay EXCLUSIVE bands and do not follow the site's |
| 755 |
* `breakpoint_mode`. Visibility is not a cascading value: "hide on |
| 756 |
* tablet" must not also hide the block on mobile, so a max-width |
| 757 |
* envelope would be wrong here even when style rules cascade. |
| 758 |
*/ |
| 759 |
$hide = isset( $attributes['hideOn'] ) && is_array( $attributes['hideOn'] ) ? $attributes['hideOn'] : []; |
| 760 |
if ( ! empty( $hide['desktop'] ) || ! empty( $hide['tablet'] ) || ! empty( $hide['mobile'] ) ) { |
| 761 |
$bp = Helper::get_breakpoints(); |
| 762 |
$tablet = isset( $bp['tablet'] ) ? (int) $bp['tablet'] : 1024; |
| 763 |
$mobile = isset( $bp['mobile'] ) ? (int) $bp['mobile'] : 767; |
| 764 |
$none = $base . '{display:none !important;}'; |
| 765 |
if ( ! empty( $hide['desktop'] ) ) { |
| 766 |
$css .= '@media screen and (min-width:' . ( $tablet + 1 ) . 'px){' . $none . '}'; |
| 767 |
} |
| 768 |
if ( ! empty( $hide['tablet'] ) ) { |
| 769 |
$css .= '@media screen and (min-width:' . ( $mobile + 1 ) . 'px) and (max-width:' . $tablet . 'px){' . $none . '}'; |
| 770 |
} |
| 771 |
if ( ! empty( $hide['mobile'] ) ) { |
| 772 |
$css .= '@media screen and (max-width:' . $mobile . 'px){' . $none . '}'; |
| 773 |
} |
| 774 |
} |
| 775 |
|
| 776 |
return $css; |
| 777 |
} |
| 778 |
|
| 779 |
/** |
| 780 |
* Turn a declarations map into a minified declaration string. |
| 781 |
*/ |
| 782 |
public static function to_string( $declarations ) { |
| 783 |
$out = ''; |
| 784 |
foreach ( $declarations as $prop => $value ) { |
| 785 |
if ( '' !== $value && null !== $value ) { |
| 786 |
$out .= Helper::esc_css_value( $prop ) . ':' . Helper::esc_css_value( $value ) . ';'; |
| 787 |
} |
| 788 |
} |
| 789 |
return $out; |
| 790 |
} |
| 791 |
} |
| 792 |
|