WooCommerce Code Reference

class-flex-layout-renderer.php

Source code

<?php
/**
 * This file is part of the WooCommerce Email Editor package
 *
 * @package Automattic\WooCommerce\EmailEditor
 */

declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Layout;

use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;

/**
 * This class provides functionality to render inner blocks of a block that supports reduced flex layout.
 */
class Flex_Layout_Renderer {
	/**
	 * Render inner blocks in flex layout.
	 *
	 * @param array             $parsed_block Parsed block.
	 * @param Rendering_Context $rendering_context Rendering context.
	 * @return string
	 */
	public function render_inner_blocks_in_layout( array $parsed_block, Rendering_Context $rendering_context ): string {
		$theme_styles    = $rendering_context->get_theme_styles();
		$flex_gap        = $theme_styles['spacing']['blockGap'] ?? '0px';
		$flex_gap_number = Styles_Helper::parse_value( $flex_gap );

		$margin_top = $parsed_block['email_attrs']['margin-top'] ?? '0px';
		$justify    = $rendering_context->resolve_text_align( $parsed_block['attrs']['layout']['justifyContent'] ?? null );
		$styles     = wp_style_engine_get_styles( $parsed_block['attrs']['style'] ?? array() )['css'] ?? '';
		$styles    .= 'margin-top: ' . $margin_top . ';';
		$styles    .= 'text-align: ' . $justify;

		list( $inner_blocks, $should_wrap ) = $this->compute_widths_for_flex_layout( $parsed_block, $flex_gap_number );

		if ( $should_wrap ) {
			return $this->render_wrapping_layout( $inner_blocks, $styles, $justify, $flex_gap, $rendering_context );
		}

		return $this->render_single_row_layout( $inner_blocks, $styles, $justify, $flex_gap, $rendering_context );
	}

	/**
	 * Render the inner blocks as a single, non-wrapping row.
	 *
	 * This is the default layout: a table row whose cells sit side by side. It's correct whenever
	 * the items are known to fit the available width (and it's what every explicitly-sized buttons
	 * block uses, since those are shrunk to fit by {@see compute_widths_for_flex_layout()}).
	 *
	 * @param array             $inner_blocks Inner blocks with computed widths.
	 * @param string            $styles Wrapper styles (already includes margin-top and text-align).
	 * @param string            $justify Resolved horizontal alignment (left/center/right).
	 * @param string            $flex_gap Gap between items (e.g. "16px").
	 * @param Rendering_Context $rendering_context Rendering context.
	 * @return string
	 */
	private function render_single_row_layout( array $inner_blocks, string $styles, string $justify, string $flex_gap, Rendering_Context $rendering_context ): string {
		// MS Outlook doesn't support style attribute in divs so we conditionally wrap the buttons in a table and repeat styles.
		$output_html = sprintf(
			'<!--[if mso | IE]><table align="%2$s" role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%%"><tr><td style="%1$s" ><![endif]-->
      <div style="%1$s"><table class="layout-flex-wrapper" style="display:inline-block;vertical-align:top"><tbody><tr>',
			esc_attr( $styles ),
			esc_attr( $justify )
		);

		foreach ( $inner_blocks as $key => $block ) {
			$item_styles = array();
			if ( $block['email_attrs']['layout_width'] ?? null ) {
				$item_styles['width'] = $block['email_attrs']['layout_width'];
			}
			if ( $key > 0 ) {
				$item_styles[ 'padding-' . $rendering_context->get_start_side() ] = $flex_gap;
			}
			$output_html .= '<td class="layout-flex-item" style="' . esc_attr( \WP_Style_Engine::compile_css( $item_styles, '' ) ) . '">' . render_block( $block ) . '</td>';
		}
		$output_html .= '</tr></table></div>
    <!--[if mso | IE]></td></tr></table><![endif]-->';

		return $output_html;
	}

	/**
	 * Render the inner blocks so they wrap when they don't fit on one line.
	 *
	 * Used for auto-width button rows (e.g. a footer navigation menu) whose combined width exceeds
	 * the available width. A table row can't reflow in email, so each item is emitted as an
	 * inline-block <div>: clients that honor it (Gmail desktop, Apple Mail, most webmail) flow the
	 * items and wrap them onto multiple lines instead of stretching the email past its content
	 * width. MS Outlook (Word engine) ignores inline-block and can't wrap a row either, so an
	 * Outlook-only <br> before each item after the first forces it to stack the buttons vertically
	 * — no overflow, at the cost of a vertical list rather than a grid. Fixes NL-737.
	 *
	 * @param array             $inner_blocks Inner blocks (at least one is auto-width in this path).
	 * @param string            $styles Wrapper styles (already includes margin-top and text-align).
	 * @param string            $justify Resolved horizontal alignment (left/center/right).
	 * @param string            $flex_gap Gap between items (e.g. "16px").
	 * @param Rendering_Context $rendering_context Rendering context.
	 * @return string
	 */
	private function render_wrapping_layout( array $inner_blocks, string $styles, string $justify, string $flex_gap, Rendering_Context $rendering_context ): string {
		// Outlook ignores style on divs, so repeat the wrapper styles on a conditional table cell.
		$output_html = sprintf(
			'<!--[if mso | IE]><table align="%2$s" role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%%"><tr><td style="%1$s" ><![endif]-->
      <div class="layout-flex-wrapper" style="%1$s">',
			esc_attr( $styles ),
			esc_attr( $justify )
		);

		// The gap padding must sit on the side away from the aligned (flush) edge, otherwise wrapped
		// lines carry an edge pad and misalign against each other. When items align to the end side
		// of the line flow (e.g. right in LTR), pad the start side of every item after the first;
		// otherwise (start-aligned or centered) pad the end side of every item before the last.
		$align_to_flow_end = $justify === $rendering_context->get_end_side();
		$last_key          = count( $inner_blocks ) - 1;

		foreach ( $inner_blocks as $key => $block ) {
			$item_styles = array(
				'display'        => 'inline-block',
				'vertical-align' => 'top',
				// Vertical gap so items that wrap onto a new line (and Outlook's stacked buttons) don't touch.
				'padding-bottom' => $flex_gap,
			);
			if ( $block['email_attrs']['layout_width'] ?? null ) {
				$item_styles['width'] = $block['email_attrs']['layout_width'];
			}
			if ( $align_to_flow_end && $key > 0 ) {
				$item_styles[ 'padding-' . $rendering_context->get_start_side() ] = $flex_gap;
			} elseif ( ! $align_to_flow_end && $key < $last_key ) {
				$item_styles[ 'padding-' . $rendering_context->get_end_side() ] = $flex_gap;
			}
			if ( $key > 0 ) {
				// Force a line break before every item after the first in Outlook, which can't wrap an inline row.
				$output_html .= '<!--[if mso | IE]><br><![endif]-->';
			}
			$output_html .= '<div class="layout-flex-item" style="' . esc_attr( \WP_Style_Engine::compile_css( $item_styles, '' ) ) . '">' . render_block( $block ) . '</div>';
		}
		$output_html .= '</div>
    <!--[if mso | IE]></td></tr></table><![endif]-->';

		return $output_html;
	}

	/**
	 * Compute widths for blocks in flex layout and decide whether the row must wrap.
	 *
	 * @param array $parsed_block Parsed block.
	 * @param float $flex_gap Flex gap.
	 * @return array{0: array, 1: bool} The inner blocks (with computed widths) and whether the row
	 *                                  should wrap instead of rendering as a single row.
	 */
	private function compute_widths_for_flex_layout( array $parsed_block, float $flex_gap ): array {
		// When there is no parent width we can't compute widths so auto width will be used, and we
		// can't tell whether the items overflow — fall back to the (non-wrapping) single row.
		if ( ! isset( $parsed_block['email_attrs']['width'] ) ) {
			return array( $parsed_block['innerBlocks'] ?? array(), false );
		}
		$blocks_count     = count( $parsed_block['innerBlocks'] );
		$total_used_width = 0; // Total width assuming items without set width would consume proportional width.
		$parent_width     = Styles_Helper::parse_value( $parsed_block['email_attrs']['width'] );
		$inner_blocks     = $parsed_block['innerBlocks'] ?? array();
		$has_auto_width   = false;

		foreach ( $inner_blocks as $key => $block ) {
			$block_width_percent = ( $block['attrs']['width'] ?? 0 ) ? intval( $block['attrs']['width'] ) : 0;
			$block_width         = floor( $parent_width * ( $block_width_percent / 100 ) );
			// If width is not set, we assume it's 25% of the parent width.
			$total_used_width += $block_width ? $block_width : floor( $parent_width * ( 25 / 100 ) );

			if ( ! $block_width ) {
				$has_auto_width                                      = true;
				$inner_blocks[ $key ]['email_attrs']['layout_width'] = null; // Will be rendered as auto.
				continue;
			}
			$inner_blocks[ $key ]['email_attrs']['layout_width'] = $this->get_width_without_gap( $block_width, $flex_gap, $block_width_percent ) . 'px';
		}

		$overflows = $blocks_count > 1 && $total_used_width > $parent_width;

		// When there is only one block, or the items fit, render the single row as set by the user.
		if ( ! $overflows ) {
			return array( $inner_blocks, false );
		}

		// Auto-width items (e.g. a nav menu of buttons) can't be shrunk to fit, so an overflowing row
		// that contains any of them can only be kept inside the content width by wrapping — the
		// explicitly-sized items in the same row keep their width and flow alongside. A row made up
		// entirely of explicit-width items is instead scaled down proportionally so it still fits on
		// one row, preserving the existing behavior.
		if ( $has_auto_width ) {
			return array( $inner_blocks, true );
		}

		foreach ( $inner_blocks as $key => $block ) {
			$proportional_space_overflow   = $parent_width / $total_used_width;
			$block_width                   = $block['email_attrs']['layout_width'] ? Styles_Helper::parse_value( $block['email_attrs']['layout_width'] ) : 0;
			$block_proportional_width      = $block_width * $proportional_space_overflow;
			$block_proportional_percentage = ( $block_proportional_width / $parent_width ) * 100;
			$inner_blocks[ $key ]['email_attrs']['layout_width'] = $block_width ? $this->get_width_without_gap( $block_proportional_width, $flex_gap, $block_proportional_percentage ) . 'px' : null;
		}
		return array( $inner_blocks, false );
	}

	/**
	 * How much of width we will strip to keep some space for the gap
	 * This is computed based on CSS rule used in the editor:
	 * For block with width set to X percent
	 * width: calc(X% - (var(--wp--style--block-gap) * (100 - X)/100)));
	 *
	 * @param float $block_width Block width in pixels.
	 * @param float $flex_gap Flex gap in pixels.
	 * @param float $block_width_percent Block width in percent.
	 * @return int
	 */
	private function get_width_without_gap( float $block_width, float $flex_gap, float $block_width_percent ): int {
		$width_gap_reduction = $flex_gap * ( ( 100 - $block_width_percent ) / 100 );
		return intval( floor( $block_width - $width_gap_reduction ) );
	}
}