PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / trunk
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder vtrunk
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 1.2.0 1.2.1 All 78 releases
ablocks / includes / classes / atomic-styles.php

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

507 lines 17.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 $css .= ( '' !== $media ) ? $media . '{' . $body . '}' : $body;
179 }
180 return $css;
181 }
182
183 /** Whether a bucket is the base one (base device, normal state). */
184 public static function is_base_bucket( $bucket ) {
185 return '' === $bucket['state']
186 && '' === StyleBuckets::device_bucket_key( $bucket['device'] );
187 }
188
189 /**
190 * A bucket that sets a solid background colour and no gradient of its own
191 * must clear any gradient inherited from a lower-precedence bucket:
192 * `background-color` and `background-image` are separate properties, so the
193 * gradient would otherwise stay painted on top of the solid colour.
194 *
195 * Skipped for the base bucket on purpose — resetting there would also wipe a
196 * gradient supplied by an applied global class, which the block never asked
197 * to override.
198 */
199 public static function apply_background_reset( $declarations, $is_base_bucket ) {
200 if ( $is_base_bucket || ! is_array( $declarations ) ) {
201 return $declarations;
202 }
203
204 $has_color = isset( $declarations['background-color'] ) && '' !== $declarations['background-color'];
205 $has_image = isset( $declarations['background-image'] ) && '' !== $declarations['background-image'];
206
207 if ( $has_color && ! $has_image ) {
208 $declarations['background-image'] = 'unset';
209 }
210
211 return $declarations;
212 }
213
214 /**
215 * Compile one bucket of style props into a CSS declarations map.
216 *
217 * Props inside a bucket carry no device suffix — the bucket key is the
218 * device — so this reads them directly. Every prop the atomic system
219 * understands is declared once in StylesSchema; this walks that descriptor
220 * rather than enumerating props itself, so the JS editor compiler and this
221 * one cannot drift on which props exist, what CSS property each maps to, or
222 * what order they emit in.
223 */
224 public static function state_declarations( $props ) {
225 $css = [];
226 if ( ! is_array( $props ) ) {
227 return $css;
228 }
229
230 foreach ( StylesSchema::props() as $entry ) {
231 $prop = $entry['prop'];
232
233 switch ( $entry['kind'] ) {
234
235 case 'typography':
236 if ( ! empty( $props['typography'] ) ) {
237 $global = ! empty( $props['typographyGlobal'] ) ? $props['typographyGlobal'] : '';
238 // false: no font-stack expansion — the JS mirror cannot
239 // reproduce it, and these declarations are hashed.
240 $css = array_merge( $css, Typography::get_css( $props['typography'], '', '', $global, false ) );
241 }
242 break;
243
244 case 'color':
245 $value = self::read_scalar( $props, $prop );
246 if ( '' !== $value ) {
247 $css[ $entry['css'] ] = Color::get_css( $value );
248 }
249 break;
250
251 case 'scalar':
252 $value = self::read_scalar( $props, $prop );
253 if ( '' !== $value ) {
254 $css[ $entry['css'] ] = $value;
255 }
256 break;
257
258 case 'range':
259 $value = self::read_range( $props, $prop );
260 if ( '' !== $value ) {
261 $css[ $entry['css'] ] = $value;
262 }
263 break;
264
265 case 'effect':
266 // Repeatable lists (shadow / transform / transition / filters).
267 $value = StyleEffects::to_css( $prop, isset( $props[ $prop ] ) ? $props[ $prop ] : null );
268 if ( '' !== $value ) {
269 $css[ $entry['css'] ] = $value;
270 }
271 break;
272
273 case 'overlay':
274 // Background overlay layers -> background-image + the four
275 // positional properties, emitted together.
276 $css = array_merge( $css, StyleBackground::to_declarations( isset( $props[ $prop ] ) ? $props[ $prop ] : null ) );
277 break;
278
279 case 'clip':
280 // `background-clip: text` still needs the -webkit- longhand.
281 $value = self::read_scalar( $props, $prop );
282 if ( '' !== $value ) {
283 $css[ '-webkit-' . $entry['css'] ] = $value;
284 $css[ $entry['css'] ] = $value;
285 }
286 break;
287
288 case 'border':
289 $css = array_merge( $css, self::border_css( $props ) );
290 break;
291
292 case 'dimensions':
293 $css = array_merge( $css, self::spacing_css( $props, $prop ) );
294 break;
295 }
296 }
297
298 // An explicit width, held against a flex row too narrow for it, held
299 // against a row with space to spare, held against the base stylesheets'
300 // `flex-basis: 0%` on every container child, kept from overflowing a
301 // parent narrower than it, and centred in whatever is left — all five
302 // mirror the JS compiler's stateToPairs(), which carries the full
303 // reasoning. Appended after the schema loop in both, so the declaration
304 // order the style hash is taken over stays identical.
305 $has_width = '' !== self::read_range( $props, 'width' );
306 $has_max_width = '' !== self::read_range( $props, 'maxWidth' );
307
308 if ( $has_width ) {
309 if ( '' === self::read_scalar( $props, 'flexShrink' ) ) {
310 $css['flex-shrink'] = '0';
311 }
312 if ( '' === self::read_scalar( $props, 'flexGrow' ) ) {
313 $css['flex-grow'] = '0';
314 }
315 if ( '' === self::read_scalar( $props, 'flexBasis' ) ) {
316 $css['flex-basis'] = 'auto';
317 }
318 if ( ! $has_max_width ) {
319 $css['max-width'] = '100%';
320 }
321 }
322
323 // Centring answers to EITHER cap — see the JS note. Kept as its own
324 // condition rather than folded into the block above so the declaration
325 // order both compilers hash over stays identical.
326 if ( ( $has_width || $has_max_width ) && ! self::has_horizontal_margin( $props ) ) {
327 $css['margin-left'] = 'auto';
328 $css['margin-right'] = 'auto';
329 }
330
331 return $css;
332 }
333
334 /**
335 * Whether the author set a left/right margin of their own — `common` covers
336 * the linked case, where one value drives all four sides.
337 *
338 * @param array $props The bucket's props.
339 * @return bool Whether a horizontal margin is set.
340 */
341 private static function has_horizontal_margin( $props ) {
342 $margin = isset( $props['margin'] ) ? $props['margin'] : null;
343 if ( ! is_array( $margin ) ) {
344 return false;
345 }
346 foreach ( [ 'common', 'left', 'right' ] as $side ) {
347 if ( isset( $margin[ $side ] ) && '' !== $margin[ $side ] ) {
348 return true;
349 }
350 }
351 return false;
352 }
353
354 /** A scalar prop from this bucket. Absent means "inherit", not "empty". */
355 private static function read_scalar( $props, $base ) {
356 return ( isset( $props[ $base ] ) && '' !== $props[ $base ] ) ? $props[ $base ] : '';
357 }
358
359 /** An aBlocks Range object ({ value, valueUnit }) -> "<value><unit>". */
360 private static function read_range( $props, $base ) {
361 $obj = isset( $props[ $base ] ) ? $props[ $base ] : '';
362
363 if ( is_array( $obj ) ) {
364 if ( ! isset( $obj['value'] ) || '' === $obj['value'] ) {
365 return '';
366 }
367 $unit = ( isset( $obj['valueUnit'] ) && '' !== $obj['valueUnit'] ) ? $obj['valueUnit'] : 'px';
368 return $obj['value'] . $unit;
369 }
370
371 return ( is_string( $obj ) && '' !== $obj ) ? $obj : '';
372 }
373
374 /**
375 * The border group: Range width/radius plus scalar style/colour.
376 *
377 * CSS paints no border without a style, so a bucket that sets only a width
378 * OR only a colour still gets one. Colour-only is the common case — a hover
379 * bucket that recolours an existing border — and it rendered nothing before
380 * this fallback covered it.
381 */
382 private static function border_css( $props ) {
383 $css = [];
384
385 $width = self::read_range( $props, 'borderWidth' );
386 $style = self::read_scalar( $props, 'borderStyle' );
387 $color = self::read_scalar( $props, 'borderColor' );
388 $radius = self::read_range( $props, 'borderRadius' );
389
390 // Per-side widths are overrides layered on the uniform one, so they are
391 // emitted after it and win by cascade order.
392 $side_widths = [];
393 $has_side_width = false;
394 foreach ( StylesSchema::BORDER_SIDES as $side ) {
395 $value = self::read_range( $props, 'borderWidth' . $side );
396 $side_widths[ strtolower( $side ) ] = $value;
397 if ( '' !== $value ) {
398 $has_side_width = true;
399 }
400 }
401
402 if ( '' !== $width ) {
403 $css['border-width'] = $width;
404 }
405 foreach ( $side_widths as $side => $value ) {
406 if ( '' !== $value ) {
407 $css[ 'border-' . $side . '-width' ] = $value;
408 }
409 }
410
411 // A width on any single side needs a style too, or it paints nothing.
412 if ( '' !== $width || $has_side_width || '' !== $color ) {
413 $css['border-style'] = '' !== $style ? $style : 'solid';
414 } elseif ( '' !== $style ) {
415 // A style on its own is meaningful (e.g. `none` to remove a border).
416 $css['border-style'] = $style;
417 }
418
419 if ( '' !== $color ) {
420 $css['border-color'] = Color::get_css( $color );
421 }
422
423 if ( '' !== $radius ) {
424 $css['border-radius'] = $radius;
425 }
426 foreach ( StylesSchema::BORDER_CORNERS as $corner => $property ) {
427 $value = self::read_range( $props, 'borderRadius' . $corner );
428 if ( '' !== $value ) {
429 $css[ $property ] = $value;
430 }
431 }
432
433 return $css;
434 }
435
436 /**
437 * Compile padding/margin for one bucket via the aBlocks Dimensions control's
438 * own get_css, so the output is identical to every other block's spacing.
439 * The device argument is always '' — the bucket already is the device.
440 */
441 private static function spacing_css( $props, $prop ) {
442 $obj = isset( $props[ $prop ] ) && is_array( $props[ $prop ] ) ? $props[ $prop ] : [];
443 if ( empty( $obj ) ) {
444 return [];
445 }
446 return Dimensions::get_css( $obj, $prop, '' );
447 }
448
449 /**
450 * Shared "Advanced" tab output: free-form Custom CSS (with a `selector`
451 * placeholder for this block) + per-device visibility. $base is the block's
452 * own selector. Mirrors the JS editor preview.
453 */
454 public static function advanced_css( $base, $attributes ) {
455 $css = '';
456
457 // Custom CSS — `selector` resolves to this block; strip any </style> so
458 // authored CSS can't break out of the inline <style> tag.
459 $custom = isset( $attributes['customCSS'] ) ? (string) $attributes['customCSS'] : '';
460 if ( '' !== trim( $custom ) ) {
461 $custom = str_replace( 'selector', $base, $custom );
462 $custom = preg_replace( '#</\s*style#i', '', $custom );
463 $css .= $custom;
464 }
465
466 /*
467 * Responsive visibility — hide on desktop / tablet / mobile ranges.
468 *
469 * These deliberately stay EXCLUSIVE bands and do not follow the site's
470 * `breakpoint_mode`. Visibility is not a cascading value: "hide on
471 * tablet" must not also hide the block on mobile, so a max-width
472 * envelope would be wrong here even when style rules cascade.
473 */
474 $hide = isset( $attributes['hideOn'] ) && is_array( $attributes['hideOn'] ) ? $attributes['hideOn'] : [];
475 if ( ! empty( $hide['desktop'] ) || ! empty( $hide['tablet'] ) || ! empty( $hide['mobile'] ) ) {
476 $bp = Helper::get_breakpoints();
477 $tablet = isset( $bp['tablet'] ) ? (int) $bp['tablet'] : 1024;
478 $mobile = isset( $bp['mobile'] ) ? (int) $bp['mobile'] : 767;
479 $none = $base . '{display:none !important;}';
480 if ( ! empty( $hide['desktop'] ) ) {
481 $css .= '@media screen and (min-width:' . ( $tablet + 1 ) . 'px){' . $none . '}';
482 }
483 if ( ! empty( $hide['tablet'] ) ) {
484 $css .= '@media screen and (min-width:' . ( $mobile + 1 ) . 'px) and (max-width:' . $tablet . 'px){' . $none . '}';
485 }
486 if ( ! empty( $hide['mobile'] ) ) {
487 $css .= '@media screen and (max-width:' . $mobile . 'px){' . $none . '}';
488 }
489 }
490
491 return $css;
492 }
493
494 /**
495 * Turn a declarations map into a minified declaration string.
496 */
497 public static function to_string( $declarations ) {
498 $out = '';
499 foreach ( $declarations as $prop => $value ) {
500 if ( '' !== $value && null !== $value ) {
501 $out .= Helper::esc_css_value( $prop ) . ':' . Helper::esc_css_value( $value ) . ';';
502 }
503 }
504 return $out;
505 }
506 }
507