PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.0-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.0-beta
16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / extensions / blocks / tiled-gallery / tiled-gallery.php
jetpack / extensions / blocks / tiled-gallery Last commit date
tiled-gallery.php 7 months ago
tiled-gallery.php
944 lines
1 <?php //phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2 /**
3 * Tiled Gallery block.
4 * Relies on Photon, but can be used even when the module is not active.
5 *
6 * @since 6.9.0
7 *
8 * @package automattic/jetpack
9 */
10
11 namespace Automattic\Jetpack\Extensions;
12
13 use Automattic\Jetpack\Blocks;
14 use Automattic\Jetpack\Current_Plan as Jetpack_Plan;
15 use Automattic\Jetpack\Status;
16 use Automattic\Jetpack\Status\Host;
17 use Jetpack;
18 use Jetpack_Gutenberg;
19
20 if ( ! defined( 'ABSPATH' ) ) {
21 exit( 0 );
22 }
23
24 /**
25 * Jetpack Tiled Gallery Block class
26 *
27 * @since 7.3
28 */
29 class Tiled_Gallery {
30 /* Values for building srcsets */
31 const IMG_SRCSET_WIDTH_MAX = 2000;
32 const IMG_SRCSET_WIDTH_MIN = 600;
33 const IMG_SRCSET_WIDTH_STEP = 300;
34
35 /**
36 * Register the block
37 */
38 public static function register() {
39 if (
40 ( defined( 'IS_WPCOM' ) && IS_WPCOM )
41 || Jetpack::is_connection_ready()
42 || ( new Status() )->is_offline_mode()
43 ) {
44 Blocks::jetpack_register_block(
45 __DIR__,
46 array(
47 'render_callback' => array( __CLASS__, 'render' ),
48 'render_email_callback' => array( __CLASS__, 'render_email' ),
49 )
50 );
51 }
52 }
53
54 /**
55 * Tiled gallery block registration
56 *
57 * @param array $attr Array containing the block attributes.
58 * @param string $content String containing the block content.
59 *
60 * @return string
61 */
62 public static function render( $attr, $content ) {
63 Jetpack_Gutenberg::load_assets_as_required( __DIR__ );
64
65 $is_squareish_layout = self::is_squareish_layout( $attr );
66 // For backward compatibility (ensuring Tiled Galleries using now deprecated versions of the block are not affected).
67 // See isVIP() in utils/index.js.
68 $jetpack_plan = Jetpack_Plan::get();
69 wp_localize_script( 'jetpack-gallery-settings', 'jetpack_plan', array( 'data' => $jetpack_plan['product_slug'] ) );
70
71 if ( preg_match_all( '/<img [^>]+>/', $content, $images ) ) {
72 /**
73 * This block processes all of the images that are found and builds $find and $replace.
74 *
75 * The original img is added to the $find array and the replacement is made and added
76 * to the $replace array. This is so that the same find and replace operations can be
77 * made on the entire $content.
78 */
79 $find = array();
80 $replace = array();
81 $image_index = 0;
82 $number_images = count( $images[0] );
83
84 foreach ( $images[0] as $image_html ) {
85 if (
86 preg_match( '/data-width="([0-9]+)"/', $image_html, $img_width )
87 && preg_match( '/data-height="([0-9]+)"/', $image_html, $img_height )
88 && preg_match( '/src="([^"]+)"/', $image_html, $img_src )
89 ) {
90 ++$image_index;
91 // Drop img src query string so it can be used as a base to add photon params
92 // for the srcset.
93 $src_parts = explode( '?', $img_src[1], 2 );
94 $orig_src = $src_parts[0];
95 $orig_height = absint( $img_height[1] );
96 $orig_width = absint( $img_width[1] );
97
98 // Because URLs are already "photon", the photon function used short-circuits
99 // before ssl is added. Detect ssl and add is if necessary.
100 $is_ssl = ! empty( $src_parts[1] ) && str_contains( $src_parts[1], 'ssl=1' );
101
102 if ( ! $orig_width || ! $orig_height || ! $orig_src ) {
103 continue;
104 }
105
106 $srcset_parts = array();
107 if ( $is_squareish_layout ) {
108 $min_width = min( self::IMG_SRCSET_WIDTH_MIN, $orig_width, $orig_height );
109 $max_width = min( self::IMG_SRCSET_WIDTH_MAX, $orig_width, $orig_height );
110
111 for ( $w = $min_width; $w <= $max_width; $w = min( $max_width, $w + self::IMG_SRCSET_WIDTH_STEP ) ) {
112 $srcset_src = add_query_arg(
113 array(
114 'resize' => $w . ',' . $w,
115 'strip' => 'info',
116 ),
117 $orig_src
118 );
119 if ( $is_ssl ) {
120 $srcset_src = add_query_arg( 'ssl', '1', $srcset_src );
121 }
122 $srcset_parts[] = esc_url( $srcset_src ) . ' ' . $w . 'w';
123 if ( $w >= $max_width ) {
124 break;
125 }
126 }
127 } else {
128 $min_width = min( self::IMG_SRCSET_WIDTH_MIN, $orig_width );
129 $max_width = min( self::IMG_SRCSET_WIDTH_MAX, $orig_width );
130
131 for ( $w = $min_width; $w <= $max_width; $w = min( $max_width, $w + self::IMG_SRCSET_WIDTH_STEP ) ) {
132 $srcset_src = add_query_arg(
133 array(
134 'strip' => 'info',
135 'w' => $w,
136 ),
137 $orig_src
138 );
139 if ( $is_ssl ) {
140 $srcset_src = add_query_arg( 'ssl', '1', $srcset_src );
141 }
142 $srcset_parts[] = esc_url( $srcset_src ) . ' ' . $w . 'w';
143 if ( $w >= $max_width ) {
144 break;
145 }
146 }
147 }
148
149 $img_element = self::interactive_markup( $image_index, $number_images );
150
151 if ( ! empty( $srcset_parts ) ) {
152 $srcset = 'srcset="' . esc_attr( implode( ',', $srcset_parts ) ) . '"';
153
154 $find[] = $image_html;
155 $replace[] = str_replace( '<img', $img_element . $srcset, $image_html );
156 }
157 }
158 }
159
160 if ( ! empty( $find ) ) {
161 $content = str_replace( $find, $replace, $content );
162 }
163 }
164
165 /**
166 * Filter the output of the Tiled Galleries content.
167 *
168 * @module tiled-gallery
169 *
170 * @since 6.9.0
171 *
172 * @param string $content Tiled Gallery block content.
173 */
174 return apply_filters( 'jetpack_tiled_galleries_block_content', $content );
175 }
176
177 /**
178 * Adds tabindex, role and aria-label markup for images that should be interactive (front-end only).
179 *
180 * @param integer $image_index Integer The current image index.
181 * @param integer $number_images Integer The total number of images.
182 */
183 private static function interactive_markup( $image_index, $number_images ) {
184
185 $host = new Host();
186 $is_module_active = $host->is_wpcom_simple()
187 ? get_option( 'carousel_enable_it' )
188 : Jetpack::is_module_active( 'carousel' );
189
190 if ( $is_module_active ) {
191 $aria_label_content = sprintf(
192 /* Translators: %1$d is the current image index, %2$d is the total number of images. */
193 __( 'Open image %1$d of %2$d in full-screen', 'jetpack' ),
194 $image_index,
195 $number_images
196 );
197 $img_element = '<img role="button" tabindex="0" aria-label="' . esc_attr( $aria_label_content ) . '"';
198 } else {
199 $img_element = '<img ';
200 }
201 return $img_element;
202 }
203
204 /**
205 * Determines whether a Tiled Gallery block uses square or circle images (1:1 ratio)
206 *
207 * Layouts are block styles and will be available as `is-style-[LAYOUT]` in the className
208 * attribute. The default (rectangular) will be omitted.
209 *
210 * @param array $attr Attributes key/value array.
211 * @return boolean True if layout is squareish, otherwise false.
212 */
213 private static function is_squareish_layout( $attr ) {
214 return isset( $attr['className'] )
215 && (
216 'is-style-square' === $attr['className']
217 || 'is-style-circle' === $attr['className']
218 );
219 }
220
221 /**
222 * Render tiled gallery block for email.
223 *
224 * @since 15.0
225 *
226 * @param string $block_content The original block HTML content.
227 * @param array $parsed_block The parsed block data including attributes.
228 * @param object $rendering_context Email rendering context.
229 *
230 * @return string
231 */
232 public static function render_email( $block_content, array $parsed_block, $rendering_context ) {
233 // Validate input parameters and required dependencies
234 if ( ! isset( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) ||
235 ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper' ) ||
236 ! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper' ) ) {
237 return '';
238 }
239
240 // Get spacing from email_attrs for better consistency with core blocks
241 $email_attrs = $parsed_block['email_attrs'] ?? array();
242 $table_margin_style = '';
243
244 if ( ! empty( $email_attrs ) && class_exists( '\WP_Style_Engine' ) ) {
245 // Get margin for table styling
246 $table_margin_style = \WP_Style_Engine::compile_css( array_intersect_key( $email_attrs, array_flip( array( 'margin' ) ) ), '' ) ?? '';
247 }
248
249 // Email cell padding
250 $email_cell_padding = 2; // Cell padding
251
252 $attr = $parsed_block['attrs'];
253
254 // Determine layout style and columns from attributes (needed for both image processing and layout building)
255 $layout_info = self::get_layout_style_from_attributes( $attr );
256
257 // Process images for email rendering
258 $images = self::process_tiled_gallery_images_for_email( $attr, $layout_info );
259
260 if ( empty( $images ) ) {
261 return '';
262 }
263
264 // Determine target width from the email layout if available
265 $target_width = self::get_email_target_width( $rendering_context );
266
267 // Build layout content based on style and columns
268 $grid_content = self::build_email_layout_content( $images, $layout_info, $email_cell_padding, $attr );
269
270 // Use Table_Wrapper_Helper for consistent email rendering
271 $table_style = sprintf( 'width: 100%%; max-width: %dpx; padding: 0; border-collapse: collapse;', $target_width );
272 if ( ! empty( $table_margin_style ) ) {
273 $table_style = $table_margin_style . '; ' . $table_style;
274 } else {
275 $table_style = 'margin: 16px 0; ' . $table_style;
276 }
277
278 $image_table_attrs = array(
279 'style' => $table_style,
280 );
281
282 $html = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper( $grid_content, $image_table_attrs );
283
284 return $html;
285 }
286
287 /**
288 * Get target width for email rendering.
289 *
290 * @param object $rendering_context Email rendering context.
291 * @return int Target width in pixels.
292 */
293 private static function get_email_target_width( $rendering_context ) {
294 $target_width = 600; // Default
295
296 if ( ! empty( $rendering_context ) && is_object( $rendering_context ) && method_exists( $rendering_context, 'get_layout_width_without_padding' ) ) {
297 $layout_width_px = $rendering_context->get_layout_width_without_padding();
298 if ( is_string( $layout_width_px ) ) {
299 $parsed_width = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper::parse_value( $layout_width_px );
300 if ( $parsed_width > 0 ) {
301 $target_width = $parsed_width;
302 }
303 }
304 }
305
306 return $target_width;
307 }
308
309 /**
310 * Process tiled gallery images for email rendering.
311 *
312 * @param array $attr Block attributes containing image data.
313 * @param array $layout_info Layout information from get_layout_style_from_attributes.
314 * @return array Processed image data for email rendering.
315 */
316 private static function process_tiled_gallery_images_for_email( $attr, $layout_info ) {
317 $images = array();
318
319 // Determine if this is a squareish layout
320 $is_squareish = in_array( $layout_info['style'], array( 'square', 'circle' ), true );
321
322 // Get images from IDs (primary data source)
323 if ( ! empty( $attr['ids'] ) && is_array( $attr['ids'] ) ) {
324 foreach ( $attr['ids'] as $id ) {
325 // Validate ID is a positive integer and attachment exists
326 $id = absint( $id );
327 if ( ! $id || ! wp_attachment_is_image( $id ) ) {
328 continue;
329 }
330
331 // For square/circle layouts, get a high-quality square crop
332 if ( $is_squareish ) {
333 // Start with full size image for better quality when resizing
334 $image_url = wp_get_attachment_image_url( $id, 'full' );
335
336 // If we have Photon/Jetpack image processing, request high-quality square crop
337 if ( function_exists( 'jetpack_photon_url' ) && $image_url ) {
338 $image_url = add_query_arg(
339 array(
340 'resize' => '1300,1300', // High-quality square crop for email
341 'crop' => '1',
342 ),
343 $image_url
344 );
345 }
346 } else {
347 $image_url = wp_get_attachment_image_url( $id, 'large' );
348 }
349
350 // Sanitize alt text from post meta
351 $alt_text = get_post_meta( $id, '_wp_attachment_image_alt', true );
352 $alt_text = sanitize_text_field( $alt_text );
353
354 if ( $image_url ) {
355 $images[] = array(
356 'url' => $image_url,
357 'alt' => $alt_text,
358 'id' => $id,
359 );
360 }
361 }
362 } elseif ( ! empty( $attr['images'] ) && is_array( $attr['images'] ) ) {
363 // Fall back to images array if IDs aren't available
364 foreach ( $attr['images'] as $image_data ) {
365 if ( ! empty( $image_data['url'] ) ) {
366 // Validate and sanitize URL
367 $url = esc_url_raw( $image_data['url'] );
368 if ( ! $url || ! wp_http_validate_url( $url ) ) {
369 continue;
370 }
371
372 // Sanitize alt text
373 $alt_text = ! empty( $image_data['alt'] ) ? sanitize_text_field( $image_data['alt'] ) : '';
374
375 // Validate ID if present
376 $id = ! empty( $image_data['id'] ) ? absint( $image_data['id'] ) : 0;
377
378 $images[] = array(
379 'url' => $url,
380 'alt' => $alt_text,
381 'id' => $id,
382 );
383 }
384 }
385 }
386
387 return $images;
388 }
389
390 /**
391 * Get layout style and columns from block attributes.
392 *
393 * @param array $attr Block attributes.
394 * @return array Array with 'style', 'columns', and 'border_radius' keys.
395 */
396 private static function get_layout_style_from_attributes( $attr ) {
397 $layout_info = array(
398 'style' => 'rectangular', // Default to rectangular/mosaic layout
399 'columns' => 3, // Default to 3 columns
400 'border_radius' => 0, // Default to no border radius
401 );
402
403 // Get number of columns from attributes with validation
404 if ( ! empty( $attr['columns'] ) && is_numeric( $attr['columns'] ) ) {
405 $columns = absint( $attr['columns'] );
406 // Clamp columns between 1 and 6 for reasonable layouts
407 $layout_info['columns'] = max( 1, min( 6, $columns ) );
408 }
409
410 // Get border radius from roundedCorners attribute (preferred method)
411 if ( ! empty( $attr['roundedCorners'] ) && is_numeric( $attr['roundedCorners'] ) ) {
412 $border_radius_value = absint( $attr['roundedCorners'] );
413 // Clamp value between 0 and 20
414 $layout_info['border_radius'] = max( 0, min( 20, $border_radius_value ) );
415 }
416
417 // Get layout style and border radius from className
418 if ( ! empty( $attr['className'] ) ) {
419 if ( str_contains( $attr['className'], 'is-style-square' ) ) {
420 $layout_info['style'] = 'square';
421 } elseif ( str_contains( $attr['className'], 'is-style-circle' ) ) {
422 $layout_info['style'] = 'circle';
423 } elseif ( str_contains( $attr['className'], 'is-style-columns' ) ) {
424 $layout_info['style'] = 'columns';
425 }
426
427 // Extract border radius from has-rounded-corners-{value} class (fallback method)
428 if ( $layout_info['border_radius'] === 0 && preg_match( '/has-rounded-corners-(\d+)/', $attr['className'], $matches ) ) {
429 $border_radius_value = absint( $matches[1] );
430 // Clamp value between 0 and 20
431 $layout_info['border_radius'] = max( 0, min( 20, $border_radius_value ) );
432 }
433 }
434
435 return $layout_info;
436 }
437
438 /**
439 * Build email layout content based on layout style and columns.
440 *
441 * @param array $images Array of image data.
442 * @param array $layout_info Array with 'style' and 'columns' keys.
443 * @param int $cell_padding Cell padding.
444 * @param array $attr Block attributes.
445 * @return string HTML content.
446 */
447 private static function build_email_layout_content( $images, $layout_info, $cell_padding, $attr ) {
448 $layout_style = $layout_info['style'];
449 $columns = $layout_info['columns'];
450 $border_radius = $layout_info['border_radius'];
451
452 switch ( $layout_style ) {
453 case 'square':
454 return self::build_square_layout_content( $images, $cell_padding, $columns, 'square', $border_radius, $attr );
455 case 'circle':
456 return self::build_square_layout_content( $images, $cell_padding, $columns, 'circle', $border_radius, $attr );
457 case 'columns':
458 return self::build_columns_layout_content( $images, $cell_padding, $columns, $border_radius, $attr );
459 case 'rectangular':
460 default:
461 return self::build_mosaic_layout_content( $images, $cell_padding, $border_radius, $attr );
462 }
463 }
464
465 /**
466 * Build square/circle layout content.
467 *
468 * @param array $images Array of image data.
469 * @param int $cell_padding Cell padding.
470 * @param int $columns Number of columns for the layout.
471 * @param string $style Layout style (square or circle).
472 * @param int $border_radius Border radius value (0-20).
473 * @param array $attr Block attributes.
474 * @return string HTML content.
475 */
476 private static function build_square_layout_content( $images, $cell_padding, $columns, $style = 'square', $border_radius = 0, $attr = array() ) {
477 $content_parts = array();
478
479 // Create rows of images with hierarchical chunks for square/circle layouts
480 $image_chunks = self::create_hierarchical_chunks( $images, $columns );
481
482 $border_radius_style = self::generate_border_radius_style( $style, $border_radius );
483
484 foreach ( $image_chunks as $row_images ) {
485 $images_in_row = count( $row_images );
486 $cell_width_percent = ( 100 / $images_in_row );
487
488 // Build table cells for this row
489 $row_cells = '';
490 foreach ( $row_images as $image ) {
491 // Calculate cell attributes with consistent padding
492 $cell_attrs = array(
493 'style' => sprintf(
494 'width: %s%%; padding: %dpx; vertical-align: top; text-align: center;',
495 $cell_width_percent,
496 $cell_padding
497 ),
498 );
499
500 $image_styles = self::generate_image_styles( false );
501
502 $cell_content = self::generate_image_html( $image, $image_styles, $border_radius_style, $attr );
503
504 $row_cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
505 $cell_content,
506 $cell_attrs
507 );
508 }
509
510 // Use Table_Wrapper_Helper for email-compatible table rendering
511 $table_attrs = array(
512 'style' => 'width: 100%; border-collapse: collapse;',
513 );
514
515 $content_parts[] = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
516 $row_cells,
517 $table_attrs
518 );
519 }
520
521 // Use Table_Wrapper_Helper for consistent email rendering
522 $wrapper_attrs = array(
523 'style' => 'width: 100%; border-collapse: collapse;',
524 );
525
526 return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
527 implode( '', $content_parts ),
528 $wrapper_attrs
529 );
530 }
531
532 /**
533 * Build columns layout content using mosaic logic organized into columns.
534 *
535 * @param array $images Array of image data.
536 * @param int $cell_padding Cell padding.
537 * @param int $columns Number of columns for the layout.
538 * @param int $border_radius Border radius value (0-20).
539 * @param array $attr Block attributes.
540 * @return string HTML content.
541 */
542 private static function build_columns_layout_content( $images, $cell_padding, $columns, $border_radius = 0, $attr = array() ) {
543 $content_parts = array();
544 $border_radius_style = self::generate_border_radius_style( '', $border_radius );
545
546 // Distribute images across columns using round-robin approach for better balance
547 $column_arrays = array_fill( 0, $columns, array() );
548 foreach ( $images as $index => $image ) {
549 $column_index = $index % $columns;
550 $column_arrays[ $column_index ][] = $image;
551 }
552
553 // Build table cells for columns layout
554 $row_cells = '';
555 foreach ( $column_arrays as $column_images ) {
556 if ( empty( $column_images ) ) {
557 // Add empty cell for balance
558 $cell_attrs = array(
559 'style' => sprintf(
560 'width: %s%%; padding: %dpx; vertical-align: top;',
561 ( 100 / $columns ),
562 $cell_padding
563 ),
564 );
565 $row_cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
566 '',
567 $cell_attrs
568 );
569 continue;
570 }
571
572 // Calculate cell attributes
573 $cell_width_percent = ( 100 / $columns );
574 $cell_attrs = array(
575 'style' => sprintf(
576 'width: %s%%; padding: %dpx; vertical-align: top;',
577 $cell_width_percent,
578 $cell_padding
579 ),
580 );
581
582 // Generate mosaic-style groupings within this column
583 $column_rows = self::generate_column_mosaic_rows( $column_images );
584
585 $cell_content = '';
586 foreach ( $column_rows as $row_index => $row_images ) {
587 foreach ( $row_images as $image ) {
588 $image_styles = self::generate_image_styles( false );
589
590 // Add top margin to all images except the first one in the column
591 if ( $row_index > 0 || $cell_content !== '' ) {
592 $image_styles .= ' margin-top: ' . ( $cell_padding * 2 ) . 'px;';
593 }
594
595 $cell_content .= self::generate_image_html( $image, $image_styles, $border_radius_style, $attr );
596 }
597 }
598
599 $row_cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
600 $cell_content,
601 $cell_attrs
602 );
603 }
604
605 // Use Table_Wrapper_Helper for email-compatible table rendering
606 $table_attrs = array(
607 'style' => 'width: 100%; border-collapse: collapse;',
608 );
609
610 $content_parts[] = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
611 $row_cells,
612 $table_attrs
613 );
614
615 // Use Table_Wrapper_Helper for consistent email rendering
616 $wrapper_attrs = array(
617 'style' => 'width: 100%; border-collapse: collapse;',
618 );
619
620 return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
621 implode( '', $content_parts ),
622 $wrapper_attrs
623 );
624 }
625
626 /**
627 * Build mosaic layout content with flexible row/column structure.
628 *
629 * @param array $images Array of image data.
630 * @param int $cell_padding Cell padding.
631 * @param int $border_radius Border radius value (0-20).
632 * @param array $attr Block attributes.
633 * @return string HTML content.
634 */
635 private static function build_mosaic_layout_content( $images, $cell_padding, $border_radius = 0, $attr = array() ) {
636 $border_radius_style = self::generate_border_radius_style( '', $border_radius );
637
638 // Generate mosaic layout rows
639 $rows = self::generate_mosaic_rows( $images );
640
641 // Determine the maximum number of columns to ensure consistent layout
642 $max_columns = 0;
643 foreach ( $rows as $row ) {
644 $max_columns = max( $max_columns, count( $row ) );
645 }
646
647 // Build each row as a separate table to match flexbox behavior
648 $content_parts = array();
649 foreach ( $rows as $row ) {
650 $images_in_row = count( $row );
651
652 // Calculate width for each cell in this row (like flexbox)
653 $cell_width_percent = ( 100 / $images_in_row );
654
655 // Build table cells for this row
656 $row_cells = '';
657 foreach ( $row as $image ) {
658 $cell_style = sprintf(
659 'width: %s%%; padding: %dpx; vertical-align: top; text-align: center;',
660 $cell_width_percent,
661 $cell_padding
662 );
663
664 // Set consistent height for all images in this row to ensure alignment
665 // Use progressive enhancement: object-fit for supported clients, natural layout for others
666 $image_styles = self::generate_image_styles( true );
667
668 $cell_content = self::generate_image_html( $image, $image_styles, $border_radius_style, $attr );
669
670 $row_cells .= sprintf(
671 '<td style="%s">%s</td>',
672 esc_attr( $cell_style ),
673 $cell_content
674 );
675 }
676
677 // Create a separate table for each row with flexible height for alignment
678 $row_table = sprintf(
679 '<table role="presentation" style="width: 100%%; border-collapse: collapse; table-layout: fixed;"><tr>%s</tr></table>',
680 $row_cells
681 );
682
683 $content_parts[] = $row_table;
684 }
685
686 // Use Table_Wrapper_Helper for the main container
687 $table_attrs = array(
688 'style' => 'width: 100%; border-collapse: collapse;',
689 );
690
691 return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
692 implode( '', $content_parts ),
693 $table_attrs
694 );
695 }
696
697 /**
698 * Generate mosaic layout rows based on image count.
699 *
700 * @param array $images Array of image data.
701 * @return array Array of rows, each containing images.
702 */
703 private static function generate_mosaic_rows( $images ) {
704 $rows = array();
705 $image_count = count( $images );
706
707 // More sophisticated mosaic algorithm based on image count
708 if ( $image_count <= 3 ) {
709 // For 3 or fewer images, use simple layout
710 $rows[] = $images;
711 } else {
712 // For more images, create varied row patterns
713 $patterns = array(
714 4 => array( 2, 2 ), // 4 images: 2 + 2
715 5 => array( 2, 3 ), // 5 images: 2 + 3
716 6 => array( 3, 3 ), // 6 images: 3 + 3
717 7 => array( 3, 2, 2 ), // 7 images: 3 + 2 + 2
718 8 => array( 3, 3, 2 ), // 8 images: 3 + 3 + 2
719 9 => array( 3, 3, 3 ), // 9 images: 3 + 3 + 3
720 );
721
722 if ( isset( $patterns[ $image_count ] ) ) {
723 // Use predefined pattern for 4-9 images
724 $pattern = $patterns[ $image_count ];
725 $image_index = 0;
726
727 foreach ( $pattern as $images_in_row ) {
728 $row = array();
729 for ( $i = 0; $i < $images_in_row; $i++ ) {
730 $row[] = $images[ $image_index ];
731 ++$image_index;
732 }
733 $rows[] = $row;
734 }
735 } else {
736 // For 10+ images, create rows of 3 with remainder handling
737 $full_rows = intval( $image_count / 3 );
738 $remainder = $image_count % 3;
739
740 $image_index = 0;
741
742 // Create full rows of 3
743 for ( $row = 0; $row < $full_rows; $row++ ) {
744 $rows[] = array(
745 $images[ $image_index ],
746 $images[ $image_index + 1 ],
747 $images[ $image_index + 2 ],
748 );
749 $image_index += 3;
750 }
751
752 // Handle remainder
753 if ( $remainder > 0 ) {
754 $remaining = array_slice( $images, $image_index );
755 $rows[] = $remaining;
756 }
757 }
758 }
759
760 return $rows;
761 }
762
763 /**
764 * Generate mosaic-style rows within a single column for columns layout.
765 *
766 * @param array $images Array of image data for this column.
767 * @return array Array of rows, each containing 1-2 images for variety.
768 */
769 private static function generate_column_mosaic_rows( $images ) {
770 $rows = array();
771 $image_count = count( $images );
772
773 if ( $image_count <= 2 ) {
774 // For 2 or fewer images, each gets its own row
775 foreach ( $images as $image ) {
776 $rows[] = array( $image );
777 }
778 } else {
779 // Create varied patterns: mix of single and paired images
780 $image_index = 0;
781
782 while ( $image_index < $image_count ) {
783 $remaining = $image_count - $image_index;
784
785 if ( $remaining === 1 ) {
786 // Last image - single row
787 $rows[] = array( $images[ $image_index ] );
788 ++$image_index;
789 } elseif ( $remaining === 3 ) {
790 // 3 remaining - do 1 + 2 for better balance
791 $rows[] = array( $images[ $image_index ] );
792 ++$image_index;
793 $rows[] = array( $images[ $image_index ], $images[ $image_index + 1 ] );
794 $image_index += 2;
795 } else {
796 // 2 or more remaining - alternate between single and pairs
797 $use_pair = ( count( $rows ) % 2 === 1 ); // Alternate pattern
798
799 if ( $use_pair && $remaining >= 2 ) {
800 // Create a pair
801 $rows[] = array( $images[ $image_index ], $images[ $image_index + 1 ] );
802 $image_index += 2;
803 } else {
804 // Single image
805 $rows[] = array( $images[ $image_index ] );
806 ++$image_index;
807 }
808 }
809 }
810 }
811
812 return $rows;
813 }
814
815 /**
816 * Generate border radius style based on layout style and border radius value.
817 *
818 * @param string $style Layout style (square, circle, etc.).
819 * @param int $border_radius Border radius value.
820 * @return string CSS border-radius style.
821 */
822 private static function generate_border_radius_style( $style, $border_radius ) {
823 if ( 'circle' === $style ) {
824 return 'border-radius:50%;';
825 } elseif ( $border_radius > 0 ) {
826 return 'border-radius:' . $border_radius . 'px;';
827 }
828 return '';
829 }
830
831 /**
832 * Generate image styles for email rendering.
833 *
834 * @param bool $use_fixed_height Whether to use fixed height with object-fit.
835 * @return string CSS style string.
836 */
837 private static function generate_image_styles( $use_fixed_height = false ) {
838 $base_styles = 'margin: 0; width: 100%; max-width: 100%; display: block;';
839
840 if ( $use_fixed_height ) {
841 return $base_styles . ' height: 200px; object-fit: cover; object-position: center;';
842 }
843
844 return $base_styles . ' height: auto;';
845 }
846
847 /**
848 * Generate image HTML with consistent styling.
849 *
850 * @param array $image Image data array.
851 * @param string $additional_styles Additional CSS styles.
852 * @param string $border_radius_style Border radius CSS.
853 * @param array $attr Block attributes (optional, for link processing).
854 * @return string Image HTML.
855 */
856 private static function generate_image_html( $image, $additional_styles = '', $border_radius_style = '', $attr = array() ) {
857 $base_styles = 'border:none;background-color:#0000001a;display:block;height:auto;max-width:100%;padding:0;';
858 $combined_styles = $base_styles . $additional_styles . $border_radius_style;
859
860 $img_html = sprintf(
861 '<img alt="%s" src="%s" style="%s" />',
862 esc_attr( $image['alt'] ),
863 esc_url( $image['url'] ),
864 $combined_styles
865 );
866
867 // Handle link settings for email
868 $link_to = ! empty( $attr['linkTo'] ) ? $attr['linkTo'] : 'none';
869 $href = self::get_image_link_href( $image, $attr, $link_to );
870
871 if ( ! empty( $href ) ) {
872 return sprintf( '<a href="%s">%s</a>', esc_url( $href ), $img_html );
873 }
874
875 return $img_html;
876 }
877
878 /**
879 * Get the href for an image based on link settings (used for email rendering).
880 * Excludes custom links which email clients will replace with the image.
881 *
882 * @since 15.0
883 *
884 * @param array $image Image data array.
885 * @param array $attr Block attributes.
886 * @param string $link_to Link setting.
887 * @return string The href URL or empty string.
888 */
889 private static function get_image_link_href( $image, $attr, $link_to ) {
890 switch ( $link_to ) {
891 case 'media':
892 return ! empty( $image['url'] ) ? $image['url'] : '';
893
894 case 'attachment':
895 // For email, we need to generate the attachment page URL from the image ID
896 if ( ! empty( $image['id'] ) ) {
897 $attachment_url = get_permalink( $image['id'] );
898 return $attachment_url ? $attachment_url : '';
899 }
900 return '';
901 default:
902 return '';
903 }
904 }
905
906 /**
907 * Create hierarchical chunks for square/circle layouts with larger items first.
908 *
909 * @param array $images Array of image data.
910 * @param int $columns Number of columns for the layout.
911 * @return array Array of rows with different sized chunks.
912 */
913 private static function create_hierarchical_chunks( $images, $columns ) {
914 $image_count = count( $images );
915 $chunks = array();
916
917 if ( $image_count <= $columns ) {
918 // For column count or fewer, single row
919 $chunks[] = $images;
920 } else {
921 // Calculate remainder when dividing by columns
922 $remainder = $image_count % $columns;
923 $start_index = 0;
924
925 // Handle all remainder cases to create proper hierarchy
926 if ( $remainder > 0 ) {
927 // Create a row with the remainder images (larger items first)
928 $chunks[] = array_slice( $images, 0, $remainder );
929 $start_index = $remainder;
930 }
931 // If remainder === 0, start_index stays 0
932
933 // Rest in groups of $columns
934 $remaining = array_slice( $images, $start_index );
935 $remaining_chunks = array_chunk( $remaining, $columns );
936 $chunks = array_merge( $chunks, $remaining_chunks );
937 }
938
939 return $chunks;
940 }
941 }
942
943 add_action( 'init', array( Tiled_Gallery::class, 'register' ) );
944