PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.13.0
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.13.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 1.1.1 1.1.2 All 80 releases
ablocks / includes / classes / atomic-styles.php

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

599 lines 21.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 * Compile a full bucket tree for one selector base into CSS.
27 *
28 * The stored state key IS the pseudo-selector, so it is appended directly;
29 * the breakpoint comes from the bucket's device via the one media-query
30 * builder. Buckets emit widest-first, which is what makes a narrower
31 * breakpoint win in cascade mode.
32 */
33 public static function compile_variants( $selector_base, $styles ) {
34 return self::rules_to_css( self::compile_rules( $styles ), $selector_base );
35 }
36
37 /**
38 * Compile a bucket tree into a normalised rule list:
39 *
40 * [ [ media-query, state-selector, [ [ prop, value ], … ] ], … ]
41 *
42 * Values are cast to strings and the structure is a plain list, so the JSON
43 * encoding is byte-identical to the JS mirror's `JSON.stringify` — that is
44 * what makes the style hash reproducible across the editor and the front end.
45 *
46 * `$alignment` (the block's own alignment attribute, which lives outside the
47 * bucket tree and is still device-suffixed) folds into each device's normal
48 * state. It has to participate in the hash: two blocks with identical styles
49 * but different alignment are not interchangeable.
50 */
51 public static function compile_rules( $styles, $alignment = [] ) {
52 $rules = [];
53 $devices = Helper::get_responsive_devices();
54 $has_styles = StyleBuckets::has_schema_version( $styles );
55
56 foreach ( $devices as $device ) {
57 $media = Helper::breakpoint_media_query( $device );
58 $bucket_key = StyleBuckets::device_bucket_key( $device );
59
60 foreach ( StyleBuckets::state_keys() as $state ) {
61 $declarations = [];
62
63 if ( $has_styles ) {
64 $props = StyleBuckets::read_bucket( $styles, $bucket_key, $state );
65 if ( ! empty( $props ) ) {
66 $declarations = self::apply_background_reset(
67 self::state_declarations( $props ),
68 '' === $state && '' === $bucket_key
69 );
70 }
71 }
72
73 // Alignment applies to the normal state, last so it beats a
74 // `textAlign` style prop set at the same level.
75 if ( '' === $state && ! empty( $alignment ) ) {
76 $declarations = array_merge(
77 $declarations,
78 Alignment::get_css( $alignment, 'text-align', $device['suffix'] )
79 );
80 }
81
82 if ( empty( $declarations ) ) {
83 continue;
84 }
85
86 $pairs = [];
87 foreach ( $declarations as $property => $value ) {
88 if ( '' === $value || null === $value ) {
89 continue;
90 }
91 $pairs[] = [ (string) $property, (string) $value ];
92 }
93
94 if ( ! empty( $pairs ) ) {
95 $rules[] = [ $media, $state, $pairs ];
96 }
97 }
98 }
99
100 return $rules;
101 }
102
103 /**
104 * FNV-1a 32-bit over the normalised rule list.
105 *
106 * Deliberately not md5: the editor has to compute the identical hash at save
107 * time, and FNV-1a is a handful of lines in both languages rather than a
108 * crypto dependency in the editor bundle.
109 */
110 public static function style_hash( $rules ) {
111 $json = wp_json_encode( $rules, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
112 $hash = 2166136261;
113 $len = strlen( $json );
114
115 for ( $i = 0; $i < $len; $i++ ) {
116 $hash ^= ord( $json[ $i ] );
117 $hash = ( $hash * 16777619 ) & 0xFFFFFFFF;
118 }
119
120 return str_pad( dechex( $hash ), 8, '0', STR_PAD_LEFT );
121 }
122
123 /** The shared style class for a bucket tree, or '' when it compiles to nothing. */
124 public static function style_class( $styles, $alignment = [] ) {
125 $rules = self::compile_rules( $styles, $alignment );
126 return empty( $rules ) ? '' : 'ablocks-s-' . self::style_hash( $rules );
127 }
128
129 /** Hashes already emitted this request, so each rule set is written once. */
130 private static $emitted = [];
131
132 /** Forget what has been emitted (test/CLI helper). */
133 public static function reset_emitted() {
134 self::$emitted = [];
135 }
136
137 /**
138 * Register a block's compiled styles and return its shared class plus the
139 * CSS that still needs emitting — empty on every block after the first with
140 * the same rule set, which is where the duplicate-CSS reduction comes from.
141 *
142 * The class is repeated in the selector (0-2-0) so a block's own styles beat
143 * an applied global class (0-1-0) without resorting to `!important`, which
144 * would make global classes unable to override anything.
145 */
146 public static function register_styles( $styles, $alignment = [] ) {
147 $rules = self::compile_rules( $styles, $alignment );
148 if ( empty( $rules ) ) {
149 return [ 'class' => '', 'css' => '' ];
150 }
151
152 $hash = self::style_hash( $rules );
153 $class = 'ablocks-s-' . $hash;
154
155 if ( isset( self::$emitted[ $hash ] ) ) {
156 return [ 'class' => $class, 'css' => '' ];
157 }
158 self::$emitted[ $hash ] = true;
159
160 return [ 'class' => $class, 'css' => self::rules_to_css( $rules, '.' . $class . '.' . $class ) ];
161 }
162
163 /** Render a normalised rule list against a selector base. */
164 public static function rules_to_css( $rules, $selector_base ) {
165 $css = '';
166 foreach ( $rules as $rule ) {
167 list( $media, $state, $pairs ) = $rule;
168 $declarations = '';
169 foreach ( $pairs as $pair ) {
170 // Escaped here rather than at the schema, so every kind of prop
171 // — scalar, range, colour, typography, effect, overlay — passes
172 // through one guard on its way out. This runs after style_hash()
173 // has already read $rules, so the hash the editor writes into
174 // the markup is unaffected.
175 $declarations .= Helper::esc_css_value( $pair[0] ) . ':' . Helper::esc_css_value( $pair[1] ) . ';';
176 }
177 $body = $selector_base . $state . '{' . $declarations . '}';
178
179 // A wrapping container's own children must size from their content,
180 // or the line can never be over-subscribed and `flex-wrap` never
181 // breaks one. That is a statement about THIS container's children,
182 // so it is emitted as a child rule here rather than as an inherited
183 // custom property: a custom property inherits down the whole tree,
184 // so a wrapping container silently re-sized the children of every
185 // non-wrapping container nested inside it — measured, a
186 // non-wrapping inner container's children came out `flex-basis:
187 // auto` (content-sized) instead of `0%` (equal share).
188 //
189 // Derived from the pairs rather than stored, so it costs nothing in
190 // the bucket tree, and — because this runs after style_hash() has
191 // read $rules — the hash in already-saved markup is unaffected.
192 $body .= self::wrap_child_css( $pairs, $selector_base . $state );
193
194 $css .= ( '' !== $media ) ? $media . '{' . $body . '}' : $body;
195 }
196 return $css;
197 }
198
199 /**
200 * The child rule a wrapping container needs, or ''.
201 *
202 * Scoped to container children only, matching the base stylesheets — a leaf
203 * block is sized by its own block, not by the row it sits in. `:where()`
204 * keeps the selector at the same specificity as those base rules, and this
205 * <style> is injected after them, so it wins on order alone.
206 *
207 * Mirrors wrapChildCss() in atomic-shared/styles.js.
208 *
209 * @param array $pairs The bucket's declaration pairs.
210 * @param string $selector The already-composed selector for this bucket.
211 * @return string A CSS rule, or ''.
212 */
213 public static function wrap_child_css( $pairs, $selector ) {
214 $wraps = false;
215 foreach ( $pairs as $pair ) {
216 if ( 'flex-wrap' === $pair[0]
217 && ( 'wrap' === $pair[1] || 'wrap-reverse' === $pair[1] ) ) {
218 $wraps = true;
219 }
220 }
221 if ( ! $wraps ) {
222 return '';
223 }
224 return $selector . self::WRAP_CHILD_SELECTOR . '{flex-basis:auto;}';
225 }
226
227 /** The child combinator both compilers append for a wrapping container. */
228 const WRAP_CHILD_SELECTOR = '>:where(.ablocks-atomic-div,.ablocks-atomic-flex,.ablocks-atomic-grid)';
229
230 /**
231 * A block-specific rule emitted only for the buckets that compile a given
232 * CSS property — media query and state preserved.
233 *
234 * Lets one block react to a declaration the shared compiler produced
235 * without that reaction leaking to every other atomic block, and without a
236 * second copy of the bucket/breakpoint walk. Used by the SVG block, whose
237 * graphic must stop filling its wrapper once the author has asked for the
238 * wrapper to position it.
239 *
240 * @param array $styles The block's styles object.
241 * @param string $selector_base The block's own selector.
242 * @param string $property The compiled CSS property to look for.
243 * @param string $suffix Appended to the selector (e.g. ' svg').
244 * @param string $declarations The declarations to emit.
245 * @param string|null $unless_property Skip a bucket that ALSO compiles this
246 * property — an explicit value there is
247 * more specific than the reaction being
248 * conditioned on, and must win outright
249 * rather than being overridden by it.
250 * @return string CSS, or ''.
251 */
252 public static function conditional_rules( $styles, $selector_base, $property, $suffix, $declarations, $unless_property = null ) {
253 $css = '';
254 foreach ( self::compile_rules( $styles ) as $rule ) {
255 list( $media, $state, $pairs ) = $rule;
256 $found = false;
257 $skip = false;
258 foreach ( $pairs as $pair ) {
259 if ( $pair[0] === $property ) {
260 $found = true;
261 }
262 if ( null !== $unless_property && $pair[0] === $unless_property ) {
263 $skip = true;
264 }
265 }
266 if ( ! $found || $skip ) {
267 continue;
268 }
269 $body = $selector_base . $state . $suffix . '{' . $declarations . '}';
270 $css .= ( '' !== $media ) ? $media . '{' . $body . '}' : $body;
271 }
272 return $css;
273 }
274
275 /** Whether a bucket is the base one (base device, normal state). */
276 public static function is_base_bucket( $bucket ) {
277 return '' === $bucket['state']
278 && '' === StyleBuckets::device_bucket_key( $bucket['device'] );
279 }
280
281 /**
282 * A bucket that sets a solid background colour and no gradient of its own
283 * must clear any gradient inherited from a lower-precedence bucket:
284 * `background-color` and `background-image` are separate properties, so the
285 * gradient would otherwise stay painted on top of the solid colour.
286 *
287 * Skipped for the base bucket on purpose — resetting there would also wipe a
288 * gradient supplied by an applied global class, which the block never asked
289 * to override.
290 */
291 public static function apply_background_reset( $declarations, $is_base_bucket ) {
292 if ( $is_base_bucket || ! is_array( $declarations ) ) {
293 return $declarations;
294 }
295
296 $has_color = isset( $declarations['background-color'] ) && '' !== $declarations['background-color'];
297 $has_image = isset( $declarations['background-image'] ) && '' !== $declarations['background-image'];
298
299 if ( $has_color && ! $has_image ) {
300 $declarations['background-image'] = 'unset';
301 }
302
303 return $declarations;
304 }
305
306 /**
307 * Compile one bucket of style props into a CSS declarations map.
308 *
309 * Props inside a bucket carry no device suffix — the bucket key is the
310 * device — so this reads them directly. Every prop the atomic system
311 * understands is declared once in StylesSchema; this walks that descriptor
312 * rather than enumerating props itself, so the JS editor compiler and this
313 * one cannot drift on which props exist, what CSS property each maps to, or
314 * what order they emit in.
315 */
316 public static function state_declarations( $props ) {
317 $css = [];
318 if ( ! is_array( $props ) ) {
319 return $css;
320 }
321
322 foreach ( StylesSchema::props() as $entry ) {
323 $prop = $entry['prop'];
324
325 switch ( $entry['kind'] ) {
326
327 case 'typography':
328 if ( ! empty( $props['typography'] ) ) {
329 $global = ! empty( $props['typographyGlobal'] ) ? $props['typographyGlobal'] : '';
330 // false: no font-stack expansion — the JS mirror cannot
331 // reproduce it, and these declarations are hashed.
332 $css = array_merge( $css, Typography::get_css( $props['typography'], '', '', $global, false ) );
333 }
334 break;
335
336 case 'color':
337 $value = self::read_scalar( $props, $prop );
338 if ( '' !== $value ) {
339 $css[ $entry['css'] ] = Color::get_css( $value );
340 }
341 break;
342
343 case 'scalar':
344 $value = self::read_scalar( $props, $prop );
345 if ( '' !== $value ) {
346 $css[ $entry['css'] ] = $value;
347 }
348 break;
349
350 case 'range':
351 $value = self::read_range( $props, $prop );
352 if ( '' !== $value ) {
353 $css[ $entry['css'] ] = $value;
354 }
355 break;
356
357 case 'effect':
358 // Repeatable lists (shadow / transform / transition / filters).
359 $value = StyleEffects::to_css( $prop, isset( $props[ $prop ] ) ? $props[ $prop ] : null );
360 if ( '' !== $value ) {
361 $css[ $entry['css'] ] = $value;
362 }
363 break;
364
365 case 'overlay':
366 // Background overlay layers -> background-image + the four
367 // positional properties, emitted together.
368 $css = array_merge( $css, StyleBackground::to_declarations( isset( $props[ $prop ] ) ? $props[ $prop ] : null ) );
369 break;
370
371 case 'clip':
372 // `background-clip: text` still needs the -webkit- longhand.
373 $value = self::read_scalar( $props, $prop );
374 if ( '' !== $value ) {
375 $css[ '-webkit-' . $entry['css'] ] = $value;
376 $css[ $entry['css'] ] = $value;
377 }
378 break;
379
380 case 'border':
381 $css = array_merge( $css, self::border_css( $props ) );
382 break;
383
384 case 'dimensions':
385 $css = array_merge( $css, self::spacing_css( $props, $prop ) );
386 break;
387 }
388 }
389
390 // An explicit width, held against a flex row too narrow for it, held
391 // against a row with space to spare, held against the base stylesheets'
392 // `flex-basis: 0%` on every container child, kept from overflowing a
393 // parent narrower than it, and centred in whatever is left — all five
394 // mirror the JS compiler's stateToPairs(), which carries the full
395 // reasoning. Appended after the schema loop in both, so the declaration
396 // order the style hash is taken over stays identical.
397 $has_width = '' !== self::read_range( $props, 'width' );
398 $has_max_width = '' !== self::read_range( $props, 'maxWidth' );
399
400 if ( $has_width ) {
401 // No `flex-shrink: 0` — see the JS note. Pinning shrink to 0 is
402 // what let a child escape its parent, and `flex-basis: auto` below
403 // already holds the width whenever the row has room for it.
404 if ( '' === self::read_scalar( $props, 'flexGrow' ) ) {
405 $css['flex-grow'] = '0';
406 }
407 if ( '' === self::read_scalar( $props, 'flexBasis' ) ) {
408 $css['flex-basis'] = 'auto';
409 }
410 if ( ! $has_max_width ) {
411 $css['max-width'] = '100%';
412 }
413 }
414
415 // Centring answers to EITHER cap — see the JS note. Kept as its own
416 // condition rather than folded into the block above so the declaration
417 // order both compilers hash over stays identical.
418 if ( ( $has_width || $has_max_width ) && ! self::has_horizontal_margin( $props ) ) {
419 $css['margin-left'] = 'auto';
420 $css['margin-right'] = 'auto';
421 }
422
423 return $css;
424 }
425
426 /**
427 * Whether the author set a left/right margin of their own — `common` covers
428 * the linked case, where one value drives all four sides.
429 *
430 * @param array $props The bucket's props.
431 * @return bool Whether a horizontal margin is set.
432 */
433 private static function has_horizontal_margin( $props ) {
434 $margin = isset( $props['margin'] ) ? $props['margin'] : null;
435 if ( ! is_array( $margin ) ) {
436 return false;
437 }
438 foreach ( [ 'common', 'left', 'right' ] as $side ) {
439 if ( isset( $margin[ $side ] ) && '' !== $margin[ $side ] ) {
440 return true;
441 }
442 }
443 return false;
444 }
445
446 /** A scalar prop from this bucket. Absent means "inherit", not "empty". */
447 private static function read_scalar( $props, $base ) {
448 return ( isset( $props[ $base ] ) && '' !== $props[ $base ] ) ? $props[ $base ] : '';
449 }
450
451 /** An aBlocks Range object ({ value, valueUnit }) -> "<value><unit>". */
452 private static function read_range( $props, $base ) {
453 $obj = isset( $props[ $base ] ) ? $props[ $base ] : '';
454
455 if ( is_array( $obj ) ) {
456 if ( ! isset( $obj['value'] ) || '' === $obj['value'] ) {
457 return '';
458 }
459 $unit = ( isset( $obj['valueUnit'] ) && '' !== $obj['valueUnit'] ) ? $obj['valueUnit'] : 'px';
460 return $obj['value'] . $unit;
461 }
462
463 return ( is_string( $obj ) && '' !== $obj ) ? $obj : '';
464 }
465
466 /**
467 * The border group: Range width/radius plus scalar style/colour.
468 *
469 * CSS paints no border without a style, so a bucket that sets only a width
470 * OR only a colour still gets one. Colour-only is the common case — a hover
471 * bucket that recolours an existing border — and it rendered nothing before
472 * this fallback covered it.
473 */
474 private static function border_css( $props ) {
475 $css = [];
476
477 $width = self::read_range( $props, 'borderWidth' );
478 $style = self::read_scalar( $props, 'borderStyle' );
479 $color = self::read_scalar( $props, 'borderColor' );
480 $radius = self::read_range( $props, 'borderRadius' );
481
482 // Per-side widths are overrides layered on the uniform one, so they are
483 // emitted after it and win by cascade order.
484 $side_widths = [];
485 $has_side_width = false;
486 foreach ( StylesSchema::BORDER_SIDES as $side ) {
487 $value = self::read_range( $props, 'borderWidth' . $side );
488 $side_widths[ strtolower( $side ) ] = $value;
489 if ( '' !== $value ) {
490 $has_side_width = true;
491 }
492 }
493
494 if ( '' !== $width ) {
495 $css['border-width'] = $width;
496 }
497 foreach ( $side_widths as $side => $value ) {
498 if ( '' !== $value ) {
499 $css[ 'border-' . $side . '-width' ] = $value;
500 }
501 }
502
503 // A width on any single side needs a style too, or it paints nothing.
504 if ( '' !== $width || $has_side_width || '' !== $color ) {
505 $css['border-style'] = '' !== $style ? $style : 'solid';
506 } elseif ( '' !== $style ) {
507 // A style on its own is meaningful (e.g. `none` to remove a border).
508 $css['border-style'] = $style;
509 }
510
511 if ( '' !== $color ) {
512 $css['border-color'] = Color::get_css( $color );
513 }
514
515 if ( '' !== $radius ) {
516 $css['border-radius'] = $radius;
517 }
518 foreach ( StylesSchema::BORDER_CORNERS as $corner => $property ) {
519 $value = self::read_range( $props, 'borderRadius' . $corner );
520 if ( '' !== $value ) {
521 $css[ $property ] = $value;
522 }
523 }
524
525 return $css;
526 }
527
528 /**
529 * Compile padding/margin for one bucket via the aBlocks Dimensions control's
530 * own get_css, so the output is identical to every other block's spacing.
531 * The device argument is always '' — the bucket already is the device.
532 */
533 private static function spacing_css( $props, $prop ) {
534 $obj = isset( $props[ $prop ] ) && is_array( $props[ $prop ] ) ? $props[ $prop ] : [];
535 if ( empty( $obj ) ) {
536 return [];
537 }
538 return Dimensions::get_css( $obj, $prop, '' );
539 }
540
541 /**
542 * Shared "Advanced" tab output: free-form Custom CSS (with a `selector`
543 * placeholder for this block) + per-device visibility. $base is the block's
544 * own selector. Mirrors the JS editor preview.
545 */
546 public static function advanced_css( $base, $attributes ) {
547 $css = '';
548
549 // Custom CSS — `selector` resolves to this block; strip any </style> so
550 // authored CSS can't break out of the inline <style> tag.
551 $custom = isset( $attributes['customCSS'] ) ? (string) $attributes['customCSS'] : '';
552 if ( '' !== trim( $custom ) ) {
553 $custom = str_replace( 'selector', $base, $custom );
554 $custom = preg_replace( '#</\s*style#i', '', $custom );
555 $css .= $custom;
556 }
557
558 /*
559 * Responsive visibility — hide on desktop / tablet / mobile ranges.
560 *
561 * These deliberately stay EXCLUSIVE bands and do not follow the site's
562 * `breakpoint_mode`. Visibility is not a cascading value: "hide on
563 * tablet" must not also hide the block on mobile, so a max-width
564 * envelope would be wrong here even when style rules cascade.
565 */
566 $hide = isset( $attributes['hideOn'] ) && is_array( $attributes['hideOn'] ) ? $attributes['hideOn'] : [];
567 if ( ! empty( $hide['desktop'] ) || ! empty( $hide['tablet'] ) || ! empty( $hide['mobile'] ) ) {
568 $bp = Helper::get_breakpoints();
569 $tablet = isset( $bp['tablet'] ) ? (int) $bp['tablet'] : 1024;
570 $mobile = isset( $bp['mobile'] ) ? (int) $bp['mobile'] : 767;
571 $none = $base . '{display:none !important;}';
572 if ( ! empty( $hide['desktop'] ) ) {
573 $css .= '@media screen and (min-width:' . ( $tablet + 1 ) . 'px){' . $none . '}';
574 }
575 if ( ! empty( $hide['tablet'] ) ) {
576 $css .= '@media screen and (min-width:' . ( $mobile + 1 ) . 'px) and (max-width:' . $tablet . 'px){' . $none . '}';
577 }
578 if ( ! empty( $hide['mobile'] ) ) {
579 $css .= '@media screen and (max-width:' . $mobile . 'px){' . $none . '}';
580 }
581 }
582
583 return $css;
584 }
585
586 /**
587 * Turn a declarations map into a minified declaration string.
588 */
589 public static function to_string( $declarations ) {
590 $out = '';
591 foreach ( $declarations as $prop => $value ) {
592 if ( '' !== $value && null !== $value ) {
593 $out .= Helper::esc_css_value( $prop ) . ':' . Helper::esc_css_value( $value ) . ';';
594 }
595 }
596 return $out;
597 }
598 }
599