| @@ -22,8 +22,26 @@ | ||
| 22 | 22 | */ |
| 23 | 23 | class AtomicStyles { |
| 24 | 24 | |
| 25 | 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 | + /** | |
| 26 | 44 | * Compile a full bucket tree for one selector base into CSS. |
| 27 | 45 | * |
| 28 | 46 | * The stored state key IS the pseudo-selector, so it is appended directly; |
| 29 | 47 | * the breakpoint comes from the bucket's device via the one media-query |
| @@ -52,11 +70,14 @@ | ||
| 52 | 70 | $rules = []; |
| 53 | 71 | $devices = Helper::get_responsive_devices(); |
| 54 | 72 | $has_styles = StyleBuckets::has_schema_version( $styles ); |
| 55 | 73 | |
| 56 | - foreach ( $devices as $device ) { | |
| 74 | + $devices = array_values( $devices ); | |
| 75 | + | |
| 76 | + foreach ( $devices as $index => $device ) { | |
| 57 | 77 | $media = Helper::breakpoint_media_query( $device ); |
| 58 | 78 | $bucket_key = StyleBuckets::device_bucket_key( $device ); |
| 79 | + $cascade = self::cascade_devices( $devices, $index ); | |
| 59 | 80 | |
| 60 | 81 | foreach ( StyleBuckets::state_keys() as $state ) { |
| 61 | 82 | $declarations = []; |
| 62 | 83 | |
| @@ -62,10 +83,14 @@ | ||
| 62 | 83 | |
| 63 | 84 | if ( $has_styles ) { |
| 64 | 85 | $props = StyleBuckets::read_bucket( $styles, $bucket_key, $state ); |
| 65 | 86 | if ( ! empty( $props ) ) { |
| 66 | - $declarations = self::apply_background_reset( | |
| 67 | - self::state_declarations( $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 : '' ), | |
| 68 | 93 | '' === $state && '' === $bucket_key |
| 69 | 94 | ); |
| 70 | 95 | } |
| 71 | 96 | } |
| @@ -100,8 +125,35 @@ | ||
| 100 | 125 | return $rules; |
| 101 | 126 | } |
| 102 | 127 | |
| 103 | 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 | + /** | |
| 104 | 156 | * FNV-1a 32-bit over the normalised rule list. |
| 105 | 157 | * |
| 106 | 158 | * Deliberately not md5: the editor has to compute the identical hash at save |
| 107 | 159 | * time, and FNV-1a is a handful of lines in both languages rather than a |
| @@ -159,8 +211,45 @@ | ||
| 159 | 211 | |
| 160 | 212 | return [ 'class' => $class, 'css' => self::rules_to_css( $rules, '.' . $class . '.' . $class ) ]; |
| 161 | 213 | } |
| 162 | 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 | + | |
| 163 | 252 | /** Render a normalised rule list against a selector base. */ |
| 164 | 253 | public static function rules_to_css( $rules, $selector_base ) { |
| 165 | 254 | $css = ''; |
| 166 | 255 | foreach ( $rules as $rule ) { |
| @@ -171,9 +260,9 @@ | ||
| 171 | 260 | // — scalar, range, colour, typography, effect, overlay — passes |
| 172 | 261 | // through one guard on its way out. This runs after style_hash() |
| 173 | 262 | // has already read $rules, so the hash the editor writes into |
| 174 | 263 | // the markup is unaffected. |
| 175 | - $declarations .= Helper::esc_css_value( $pair[0] ) . ':' . Helper::esc_css_value( $pair[1] ) . ';'; | |
| 264 | + $declarations .= Helper::esc_css_value( $pair[0] ) . ':' . Helper::esc_css_value( self::emitted_value( $pair[0], $pair[1] ) ) . ';'; | |
| 176 | 265 | } |
| 177 | 266 | $body = $selector_base . $state . '{' . $declarations . '}'; |
| 178 | 267 | |
| 179 | 268 | // A wrapping container's own children must size from their content, |
| @@ -189,8 +278,9 @@ | ||
| 189 | 278 | // Derived from the pairs rather than stored, so it costs nothing in |
| 190 | 279 | // the bucket tree, and — because this runs after style_hash() has |
| 191 | 280 | // read $rules — the hash in already-saved markup is unaffected. |
| 192 | 281 | $body .= self::wrap_child_css( $pairs, $selector_base . $state ); |
| 282 | + $body .= self::flex_child_css( $pairs, $selector_base . $state ); | |
| 193 | 283 | |
| 194 | 284 | $css .= ( '' !== $media ) ? $media . '{' . $body . '}' : $body; |
| 195 | 285 | } |
| 196 | 286 | return $css; |
| @@ -196,8 +286,82 @@ | ||
| 196 | 286 | return $css; |
| 197 | 287 | } |
| 198 | 288 | |
| 199 | 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 | + /** | |
| 200 | 364 | * The child rule a wrapping container needs, or ''. |
| 201 | 365 | * |
| 202 | 366 | * Scoped to container children only, matching the base stylesheets — a leaf |
| 203 | 367 | * block is sized by its own block, not by the row it sits in. `:where()` |
| @@ -278,8 +442,20 @@ | ||
| 278 | 442 | && '' === StyleBuckets::device_bucket_key( $bucket['device'] ); |
| 279 | 443 | } |
| 280 | 444 | |
| 281 | 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 | + /** | |
| 282 | 458 | * A bucket that sets a solid background colour and no gradient of its own |
| 283 | 459 | * must clear any gradient inherited from a lower-precedence bucket: |
| 284 | 460 | * `background-color` and `background-image` are separate properties, so the |
| 285 | 461 | * gradient would otherwise stay painted on top of the solid colour. |
| @@ -312,9 +488,9 @@ | ||
| 312 | 488 | * rather than enumerating props itself, so the JS editor compiler and this |
| 313 | 489 | * one cannot drift on which props exist, what CSS property each maps to, or |
| 314 | 490 | * what order they emit in. |
| 315 | 491 | */ |
| 316 | - public static function state_declarations( $props ) { | |
| 492 | + public static function state_declarations( $props, $inherited_border_style = '' ) { | |
| 317 | 493 | $css = []; |
| 318 | 494 | if ( ! is_array( $props ) ) { |
| 319 | 495 | return $css; |
| 320 | 496 | } |
| @@ -335,9 +511,17 @@ | ||
| 335 | 511 | |
| 336 | 512 | case 'color': |
| 337 | 513 | $value = self::read_scalar( $props, $prop ); |
| 338 | 514 | if ( '' !== $value ) { |
| 339 | - $css[ $entry['css'] ] = Color::get_css( $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 | + } | |
| 340 | 524 | } |
| 341 | 525 | break; |
| 342 | 526 | |
| 343 | 527 | case 'scalar': |
| @@ -377,9 +561,9 @@ | ||
| 377 | 561 | } |
| 378 | 562 | break; |
| 379 | 563 | |
| 380 | 564 | case 'border': |
| 381 | - $css = array_merge( $css, self::border_css( $props ) ); | |
| 565 | + $css = array_merge( $css, self::border_css( $props, $inherited_border_style ) ); | |
| 382 | 566 | break; |
| 383 | 567 | |
| 384 | 568 | case 'dimensions': |
| 385 | 569 | $css = array_merge( $css, self::spacing_css( $props, $prop ) ); |
| @@ -470,9 +654,9 @@ | ||
| 470 | 654 | * OR only a colour still gets one. Colour-only is the common case — a hover |
| 471 | 655 | * bucket that recolours an existing border — and it rendered nothing before |
| 472 | 656 | * this fallback covered it. |
| 473 | 657 | */ |
| 474 | - private static function border_css( $props ) { | |
| 658 | + private static function border_css( $props, $inherited_style = '' ) { | |
| 475 | 659 | $css = []; |
| 476 | 660 | |
| 477 | 661 | $width = self::read_range( $props, 'borderWidth' ); |
| 478 | 662 | $style = self::read_scalar( $props, 'borderStyle' ); |
| @@ -501,9 +685,18 @@ | ||
| 501 | 685 | } |
| 502 | 686 | |
| 503 | 687 | // A width on any single side needs a style too, or it paints nothing. |
| 504 | 688 | if ( '' !== $width || $has_side_width || '' !== $color ) { |
| 505 | - $css['border-style'] = '' !== $style ? $style : 'solid'; | |
| 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 | + } | |
| 506 | 699 | } elseif ( '' !== $style ) { |
| 507 | 700 | // A style on its own is meaningful (e.g. `none` to remove a border). |
| 508 | 701 | $css['border-style'] = $style; |
| 509 | 702 | } |