PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.15.0
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.15.0
2.15.0 2.14.0 2.13.0 2.13.1 2.12.0 2.11.1 2.11.0 2.10.0 2.9.0 2.7.4 2.7.5 2.7.6 2.7.7 2.8.0 2.8.1 2.9.1 trunk 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.0.1 1.0.2 1.0.3 1.1.0 All 82 releases
ablocks / includes / classes / atomic-styles.php

atomic-styles.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder 2.15.0, at includes/classes/atomic-styles.php

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