| 1 |
<?php |
| 2 |
/** |
| 3 |
* Term Thumbnail render template. |
| 4 |
* |
| 5 |
* @package Gutenify |
| 6 |
*/ |
| 7 |
|
| 8 |
if ( ! defined( 'ABSPATH' ) ) { |
| 9 |
exit; |
| 10 |
} |
| 11 |
|
| 12 |
if ( ! function_exists( 'gutenify_term_thumbnail_get_border_attributes' ) ) { |
| 13 |
/** |
| 14 |
* Builds border classnames/styles for this block from its own `style` |
| 15 |
* attribute, mirroring core's own (Gutenberg-plugin) |
| 16 |
* `gutenberg_get_block_core_post_featured_image_border_attributes()`. |
| 17 |
* |
| 18 |
* block.json's `__experimentalBorder` support declares |
| 19 |
* `__experimentalSkipSerialization: true`, so WordPress never |
| 20 |
* auto-applies the border to `get_block_wrapper_attributes()`'s outer |
| 21 |
* wrapper `<div>` — without that flag the rounded corners/width/color |
| 22 |
* only ever showed on the invisible wrapper, never on the `<img>` (or |
| 23 |
* the dim-overlay `<span>`) sitting inside it. This re-applies border |
| 24 |
* support manually to the actual image/overlay elements below, using |
| 25 |
* the stable core `wp_style_engine_get_styles()` (not the |
| 26 |
* Gutenberg-plugin-only `gutenberg_style_engine_get_styles()`, which |
| 27 |
* only exists here because the Gutenberg plugin happens to be checked |
| 28 |
* out for reference on this dev site). |
| 29 |
* |
| 30 |
* Declared as a plain guarded function (not a class method/namespaced |
| 31 |
* function) because this file is a render template, not a class, and |
| 32 |
* `render.php` can be `include`d more than once per request — a Terms |
| 33 |
* Query Loop renders this block once per term, so a second `include` |
| 34 |
* of this same file within the same request is the normal case here, |
| 35 |
* not an edge case. |
| 36 |
* |
| 37 |
* @param array $attributes Block attributes. |
| 38 |
* @return array{class?: string, style?: string} Border classnames/styles, if any were set. |
| 39 |
*/ |
| 40 |
function gutenify_term_thumbnail_get_border_attributes( $attributes ) { |
| 41 |
$border_styles = array(); |
| 42 |
$sides = array( 'top', 'right', 'bottom', 'left' ); |
| 43 |
|
| 44 |
if ( isset( $attributes['style']['border']['radius'] ) ) { |
| 45 |
$border_styles['radius'] = $attributes['style']['border']['radius']; |
| 46 |
} |
| 47 |
|
| 48 |
if ( isset( $attributes['style']['border']['style'] ) ) { |
| 49 |
$border_styles['style'] = $attributes['style']['border']['style']; |
| 50 |
} |
| 51 |
|
| 52 |
if ( isset( $attributes['style']['border']['width'] ) ) { |
| 53 |
$border_styles['width'] = $attributes['style']['border']['width']; |
| 54 |
} |
| 55 |
|
| 56 |
$preset_color = ! empty( $attributes['borderColor'] ) ? 'var:preset|color|' . $attributes['borderColor'] : null; |
| 57 |
$custom_color = $attributes['style']['border']['color'] ?? null; |
| 58 |
$border_styles['color'] = $preset_color ? $preset_color : $custom_color; |
| 59 |
|
| 60 |
// Individual per-side overrides (top/right/bottom/left), same shape |
| 61 |
// core's own border block-support stores them in. |
| 62 |
foreach ( $sides as $side ) { |
| 63 |
$side_border = $attributes['style']['border'][ $side ] ?? null; |
| 64 |
$border_styles[ $side ] = array( |
| 65 |
'color' => $side_border['color'] ?? null, |
| 66 |
'style' => $side_border['style'] ?? null, |
| 67 |
'width' => $side_border['width'] ?? null, |
| 68 |
); |
| 69 |
} |
| 70 |
|
| 71 |
$styles = wp_style_engine_get_styles( array( 'border' => $border_styles ) ); |
| 72 |
|
| 73 |
$border_attributes = array(); |
| 74 |
if ( ! empty( $styles['classnames'] ) ) { |
| 75 |
$border_attributes['class'] = $styles['classnames']; |
| 76 |
} |
| 77 |
if ( ! empty( $styles['css'] ) ) { |
| 78 |
$border_attributes['style'] = $styles['css']; |
| 79 |
} |
| 80 |
return $border_attributes; |
| 81 |
} |
| 82 |
} |
| 83 |
|
| 84 |
// Both the classic-editor context (is_admin()) and ServerSideRender's own |
| 85 |
// block-renderer REST call (wp_is_json_request()) render this template for |
| 86 |
// editor-canvas preview purposes, never as real frontend output. Every |
| 87 |
// editor-only branch below (missing-term messages, and the link-suppression |
| 88 |
// in Task 1) keys off this single flag rather than repeating the same |
| 89 |
// condition three times. |
| 90 |
$is_editor_context = is_admin() || wp_is_json_request(); |
| 91 |
|
| 92 |
// core/term-template injects `termId`/`taxonomy` into block context for |
| 93 |
// every child block rendered inside its loop (same contract core's own |
| 94 |
// core/term-name and core/term-count consume). |
| 95 |
$term_id = isset( $block->context['termId'] ) ? absint( $block->context['termId'] ) : 0; |
| 96 |
$taxonomy = isset( $block->context['taxonomy'] ) ? sanitize_key( $block->context['taxonomy'] ) : ''; |
| 97 |
|
| 98 |
// The editor's ServerSideRender preview calls the block-renderer REST |
| 99 |
// endpoint, which does not forward block context — only attributes. When |
| 100 |
// this block sits inside a real Term Template in the editor canvas, |
| 101 |
// Gutenberg's own BlockContextProvider still supplies `context` to this |
| 102 |
// file's caller (edit.js), which mirrors it into these preview attributes |
| 103 |
// so the SSR request can reconstruct the same term. Mirrors |
| 104 |
// single-product-gallery's previewProductId pattern. |
| 105 |
if ( ! $term_id && ! empty( $attributes['previewTermId'] ) ) { |
| 106 |
$term_id = absint( $attributes['previewTermId'] ); |
| 107 |
$taxonomy = ! empty( $attributes['previewTaxonomy'] ) ? sanitize_key( $attributes['previewTaxonomy'] ) : $taxonomy; |
| 108 |
} |
| 109 |
|
| 110 |
if ( ! $term_id ) { |
| 111 |
if ( $is_editor_context ) { |
| 112 |
echo '<p class="gutenify--term-thumbnail__message">' . esc_html__( 'This block must be placed inside a Terms Query Loop (Term Template) so it can receive a term.', 'gutenify' ) . '</p>'; |
| 113 |
} |
| 114 |
return; |
| 115 |
} |
| 116 |
|
| 117 |
$term = get_term( $term_id, $taxonomy ); |
| 118 |
|
| 119 |
if ( ! $term || is_wp_error( $term ) ) { |
| 120 |
if ( $is_editor_context ) { |
| 121 |
echo '<p class="gutenify--term-thumbnail__message">' . esc_html__( 'This term could not be found.', 'gutenify' ) . '</p>'; |
| 122 |
} |
| 123 |
return; |
| 124 |
} |
| 125 |
|
| 126 |
// get_term() already resolves the real taxonomy from the term object even |
| 127 |
// when the `$taxonomy` arg above was empty/wrong — use its value from here |
| 128 |
// on for the WooCommerce fallback check and the term link below. |
| 129 |
$taxonomy = $term->taxonomy; |
| 130 |
|
| 131 |
$allowed_sizes = array_values( |
| 132 |
array_unique( |
| 133 |
array_merge( array( 'thumbnail', 'medium', 'medium_large', 'large', 'full' ), get_intermediate_image_sizes() ) |
| 134 |
) |
| 135 |
); |
| 136 |
$image_size = isset( $attributes['imageSize'] ) && in_array( $attributes['imageSize'], $allowed_sizes, true ) ? $attributes['imageSize'] : 'thumbnail'; |
| 137 |
|
| 138 |
// Same three values as CSS's own `object-fit` keywords, matching core's |
| 139 |
// Image block "Scale" control (Cover/Contain/Fill) 1:1. |
| 140 |
$allowed_scales = array( 'cover', 'contain', 'fill' ); |
| 141 |
$scale = isset( $attributes['scale'] ) && in_array( $attributes['scale'], $allowed_scales, true ) ? $attributes['scale'] : 'cover'; |
| 142 |
|
| 143 |
// Same preset values as core's Image block "Aspect ratio" control — |
| 144 |
// whitelisted rather than passed through raw since this becomes a CSS |
| 145 |
// custom property value below. |
| 146 |
// |
| 147 |
// Default is "Square" (1), not "Original" (auto) — this block is meant |
| 148 |
// for a Terms Query Loop grid, where each term's own thumbnail almost |
| 149 |
// certainly has a different natural size/orientation (different source |
| 150 |
// photos per category). "Original" produced a visibly broken grid out |
| 151 |
// of the box; a uniform square crop is the standard choice for exactly |
| 152 |
// this reason. |
| 153 |
$allowed_aspect_ratios = array( 'auto', '1', '4/3', '3/2', '16/9', '3/4', '2/3', '9/16' ); |
| 154 |
$aspect_ratio = isset( $attributes['aspectRatio'] ) ? (string) $attributes['aspectRatio'] : '1'; |
| 155 |
if ( ! in_array( $aspect_ratio, $allowed_aspect_ratios, true ) ) { |
| 156 |
$aspect_ratio = '1'; |
| 157 |
} |
| 158 |
|
| 159 |
// `focalPoint` mirrors core's Image block attribute shape ({x, y}, each |
| 160 |
// 0-1) — clamped here since it becomes a CSS percentage below. |
| 161 |
$focal_point = isset( $attributes['focalPoint'] ) && is_array( $attributes['focalPoint'] ) ? $attributes['focalPoint'] : array(); |
| 162 |
$focal_point_x = isset( $focal_point['x'] ) ? min( 1, max( 0, (float) $focal_point['x'] ) ) : 0.5; |
| 163 |
$focal_point_y = isset( $focal_point['y'] ) ? min( 1, max( 0, (float) $focal_point['y'] ) ) : 0.5; |
| 164 |
|
| 165 |
$link_to_term = ! isset( $attributes['linkToTerm'] ) || ! empty( $attributes['linkToTerm'] ); |
| 166 |
|
| 167 |
// Border support is skip-serialized in block.json (see the helper above), |
| 168 |
// so it never lands on get_block_wrapper_attributes()'s wrapper <div> |
| 169 |
// below — apply it directly to the <img>/placeholder <img> (and, further |
| 170 |
// down, the dim-overlay <span>) instead, same as core's own Post Featured |
| 171 |
// Image block does for its <img>/overlay pair. |
| 172 |
$border_attributes = gutenify_term_thumbnail_get_border_attributes( $attributes ); |
| 173 |
$border_class = ! empty( $border_attributes['class'] ) ? (string) $border_attributes['class'] : ''; |
| 174 |
$border_style = ! empty( $border_attributes['style'] ) ? (string) $border_attributes['style'] : ''; |
| 175 |
|
| 176 |
// Same WooCommerce term-thumbnail meta convention category-list already |
| 177 |
// reads. |
| 178 |
$image_id = absint( get_term_meta( $term_id, 'thumbnail_id', true ) ); |
| 179 |
$image_html = ''; |
| 180 |
|
| 181 |
if ( $image_id ) { |
| 182 |
$attachment_title = get_the_title( $image_id ); |
| 183 |
$image_attr = array( |
| 184 |
'loading' => 'lazy', |
| 185 |
'alt' => $attachment_title ? $attachment_title : $term->name, |
| 186 |
); |
| 187 |
// wp_get_attachment_image() esc_attr()'s every $attr value itself |
| 188 |
// before echoing, so class/style don't need re-escaping here — only |
| 189 |
// merged in when actually set, so the default attachment/size classes |
| 190 |
// are left untouched when there's no border to apply. |
| 191 |
if ( $border_class ) { |
| 192 |
$image_attr['class'] = $border_class; |
| 193 |
} |
| 194 |
if ( $border_style ) { |
| 195 |
$image_attr['style'] = $border_style; |
| 196 |
} |
| 197 |
$image_html = wp_get_attachment_image( $image_id, $image_size, false, $image_attr ); |
| 198 |
} elseif ( function_exists( 'wc_placeholder_img' ) && 'product_cat' === $taxonomy ) { |
| 199 |
// Generic, not WooCommerce-gated: this fallback only fires for the one |
| 200 |
// case category-list already handles the same way — a product_cat term |
| 201 |
// with no thumbnail_id meta, while WooCommerce is active. Any other |
| 202 |
// taxonomy with no thumbnail simply renders nothing below, matching |
| 203 |
// core's own Post Featured Image block when a post has no featured |
| 204 |
// image. |
| 205 |
// |
| 206 |
// wc_placeholder_img()'s own default $attr (`wp_parse_args( $attr, |
| 207 |
// $default_attr )`) lets a passed-in `class` key win outright rather |
| 208 |
// than merging strings, so its default |
| 209 |
// `woocommerce-placeholder wp-post-image` class has to be concatenated |
| 210 |
// in here explicitly or it's silently lost the moment a border class |
| 211 |
// exists. wc_placeholder_img() also esc_attr()'s every attribute |
| 212 |
// itself before echoing, same as wp_get_attachment_image() above. |
| 213 |
$placeholder_attr = array( |
| 214 |
'class' => trim( 'woocommerce-placeholder wp-post-image' . ( $border_class ? ' ' . $border_class : '' ) ), |
| 215 |
); |
| 216 |
if ( $border_style ) { |
| 217 |
$placeholder_attr['style'] = $border_style; |
| 218 |
} |
| 219 |
$image_html = wc_placeholder_img( $image_size, $placeholder_attr ); |
| 220 |
} |
| 221 |
|
| 222 |
if ( '' === $image_html ) { |
| 223 |
if ( $is_editor_context ) { |
| 224 |
echo '<p class="gutenify--term-thumbnail__message">' . esc_html__( 'This term does not have a thumbnail image.', 'gutenify' ) . '</p>'; |
| 225 |
} |
| 226 |
return; |
| 227 |
} |
| 228 |
|
| 229 |
$has_aspect_ratio = 'auto' !== $aspect_ratio; |
| 230 |
$style = '--term-thumbnail-scale:' . esc_attr( $scale ) . ';'; |
| 231 |
if ( $has_aspect_ratio ) { |
| 232 |
$style .= sprintf( |
| 233 |
'--term-thumbnail-aspect-ratio:%1$s;--term-thumbnail-focal-x:%2$s%%;--term-thumbnail-focal-y:%3$s%%;', |
| 234 |
esc_attr( $aspect_ratio ), |
| 235 |
esc_attr( $focal_point_x * 100 ), |
| 236 |
esc_attr( $focal_point_y * 100 ) |
| 237 |
); |
| 238 |
} |
| 239 |
|
| 240 |
$wrapper_attributes = get_block_wrapper_attributes( |
| 241 |
array( |
| 242 |
'class' => 'gutenify--term-thumbnail' . ( $has_aspect_ratio ? ' has-aspect-ratio' : '' ), |
| 243 |
'style' => $style, |
| 244 |
) |
| 245 |
); |
| 246 |
|
| 247 |
// A link is never rendered for an editor-context render (classic-editor |
| 248 |
// is_admin(), or ServerSideRender's block-renderer REST call) regardless of |
| 249 |
// the `linkToTerm` attribute's value — clicking a link inside the editor |
| 250 |
// canvas is bad UX (nested-link/click-target problems), and core's own |
| 251 |
// Post Featured Image block never renders its link in the editor either, |
| 252 |
// only on the real frontend. get_term_link() is also skipped entirely in |
| 253 |
// that case since its result would never be used. |
| 254 |
$term_link = ( ! $is_editor_context && $link_to_term ) ? get_term_link( $term ) : ''; |
| 255 |
$has_link = ! $is_editor_context && $link_to_term && $term_link && ! is_wp_error( $term_link ); |
| 256 |
|
| 257 |
$link_target = isset( $attributes['linkTarget'] ) && '_blank' === $attributes['linkTarget'] ? '_blank' : '_self'; |
| 258 |
$link_rel = isset( $attributes['rel'] ) ? (string) $attributes['rel'] : ''; |
| 259 |
|
| 260 |
// Overlay: dim/color-or-gradient layer on top of the image, matching core's |
| 261 |
// own Post Featured Image "Overlay" panel (dimRatio/overlayColor/ |
| 262 |
// customOverlayColor/gradient/customGradient). Snapped to the nearest 10 to |
| 263 |
// match the discrete has-background-dim-{n} opacity classes shipped in |
| 264 |
// style.scss (also guards against an out-of-step value ever reaching here |
| 265 |
// via a raw REST/attributes payload, since RangeControl's step is only a |
| 266 |
// display hint). |
| 267 |
$dim_ratio = isset( $attributes['dimRatio'] ) ? absint( $attributes['dimRatio'] ) : 0; |
| 268 |
$dim_ratio = (int) ( round( $dim_ratio / 10 ) * 10 ); |
| 269 |
$dim_ratio = max( 0, min( 100, $dim_ratio ) ); |
| 270 |
|
| 271 |
$overlay_color = isset( $attributes['overlayColor'] ) ? sanitize_html_class( $attributes['overlayColor'] ) : ''; |
| 272 |
$custom_overlay_color = isset( $attributes['customOverlayColor'] ) ? (string) $attributes['customOverlayColor'] : ''; |
| 273 |
$gradient_slug = isset( $attributes['gradient'] ) ? sanitize_html_class( $attributes['gradient'] ) : ''; |
| 274 |
$custom_gradient = isset( $attributes['customGradient'] ) ? (string) $attributes['customGradient'] : ''; |
| 275 |
|
| 276 |
$overlay_html = ''; |
| 277 |
|
| 278 |
if ( $dim_ratio > 0 ) { |
| 279 |
$overlay_classes = array( 'gutenify--term-thumbnail__overlay', 'has-background-dim', 'has-background-dim-' . $dim_ratio ); |
| 280 |
$overlay_styles = array(); |
| 281 |
|
| 282 |
if ( $overlay_color ) { |
| 283 |
$overlay_classes[] = 'has-' . $overlay_color . '-background-color'; |
| 284 |
} |
| 285 |
|
| 286 |
if ( $gradient_slug || $custom_gradient ) { |
| 287 |
$overlay_classes[] = 'has-background-gradient'; |
| 288 |
} |
| 289 |
|
| 290 |
if ( $gradient_slug ) { |
| 291 |
$overlay_classes[] = 'has-' . $gradient_slug . '-gradient-background'; |
| 292 |
} |
| 293 |
|
| 294 |
// Give the overlay the same border radius/width/color as the image |
| 295 |
// beneath it — otherwise a square overlay pokes out past the image's |
| 296 |
// rounded corners, same fix core's own Post Featured Image block |
| 297 |
// applies via its overlay markup builder. |
| 298 |
if ( $border_class ) { |
| 299 |
$overlay_classes[] = $border_class; |
| 300 |
} |
| 301 |
if ( $border_style ) { |
| 302 |
$overlay_styles[] = $border_style; |
| 303 |
} |
| 304 |
|
| 305 |
// Mirrors core's own overlay markup builder: build the raw declarations |
| 306 |
// first, then run the combined string through safecss_filter_attr() |
| 307 |
// (rejects anything that isn't a safe CSS value, e.g. a javascript: URI |
| 308 |
// smuggled into a gradient) before esc_attr()'ing the whole style |
| 309 |
// attribute — the same two-layer defense already used above for |
| 310 |
// aspect-ratio/height/width/object-fit. |
| 311 |
if ( $custom_gradient ) { |
| 312 |
$overlay_styles[] = 'background-image:' . $custom_gradient . ';'; |
| 313 |
} elseif ( $custom_overlay_color ) { |
| 314 |
$overlay_styles[] = 'background-color:' . $custom_overlay_color . ';'; |
| 315 |
} |
| 316 |
|
| 317 |
$overlay_html = sprintf( |
| 318 |
'<span class="%1$s" style="%2$s" aria-hidden="true"></span>', |
| 319 |
esc_attr( implode( ' ', $overlay_classes ) ), |
| 320 |
esc_attr( safecss_filter_attr( implode( '', $overlay_styles ) ) ) |
| 321 |
); |
| 322 |
} |
| 323 |
|
| 324 |
$image_html .= $overlay_html; |
| 325 |
?> |
| 326 |
<div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>> |
| 327 |
<?php if ( $has_link ) : ?> |
| 328 |
<a class="gutenify--term-thumbnail__link" href="<?php echo esc_url( $term_link ); ?>"<?php echo '_blank' === $link_target ? ' target="_blank" rel="' . esc_attr( $link_rel ) . '"' : ''; ?>> |
| 329 |
<span class="gutenify--term-thumbnail__image"><?php echo $image_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span> |
| 330 |
</a> |
| 331 |
<?php else : ?> |
| 332 |
<span class="gutenify--term-thumbnail__image"><?php echo $image_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span> |
| 333 |
<?php endif; ?> |
| 334 |
</div> |
| 335 |
|