PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
← All changes | extensions/blocks/tiled-gallery/tiled-gallery.php +804 -9 13.7.2 → 16.3-a.7 View file →
@@ -10,13 +10,17 @@
10 10
11 11 namespace Automattic\Jetpack\Extensions;
12 12
13 13 use Automattic\Jetpack\Blocks;
14 -use Automattic\Jetpack\Current_Plan as Jetpack_Plan;
15 14 use Automattic\Jetpack\Status;
15 +use Automattic\Jetpack\Status\Host;
16 16 use Jetpack;
17 17 use Jetpack_Gutenberg;
18 18
19 +if ( ! defined( 'ABSPATH' ) ) {
20 + exit( 0 );
21 +}
22 +
19 23 /**
20 24 * Jetpack Tiled Gallery Block class
21 25 *
22 26 * @since 7.3
@@ -38,9 +42,10 @@
38 42 ) {
39 43 Blocks::jetpack_register_block(
40 44 __DIR__,
41 45 array(
42 - 'render_callback' => array( __CLASS__, 'render' ),
46 + 'render_callback' => array( __CLASS__, 'render' ),
47 + 'render_email_callback' => array( __CLASS__, 'render_email' ),
43 48 )
44 49 );
45 50 }
46 51 }
@@ -55,13 +60,17 @@
55 60 */
56 61 public static function render( $attr, $content ) {
57 62 Jetpack_Gutenberg::load_assets_as_required( __DIR__ );
58 63
64 + /*
65 + * Note that the image host here comes from whatever the editor baked into $content when the post
66 + * was last saved, so galleries published on a VIP site before the editor honoured that setting
67 + * still serve unreachable i0.wp.com URLs until someone saves the post again. Rewriting the host
68 + * from the data-url attribute below would fix those without an edit:
69 + * https://github.com/Automattic/jetpack/issues/51075
70 + */
59 71 $is_squareish_layout = self::is_squareish_layout( $attr );
60 72
61 - $jetpack_plan = Jetpack_Plan::get();
62 - wp_localize_script( 'jetpack-gallery-settings', 'jetpack_plan', array( 'data' => $jetpack_plan['product_slug'] ) );
63 -
64 73 if ( preg_match_all( '/<img [^>]+>/', $content, $images ) ) {
65 74 /**
66 75 * This block processes all of the images that are found and builds $find and $replace.
67 76 *
@@ -68,10 +77,12 @@
68 77 * The original img is added to the $find array and the replacement is made and added
69 78 * to the $replace array. This is so that the same find and replace operations can be
70 79 * made on the entire $content.
71 80 */
72 - $find = array();
73 - $replace = array();
81 + $find = array();
82 + $replace = array();
83 + $image_index = 0;
84 + $number_images = count( $images[0] );
74 85
75 86 foreach ( $images[0] as $image_html ) {
76 87 if (
77 88 preg_match( '/data-width="([0-9]+)"/', $image_html, $img_width )
@@ -77,8 +88,9 @@
77 88 preg_match( '/data-width="([0-9]+)"/', $image_html, $img_width )
78 89 && preg_match( '/data-height="([0-9]+)"/', $image_html, $img_height )
79 90 && preg_match( '/src="([^"]+)"/', $image_html, $img_src )
80 91 ) {
92 + ++$image_index;
81 93 // Drop img src query string so it can be used as a base to add photon params
82 94 // for the srcset.
83 95 $src_parts = explode( '?', $img_src[1], 2 );
84 96 $orig_src = $src_parts[0];
@@ -92,8 +104,20 @@
92 104 if ( ! $orig_width || ! $orig_height || ! $orig_src ) {
93 105 continue;
94 106 }
95 107
108 + // data-width and data-height describe the original upload, but the
109 + // candidates below are built on the src, which is usually a smaller
110 + // intermediate size. Photon never upscales, so sizing the srcset from
111 + // the original would advertise widths that resolve to a narrower image
112 + // than the browser was told to expect, and the tile renders soft on a
113 + // high density screen. Cap both to what the source file can produce.
114 + $source_dimensions = self::get_source_file_dimensions( $orig_src );
115 + if ( null !== $source_dimensions ) {
116 + $orig_width = min( $orig_width, $source_dimensions[0] );
117 + $orig_height = min( $orig_height, $source_dimensions[1] );
118 + }
119 +
96 120 $srcset_parts = array();
97 121 if ( $is_squareish_layout ) {
98 122 $min_width = min( self::IMG_SRCSET_WIDTH_MIN, $orig_width, $orig_height );
99 123 $max_width = min( self::IMG_SRCSET_WIDTH_MAX, $orig_width, $orig_height );
@@ -135,13 +159,15 @@
135 159 }
136 160 }
137 161 }
138 162
163 + $img_element = self::interactive_markup( $image_index, $number_images );
164 +
139 165 if ( ! empty( $srcset_parts ) ) {
140 166 $srcset = 'srcset="' . esc_attr( implode( ',', $srcset_parts ) ) . '"';
141 167
142 168 $find[] = $image_html;
143 - $replace[] = str_replace( '<img', '<img ' . $srcset, $image_html );
169 + $replace[] = str_replace( '<img', $img_element . $srcset, $image_html );
144 170 }
145 171 }
146 172 }
147 173
@@ -162,8 +188,56 @@
162 188 return apply_filters( 'jetpack_tiled_galleries_block_content', $content );
163 189 }
164 190
165 191 /**
192 + * Dimensions of the file an image URL points at, when it names a WordPress intermediate size.
193 + *
194 + * WordPress appends -WIDTHxHEIGHT to the file name of every size it generates, so
195 + * a URL ending that way tells us how large the file behind it actually is. Returns
196 + * null for anything else, including the original upload, whose size is unknowable
197 + * from the URL alone.
198 + *
199 + * @param string $url Image URL, with any query string already removed.
200 + * @return array|null Array of width and height, or null if the URL names no size.
201 + */
202 + private static function get_source_file_dimensions( $url ) {
203 + if ( ! preg_match( '/-(\d+)x(\d+)\.[a-zA-Z0-9]+$/', $url, $dimensions ) ) {
204 + return null;
205 + }
206 +
207 + return array( absint( $dimensions[1] ), absint( $dimensions[2] ) );
208 + }
209 +
210 + /**
211 + * Adds tabindex, role and aria-label markup for images that should be interactive (front-end only).
212 + *
213 + * @param integer $image_index Integer The current image index.
214 + * @param integer $number_images Integer The total number of images.
215 + */
216 + private static function interactive_markup( $image_index, $number_images ) {
217 +
218 + $host = new Host();
219 + $is_module_active = $host->is_wpcom_simple()
220 + ? get_option( 'carousel_enable_it' )
221 + : Jetpack::is_module_active( 'carousel' );
222 +
223 + if ( $is_module_active ) {
224 + $aria_label_content = sprintf(
225 + /* Translators: %1$d is the current image index, %2$d is the total number of images. */
226 + __( 'Open image %1$d of %2$d in full-screen', 'jetpack' ),
227 + $image_index,
228 + $number_images
229 + );
230 + // The trailing space matters: render() appends the srcset directly onto
231 + // this string, and without it the two run together as aria-label="…"srcset="…".
232 + $img_element = '<img role="button" tabindex="0" aria-label="' . esc_attr( $aria_label_content ) . '" ';
233 + } else {
234 + $img_element = '<img ';
235 + }
236 + return $img_element;
237 + }
238 +
239 + /**
166 240 * Determines whether a Tiled Gallery block uses square or circle images (1:1 ratio)
167 241 *
168 242 * Layouts are block styles and will be available as `is-style-[LAYOUT]` in the className
169 243 * attribute. The default (rectangular) will be omitted.
@@ -177,7 +251,728 @@
177 251 'is-style-square' === $attr['className']
178 252 || 'is-style-circle' === $attr['className']
179 253 );
180 254 }
255 +
256 + /**
257 + * Render tiled gallery block for email.
258 + *
259 + * @since 15.0
260 + *
261 + * @param string $block_content The original block HTML content.
262 + * @param array $parsed_block The parsed block data including attributes.
263 + * @param object $rendering_context Email rendering context.
264 + *
265 + * @return string
266 + */
267 + public static function render_email( $block_content, array $parsed_block, $rendering_context ) {
268 + // Validate input parameters and required dependencies
269 + if ( ! isset( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) ||
270 + ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper' ) ||
271 + ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper' ) ) {
272 + return '';
273 + }
274 +
275 + // Get spacing from email_attrs for better consistency with core blocks
276 + $email_attrs = $parsed_block['email_attrs'] ?? array();
277 + $table_margin_style = '';
278 +
279 + if ( ! empty( $email_attrs ) && class_exists( '\WP_Style_Engine' ) ) {
280 + // Get margin for table styling
281 + $table_margin_style = \WP_Style_Engine::compile_css( array_intersect_key( $email_attrs, array_flip( array( 'margin' ) ) ), '' ) ?? '';
282 + }
283 +
284 + // Email cell padding
285 + $email_cell_padding = 2; // Cell padding
286 +
287 + $attr = $parsed_block['attrs'];
288 +
289 + // Determine layout style and columns from attributes (needed for both image processing and layout building)
290 + $layout_info = self::get_layout_style_from_attributes( $attr );
291 +
292 + // Process images for email rendering
293 + $images = self::process_tiled_gallery_images_for_email( $attr, $layout_info );
294 +
295 + if ( empty( $images ) ) {
296 + return '';
297 + }
298 +
299 + // Determine target width from the email layout if available
300 + $target_width = self::get_email_target_width( $rendering_context );
301 +
302 + // Build layout content based on style and columns
303 + $grid_content = self::build_email_layout_content( $images, $layout_info, $email_cell_padding, $attr );
304 +
305 + // Use Table_Wrapper_Helper for consistent email rendering
306 + $table_style = sprintf( 'width: 100%%; max-width: %dpx; padding: 0; border-collapse: collapse;', $target_width );
307 + if ( ! empty( $table_margin_style ) ) {
308 + $table_style = $table_margin_style . '; ' . $table_style;
309 + } else {
310 + $table_style = 'margin: 16px 0; ' . $table_style;
311 + }
312 +
313 + $image_table_attrs = array(
314 + 'style' => $table_style,
315 + );
316 +
317 + $html = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper( $grid_content, $image_table_attrs );
318 +
319 + return $html;
320 + }
321 +
322 + /**
323 + * Get target width for email rendering.
324 + *
325 + * @param object $rendering_context Email rendering context.
326 + * @return int Target width in pixels.
327 + */
328 + private static function get_email_target_width( $rendering_context ) {
329 + $target_width = 600; // Default
330 +
331 + if ( ! empty( $rendering_context ) && is_object( $rendering_context ) && method_exists( $rendering_context, 'get_layout_width_without_padding' ) ) {
332 + $layout_width_px = $rendering_context->get_layout_width_without_padding();
333 + if ( is_string( $layout_width_px ) ) {
334 + $parsed_width = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper::parse_value( $layout_width_px );
335 + if ( $parsed_width > 0 ) {
336 + $target_width = $parsed_width;
337 + }
338 + }
339 + }
340 +
341 + return $target_width;
342 + }
343 +
344 + /**
345 + * Process tiled gallery images for email rendering.
346 + *
347 + * @param array $attr Block attributes containing image data.
348 + * @param array $layout_info Layout information from get_layout_style_from_attributes.
349 + * @return array Processed image data for email rendering.
350 + */
351 + private static function process_tiled_gallery_images_for_email( $attr, $layout_info ) {
352 + $images = array();
353 +
354 + // Determine if this is a squareish layout
355 + $is_squareish = in_array( $layout_info['style'], array( 'square', 'circle' ), true );
356 +
357 + // Get images from IDs (primary data source)
358 + if ( ! empty( $attr['ids'] ) && is_array( $attr['ids'] ) ) {
359 + foreach ( $attr['ids'] as $id ) {
360 + // Validate ID is a positive integer and attachment exists
361 + $id = absint( $id );
362 + if ( ! $id || ! wp_attachment_is_image( $id ) ) {
363 + continue;
364 + }
365 +
366 + // For square/circle layouts, get a high-quality square crop
367 + if ( $is_squareish ) {
368 + // Start with full size image for better quality when resizing
369 + $image_url = wp_get_attachment_image_url( $id, 'full' );
370 +
371 + // If we have Photon/Jetpack image processing, request high-quality square crop
372 + if ( function_exists( 'jetpack_photon_url' ) && $image_url ) {
373 + $image_url = add_query_arg(
374 + array(
375 + 'resize' => '1300,1300', // High-quality square crop for email
376 + 'crop' => '1',
377 + ),
378 + $image_url
379 + );
380 + }
381 + } else {
382 + $image_url = wp_get_attachment_image_url( $id, 'large' );
383 + }
384 +
385 + // Sanitize alt text from post meta
386 + $alt_text = get_post_meta( $id, '_wp_attachment_image_alt', true );
387 + $alt_text = sanitize_text_field( $alt_text );
388 +
389 + if ( $image_url ) {
390 + $images[] = array(
391 + 'url' => $image_url,
392 + 'alt' => $alt_text,
393 + 'id' => $id,
394 + );
395 + }
396 + }
397 + } elseif ( ! empty( $attr['images'] ) && is_array( $attr['images'] ) ) {
398 + // Fall back to images array if IDs aren't available
399 + foreach ( $attr['images'] as $image_data ) {
400 + if ( ! empty( $image_data['url'] ) ) {
401 + // Validate and sanitize URL
402 + $url = esc_url_raw( $image_data['url'] );
403 + if ( ! $url || ! wp_http_validate_url( $url ) ) {
404 + continue;
405 + }
406 +
407 + // Sanitize alt text
408 + $alt_text = ! empty( $image_data['alt'] ) ? sanitize_text_field( $image_data['alt'] ) : '';
409 +
410 + // Validate ID if present
411 + $id = ! empty( $image_data['id'] ) ? absint( $image_data['id'] ) : 0;
412 +
413 + $images[] = array(
414 + 'url' => $url,
415 + 'alt' => $alt_text,
416 + 'id' => $id,
417 + );
418 + }
419 + }
420 + }
421 +
422 + return $images;
423 + }
424 +
425 + /**
426 + * Get layout style and columns from block attributes.
427 + *
428 + * @param array $attr Block attributes.
429 + * @return array Array with 'style', 'columns', and 'border_radius' keys.
430 + */
431 + private static function get_layout_style_from_attributes( $attr ) {
432 + $layout_info = array(
433 + 'style' => 'rectangular', // Default to rectangular/mosaic layout
434 + 'columns' => 3, // Default to 3 columns
435 + 'border_radius' => 0, // Default to no border radius
436 + );
437 +
438 + // Get number of columns from attributes with validation
439 + if ( ! empty( $attr['columns'] ) && is_numeric( $attr['columns'] ) ) {
440 + $columns = absint( $attr['columns'] );
441 + // Clamp columns between 1 and 6 for reasonable layouts
442 + $layout_info['columns'] = max( 1, min( 6, $columns ) );
443 + }
444 +
445 + // Get border radius from roundedCorners attribute (preferred method)
446 + if ( ! empty( $attr['roundedCorners'] ) && is_numeric( $attr['roundedCorners'] ) ) {
447 + $border_radius_value = absint( $attr['roundedCorners'] );
448 + // Clamp value between 0 and 20
449 + $layout_info['border_radius'] = max( 0, min( 20, $border_radius_value ) );
450 + }
451 +
452 + // Get layout style and border radius from className
453 + if ( ! empty( $attr['className'] ) ) {
454 + if ( str_contains( $attr['className'], 'is-style-square' ) ) {
455 + $layout_info['style'] = 'square';
456 + } elseif ( str_contains( $attr['className'], 'is-style-circle' ) ) {
457 + $layout_info['style'] = 'circle';
458 + } elseif ( str_contains( $attr['className'], 'is-style-columns' ) ) {
459 + $layout_info['style'] = 'columns';
460 + }
461 +
462 + // Extract border radius from has-rounded-corners-{value} class (fallback method)
463 + if ( $layout_info['border_radius'] === 0 && preg_match( '/has-rounded-corners-(\d+)/', $attr['className'], $matches ) ) {
464 + $border_radius_value = absint( $matches[1] );
465 + // Clamp value between 0 and 20
466 + $layout_info['border_radius'] = max( 0, min( 20, $border_radius_value ) );
467 + }
468 + }
469 +
470 + return $layout_info;
471 + }
472 +
473 + /**
474 + * Build email layout content based on layout style and columns.
475 + *
476 + * @param array $images Array of image data.
477 + * @param array $layout_info Array with 'style' and 'columns' keys.
478 + * @param int $cell_padding Cell padding.
479 + * @param array $attr Block attributes.
480 + * @return string HTML content.
481 + */
482 + private static function build_email_layout_content( $images, $layout_info, $cell_padding, $attr ) {
483 + $layout_style = $layout_info['style'];
484 + $columns = $layout_info['columns'];
485 + $border_radius = $layout_info['border_radius'];
486 +
487 + switch ( $layout_style ) {
488 + case 'square':
489 + return self::build_square_layout_content( $images, $cell_padding, $columns, 'square', $border_radius, $attr );
490 + case 'circle':
491 + return self::build_square_layout_content( $images, $cell_padding, $columns, 'circle', $border_radius, $attr );
492 + case 'columns':
493 + return self::build_columns_layout_content( $images, $cell_padding, $columns, $border_radius, $attr );
494 + case 'rectangular':
495 + default:
496 + return self::build_mosaic_layout_content( $images, $cell_padding, $border_radius, $attr );
497 + }
498 + }
499 +
500 + /**
501 + * Build square/circle layout content.
502 + *
503 + * @param array $images Array of image data.
504 + * @param int $cell_padding Cell padding.
505 + * @param int $columns Number of columns for the layout.
506 + * @param string $style Layout style (square or circle).
507 + * @param int $border_radius Border radius value (0-20).
508 + * @param array $attr Block attributes.
509 + * @return string HTML content.
510 + */
511 + private static function build_square_layout_content( $images, $cell_padding, $columns, $style = 'square', $border_radius = 0, $attr = array() ) {
512 + $content_parts = array();
513 +
514 + // Create rows of images with hierarchical chunks for square/circle layouts
515 + $image_chunks = self::create_hierarchical_chunks( $images, $columns );
516 +
517 + $border_radius_style = self::generate_border_radius_style( $style, $border_radius );
518 +
519 + foreach ( $image_chunks as $row_images ) {
520 + $images_in_row = count( $row_images );
521 + $cell_width_percent = ( 100 / $images_in_row );
522 +
523 + // Build table cells for this row
524 + $row_cells = '';
525 + foreach ( $row_images as $image ) {
526 + // Calculate cell attributes with consistent padding
527 + $cell_attrs = array(
528 + 'style' => sprintf(
529 + 'width: %s%%; padding: %dpx; vertical-align: top; text-align: center;',
530 + $cell_width_percent,
531 + $cell_padding
532 + ),
533 + );
534 +
535 + $image_styles = self::generate_image_styles( false );
536 +
537 + $cell_content = self::generate_image_html( $image, $image_styles, $border_radius_style, $attr );
538 +
539 + $row_cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
540 + $cell_content,
541 + $cell_attrs
542 + );
543 + }
544 +
545 + // Use Table_Wrapper_Helper for email-compatible table rendering
546 + $table_attrs = array(
547 + 'style' => 'width: 100%; border-collapse: collapse;',
548 + );
549 +
550 + $content_parts[] = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
551 + $row_cells,
552 + $table_attrs
553 + );
554 + }
555 +
556 + // Use Table_Wrapper_Helper for consistent email rendering
557 + $wrapper_attrs = array(
558 + 'style' => 'width: 100%; border-collapse: collapse;',
559 + );
560 +
561 + return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
562 + implode( '', $content_parts ),
563 + $wrapper_attrs
564 + );
565 + }
566 +
567 + /**
568 + * Build columns layout content using mosaic logic organized into columns.
569 + *
570 + * @param array $images Array of image data.
571 + * @param int $cell_padding Cell padding.
572 + * @param int $columns Number of columns for the layout.
573 + * @param int $border_radius Border radius value (0-20).
574 + * @param array $attr Block attributes.
575 + * @return string HTML content.
576 + */
577 + private static function build_columns_layout_content( $images, $cell_padding, $columns, $border_radius = 0, $attr = array() ) {
578 + $content_parts = array();
579 + $border_radius_style = self::generate_border_radius_style( '', $border_radius );
580 +
581 + // Distribute images across columns using round-robin approach for better balance
582 + $column_arrays = array_fill( 0, $columns, array() );
583 + foreach ( $images as $index => $image ) {
584 + $column_index = $index % $columns;
585 + $column_arrays[ $column_index ][] = $image;
586 + }
587 +
588 + // Build table cells for columns layout
589 + $row_cells = '';
590 + foreach ( $column_arrays as $column_images ) {
591 + if ( empty( $column_images ) ) {
592 + // Add empty cell for balance
593 + $cell_attrs = array(
594 + 'style' => sprintf(
595 + 'width: %s%%; padding: %dpx; vertical-align: top;',
596 + ( 100 / $columns ),
597 + $cell_padding
598 + ),
599 + );
600 + $row_cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
601 + '',
602 + $cell_attrs
603 + );
604 + continue;
605 + }
606 +
607 + // Calculate cell attributes
608 + $cell_width_percent = ( 100 / $columns );
609 + $cell_attrs = array(
610 + 'style' => sprintf(
611 + 'width: %s%%; padding: %dpx; vertical-align: top;',
612 + $cell_width_percent,
613 + $cell_padding
614 + ),
615 + );
616 +
617 + // Generate mosaic-style groupings within this column
618 + $column_rows = self::generate_column_mosaic_rows( $column_images );
619 +
620 + $cell_content = '';
621 + foreach ( $column_rows as $row_index => $row_images ) {
622 + foreach ( $row_images as $image ) {
623 + $image_styles = self::generate_image_styles( false );
624 +
625 + // Add top margin to all images except the first one in the column
626 + if ( $row_index > 0 || $cell_content !== '' ) {
627 + $image_styles .= ' margin-top: ' . ( $cell_padding * 2 ) . 'px;';
628 + }
629 +
630 + $cell_content .= self::generate_image_html( $image, $image_styles, $border_radius_style, $attr );
631 + }
632 + }
633 +
634 + $row_cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
635 + $cell_content,
636 + $cell_attrs
637 + );
638 + }
639 +
640 + // Use Table_Wrapper_Helper for email-compatible table rendering
641 + $table_attrs = array(
642 + 'style' => 'width: 100%; border-collapse: collapse;',
643 + );
644 +
645 + $content_parts[] = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
646 + $row_cells,
647 + $table_attrs
648 + );
649 +
650 + // Use Table_Wrapper_Helper for consistent email rendering
651 + $wrapper_attrs = array(
652 + 'style' => 'width: 100%; border-collapse: collapse;',
653 + );
654 +
655 + return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
656 + implode( '', $content_parts ),
657 + $wrapper_attrs
658 + );
659 + }
660 +
661 + /**
662 + * Build mosaic layout content with flexible row/column structure.
663 + *
664 + * @param array $images Array of image data.
665 + * @param int $cell_padding Cell padding.
666 + * @param int $border_radius Border radius value (0-20).
667 + * @param array $attr Block attributes.
668 + * @return string HTML content.
669 + */
670 + private static function build_mosaic_layout_content( $images, $cell_padding, $border_radius = 0, $attr = array() ) {
671 + $border_radius_style = self::generate_border_radius_style( '', $border_radius );
672 +
673 + // Generate mosaic layout rows
674 + $rows = self::generate_mosaic_rows( $images );
675 +
676 + // Determine the maximum number of columns to ensure consistent layout
677 + $max_columns = 0;
678 + foreach ( $rows as $row ) {
679 + $max_columns = max( $max_columns, count( $row ) );
680 + }
681 +
682 + // Build each row as a separate table to match flexbox behavior
683 + $content_parts = array();
684 + foreach ( $rows as $row ) {
685 + $images_in_row = count( $row );
686 +
687 + // Calculate width for each cell in this row (like flexbox)
688 + $cell_width_percent = ( 100 / $images_in_row );
689 +
690 + // Build table cells for this row
691 + $row_cells = '';
692 + foreach ( $row as $image ) {
693 + $cell_style = sprintf(
694 + 'width: %s%%; padding: %dpx; vertical-align: top; text-align: center;',
695 + $cell_width_percent,
696 + $cell_padding
697 + );
698 +
699 + // Set consistent height for all images in this row to ensure alignment
700 + // Use progressive enhancement: object-fit for supported clients, natural layout for others
701 + $image_styles = self::generate_image_styles( true );
702 +
703 + $cell_content = self::generate_image_html( $image, $image_styles, $border_radius_style, $attr );
704 +
705 + $row_cells .= sprintf(
706 + '<td style="%s">%s</td>',
707 + esc_attr( $cell_style ),
708 + $cell_content
709 + );
710 + }
711 +
712 + // Create a separate table for each row with flexible height for alignment
713 + $row_table = sprintf(
714 + '<table role="presentation" style="width: 100%%; border-collapse: collapse; table-layout: fixed;"><tr>%s</tr></table>',
715 + $row_cells
716 + );
717 +
718 + $content_parts[] = $row_table;
719 + }
720 +
721 + // Use Table_Wrapper_Helper for the main container
722 + $table_attrs = array(
723 + 'style' => 'width: 100%; border-collapse: collapse;',
724 + );
725 +
726 + return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
727 + implode( '', $content_parts ),
728 + $table_attrs
729 + );
730 + }
731 +
732 + /**
733 + * Generate mosaic layout rows based on image count.
734 + *
735 + * @param array $images Array of image data.
736 + * @return array Array of rows, each containing images.
737 + */
738 + private static function generate_mosaic_rows( $images ) {
739 + $rows = array();
740 + $image_count = count( $images );
741 +
742 + // More sophisticated mosaic algorithm based on image count
743 + if ( $image_count <= 3 ) {
744 + // For 3 or fewer images, use simple layout
745 + $rows[] = $images;
746 + } else {
747 + // For more images, create varied row patterns
748 + $patterns = array(
749 + 4 => array( 2, 2 ), // 4 images: 2 + 2
750 + 5 => array( 2, 3 ), // 5 images: 2 + 3
751 + 6 => array( 3, 3 ), // 6 images: 3 + 3
752 + 7 => array( 3, 2, 2 ), // 7 images: 3 + 2 + 2
753 + 8 => array( 3, 3, 2 ), // 8 images: 3 + 3 + 2
754 + 9 => array( 3, 3, 3 ), // 9 images: 3 + 3 + 3
755 + );
756 +
757 + if ( isset( $patterns[ $image_count ] ) ) {
758 + // Use predefined pattern for 4-9 images
759 + $pattern = $patterns[ $image_count ];
760 + $image_index = 0;
761 +
762 + foreach ( $pattern as $images_in_row ) {
763 + $row = array();
764 + for ( $i = 0; $i < $images_in_row; $i++ ) {
765 + $row[] = $images[ $image_index ];
766 + ++$image_index;
767 + }
768 + $rows[] = $row;
769 + }
770 + } else {
771 + // For 10+ images, create rows of 3 with remainder handling
772 + $full_rows = intval( $image_count / 3 );
773 + $remainder = $image_count % 3;
774 +
775 + $image_index = 0;
776 +
777 + // Create full rows of 3
778 + for ( $row = 0; $row < $full_rows; $row++ ) {
779 + $rows[] = array(
780 + $images[ $image_index ],
781 + $images[ $image_index + 1 ],
782 + $images[ $image_index + 2 ],
783 + );
784 + $image_index += 3;
785 + }
786 +
787 + // Handle remainder
788 + if ( $remainder > 0 ) {
789 + $remaining = array_slice( $images, $image_index );
790 + $rows[] = $remaining;
791 + }
792 + }
793 + }
794 +
795 + return $rows;
796 + }
797 +
798 + /**
799 + * Generate mosaic-style rows within a single column for columns layout.
800 + *
801 + * @param array $images Array of image data for this column.
802 + * @return array Array of rows, each containing 1-2 images for variety.
803 + */
804 + private static function generate_column_mosaic_rows( $images ) {
805 + $rows = array();
806 + $image_count = count( $images );
807 +
808 + if ( $image_count <= 2 ) {
809 + // For 2 or fewer images, each gets its own row
810 + foreach ( $images as $image ) {
811 + $rows[] = array( $image );
812 + }
813 + } else {
814 + // Create varied patterns: mix of single and paired images
815 + $image_index = 0;
816 +
817 + while ( $image_index < $image_count ) {
818 + $remaining = $image_count - $image_index;
819 +
820 + if ( $remaining === 1 ) {
821 + // Last image - single row
822 + $rows[] = array( $images[ $image_index ] );
823 + ++$image_index;
824 + } elseif ( $remaining === 3 ) {
825 + // 3 remaining - do 1 + 2 for better balance
826 + $rows[] = array( $images[ $image_index ] );
827 + ++$image_index;
828 + $rows[] = array( $images[ $image_index ], $images[ $image_index + 1 ] );
829 + $image_index += 2;
830 + } else {
831 + // 2 or more remaining - alternate between single and pairs
832 + $use_pair = ( count( $rows ) % 2 === 1 ); // Alternate pattern
833 +
834 + if ( $use_pair && $remaining >= 2 ) {
835 + // Create a pair
836 + $rows[] = array( $images[ $image_index ], $images[ $image_index + 1 ] );
837 + $image_index += 2;
838 + } else {
839 + // Single image
840 + $rows[] = array( $images[ $image_index ] );
841 + ++$image_index;
842 + }
843 + }
844 + }
845 + }
846 +
847 + return $rows;
848 + }
849 +
850 + /**
851 + * Generate border radius style based on layout style and border radius value.
852 + *
853 + * @param string $style Layout style (square, circle, etc.).
854 + * @param int $border_radius Border radius value.
855 + * @return string CSS border-radius style.
856 + */
857 + private static function generate_border_radius_style( $style, $border_radius ) {
858 + if ( 'circle' === $style ) {
859 + return 'border-radius:50%;';
860 + } elseif ( $border_radius > 0 ) {
861 + return 'border-radius:' . $border_radius . 'px;';
862 + }
863 + return '';
864 + }
865 +
866 + /**
867 + * Generate image styles for email rendering.
868 + *
869 + * @param bool $use_fixed_height Whether to use fixed height with object-fit.
870 + * @return string CSS style string.
871 + */
872 + private static function generate_image_styles( $use_fixed_height = false ) {
873 + $base_styles = 'margin: 0; width: 100%; max-width: 100%; display: block;';
874 +
875 + if ( $use_fixed_height ) {
876 + return $base_styles . ' height: 200px; object-fit: cover; object-position: center;';
877 + }
878 +
879 + return $base_styles . ' height: auto;';
880 + }
881 +
882 + /**
883 + * Generate image HTML with consistent styling.
884 + *
885 + * @param array $image Image data array.
886 + * @param string $additional_styles Additional CSS styles.
887 + * @param string $border_radius_style Border radius CSS.
888 + * @param array $attr Block attributes (optional, for link processing).
889 + * @return string Image HTML.
890 + */
891 + private static function generate_image_html( $image, $additional_styles = '', $border_radius_style = '', $attr = array() ) {
892 + $base_styles = 'border:none;background-color:#0000001a;display:block;height:auto;max-width:100%;padding:0;';
893 + $combined_styles = $base_styles . $additional_styles . $border_radius_style;
894 +
895 + $img_html = sprintf(
896 + '<img alt="%s" src="%s" style="%s" />',
897 + esc_attr( $image['alt'] ),
898 + esc_url( $image['url'] ),
899 + $combined_styles
900 + );
901 +
902 + // Handle link settings for email
903 + $link_to = ! empty( $attr['linkTo'] ) ? $attr['linkTo'] : 'none';
904 + $href = self::get_image_link_href( $image, $attr, $link_to );
905 +
906 + if ( ! empty( $href ) ) {
907 + return sprintf( '<a href="%s">%s</a>', esc_url( $href ), $img_html );
908 + }
909 +
910 + return $img_html;
911 + }
912 +
913 + /**
914 + * Get the href for an image based on link settings (used for email rendering).
915 + * Excludes custom links which email clients will replace with the image.
916 + *
917 + * @since 15.0
918 + *
919 + * @param array $image Image data array.
920 + * @param array $attr Block attributes.
921 + * @param string $link_to Link setting.
922 + * @return string The href URL or empty string.
923 + */
924 + private static function get_image_link_href( $image, $attr, $link_to ) {
925 + switch ( $link_to ) {
926 + case 'media':
927 + return ! empty( $image['url'] ) ? $image['url'] : '';
928 +
929 + case 'attachment':
930 + // For email, we need to generate the attachment page URL from the image ID
931 + if ( ! empty( $image['id'] ) ) {
932 + $attachment_url = get_permalink( $image['id'] );
933 + return $attachment_url ? $attachment_url : '';
934 + }
935 + return '';
936 + default:
937 + return '';
938 + }
939 + }
940 +
941 + /**
942 + * Create hierarchical chunks for square/circle layouts with larger items first.
943 + *
944 + * @param array $images Array of image data.
945 + * @param int $columns Number of columns for the layout.
946 + * @return array Array of rows with different sized chunks.
947 + */
948 + private static function create_hierarchical_chunks( $images, $columns ) {
949 + $image_count = count( $images );
950 + $chunks = array();
951 +
952 + if ( $image_count <= $columns ) {
953 + // For column count or fewer, single row
954 + $chunks[] = $images;
955 + } else {
956 + // Calculate remainder when dividing by columns
957 + $remainder = $image_count % $columns;
958 + $start_index = 0;
959 +
960 + // Handle all remainder cases to create proper hierarchy
961 + if ( $remainder > 0 ) {
962 + // Create a row with the remainder images (larger items first)
963 + $chunks[] = array_slice( $images, 0, $remainder );
964 + $start_index = $remainder;
965 + }
966 + // If remainder === 0, start_index stays 0
967 +
968 + // Rest in groups of $columns
969 + $remaining = array_slice( $images, $start_index );
970 + $remaining_chunks = array_chunk( $remaining, $columns );
971 + $chunks = array_merge( $chunks, $remaining_chunks );
972 + }
973 +
974 + return $chunks;
975 + }
181 976 }
182 977
183 -Tiled_Gallery::register();
978 +add_action( 'init', array( Tiled_Gallery::class, 'register' ) );