; and without * JavaScript the images are still visible and linked to the full-size file. */ defined( 'ABSPATH' ) || exit; class EquaFrame_Renderer { /** @var int Instances rendered on this request, used to keep element IDs unique. */ private static $instances = 0; /** @var array Custom image IDs already printed on this page (IDs must be unique). */ private static $used_ids = array(); /** @var array Gallery IDs whose CSS has already been added to the page. */ private static $styled = array(); public static function init() { add_shortcode( 'equaframe', array( __CLASS__, 'shortcode' ) ); add_action( 'wp_enqueue_scripts', array( __CLASS__, 'register_assets' ) ); } public static function register_assets() { wp_register_style( 'eqfr-frontend', EQUAFRAME_URL . 'assets/css/frontend.css', array(), EQUAFRAME_VERSION ); wp_register_script( 'eqfr-frontend', EQUAFRAME_URL . 'assets/js/frontend.js', array(), EQUAFRAME_VERSION, array( 'in_footer' => true ) ); wp_add_inline_script( 'eqfr-frontend', 'window.eqfrI18n=' . wp_json_encode( array( /* translators: 1: current image number, 2: total number of images. Keep the {current} and {total} placeholders. */ 'imageOf' => __( 'Image {current} of {total}', 'equaframe' ), ) ) . ';', 'before' ); // Galleries that are in the main post content get their stylesheet and settings CSS in , // so the layout never jumps. Galleries added any other way (widgets, page builders) enqueue // the same styles while rendering and print in the footer instead. $post = is_singular() ? get_post() : null; if ( $post && has_shortcode( $post->post_content, 'equaframe' ) ) { wp_enqueue_style( 'eqfr-frontend' ); wp_register_style( 'eqfr-gallery-head', false, array( 'eqfr-frontend' ), EQUAFRAME_VERSION ); wp_enqueue_style( 'eqfr-gallery-head' ); foreach ( self::shortcode_gallery_ids( $post->post_content ) as $gallery_id ) { $gallery = EquaFrame_Gallery::get( $gallery_id ); if ( $gallery && 'publish' === $gallery->post_status ) { self::add_gallery_css( $gallery->ID, EquaFrame_Gallery::get_settings( $gallery->ID ), 'eqfr-gallery-head' ); } } } } /** * Gallery IDs used by [equaframe] shortcodes in a piece of content. * * @param string $content Post content. * @return int[] */ private static function shortcode_gallery_ids( $content ) { $ids = array(); if ( preg_match_all( '/' . get_shortcode_regex( array( 'equaframe' ) ) . '/', $content, $matches, PREG_SET_ORDER ) ) { foreach ( $matches as $match ) { $atts = shortcode_parse_atts( $match[3] ); if ( ! empty( $atts['id'] ) ) { $ids[] = (int) $atts['id']; } } } return array_unique( array_filter( $ids ) ); } /** * Add one gallery's settings CSS to a style handle, once per page. * The CSS is built from sanitized values only (see EquaFrame_Settings::sanitize). * * @param int $gallery_id Gallery ID. * @param array $settings Sanitized settings. * @param string $handle Registered style handle. */ private static function add_gallery_css( $gallery_id, $settings, $handle ) { if ( isset( self::$styled[ $gallery_id ] ) ) { return; } self::$styled[ $gallery_id ] = true; wp_add_inline_style( $handle, EquaFrame_CSS::build( $gallery_id, $settings ) ); } public static function shortcode( $atts ) { $atts = shortcode_atts( array( 'id' => 0, 'class' => '', ), $atts, 'equaframe' ); $post = EquaFrame_Gallery::get( (int) $atts['id'] ); if ( ! $post || 'publish' !== $post->post_status ) { return current_user_can( 'edit_posts' ) ? '

' . esc_html__( 'EquaFrame: that gallery could not be found.', 'equaframe' ) . '

' : ''; } return self::render( $post, $atts['class'] ); } /** * Build the gallery markup. * * @param WP_Post $post Gallery post. * @param string $extra_class Extra wrapper class(es) from the shortcode. */ public static function render( $post, $extra_class = '' ) { $settings = EquaFrame_Gallery::get_settings( $post->ID ); $images = EquaFrame_Gallery::get_images( $post->ID ); if ( ! $images ) { return current_user_can( 'edit_posts' ) ? '

' . esc_html__( 'EquaFrame: this gallery has no images yet.', 'equaframe' ) . '

' : ''; } wp_enqueue_style( 'eqfr-frontend' ); wp_enqueue_script( 'eqfr-frontend' ); // Normally already added in (see register_assets); this covers any other placement. if ( ! isset( self::$styled[ $post->ID ] ) ) { wp_register_style( 'eqfr-gallery-late', false, array( 'eqfr-frontend' ), EQUAFRAME_VERSION ); wp_enqueue_style( 'eqfr-gallery-late' ); self::add_gallery_css( $post->ID, $settings, 'eqfr-gallery-late' ); } ++self::$instances; $dom_id = 'eqfr-' . $post->ID . '-' . self::$instances; $layout = $settings['layout']; $lightbox = (bool) $settings['lb_on']; // Resolve images once; skip attachments that have since been deleted. $items = array(); foreach ( $images as $image ) { $full = wp_get_attachment_image_src( $image['id'], $settings['lb_image_size'] ); if ( ! $full ) { continue; } $image['full'] = $full[0]; $items[] = $image; } if ( ! $items ) { return ''; } $total = count( $items ); $classes = array( 'eqfr-gallery', 'eqfr-gallery-' . (int) $post->ID, 'eqfr-layout-' . $layout ); if ( $settings['zoom_on'] ) { $classes[] = 'eqfr-zoom'; } if ( $lightbox ) { $classes[] = 'eqfr-has-lightbox'; } if ( 'slideshow' === $layout ) { $classes[] = 'eqfr-ss'; $classes[] = 'eqfr-ss-mode-' . $settings['ss_mode']; if ( 'filmstrip' === $settings['ss_mode'] ) { $classes[] = 'eqfr-ss-pos-' . $settings['ss_strip_pos']; } } foreach ( preg_split( '/\s+/', (string) $extra_class, -1, PREG_SPLIT_NO_EMPTY ) as $extra ) { $classes[] = sanitize_html_class( $extra ); } $attrs = array( 'class' => implode( ' ', array_filter( $classes ) ), 'id' => $dom_id, // A named group lets screen reader users know where the gallery starts without adding a landmark. 'role' => 'group', 'aria-label' => $post->post_title, 'data-lightbox' => $lightbox ? '1' : '0', 'data-close-backdrop' => $settings['lb_close_backdrop'] ? '1' : '0', 'data-close-esc' => $settings['lb_close_esc'] ? '1' : '0', 'data-show-title' => $settings['lb_show_title'] ? '1' : '0', 'data-show-caption' => $settings['lb_show_caption'] ? '1' : '0', 'data-announce' => $settings['lb_announce'], 'data-loop' => $settings['ss_loop'] ? '1' : '0', ); $html = ' $value ) { $html .= ' ' . $name . '="' . esc_attr( $value ) . '"'; } $html .= '>'; if ( 'slideshow' === $layout ) { $html .= self::render_slideshow( $items, $settings, $dom_id, $lightbox ); } else { $html .= self::render_tiles( $items, $settings, $lightbox ); } if ( $lightbox ) { $html .= self::render_lightbox( $post->post_title, $settings ); } $html .= ''; return $html; } /* ------------------------------------------------------------------ * Grid, masonry, mosaic * ---------------------------------------------------------------- */ private static function render_tiles( $items, $settings, $lightbox ) { $total = count( $items ); $sizes = sprintf( '(max-width:%1$dpx) %2$dvw, (max-width:%3$dpx) %4$dvw, %5$dvw', EquaFrame_Settings::BP_MOBILE, (int) round( 100 / max( 1, $settings['cols_mobile'] ) ), EquaFrame_Settings::BP_TABLET, (int) round( 100 / max( 1, $settings['cols_tablet'] ) ), (int) round( 100 / max( 1, $settings['cols_desktop'] ) ) ); // A real list: screen readers announce "list, 12 items" and users can skip it. $html = ''; return $html; } /* ------------------------------------------------------------------ * Slideshow (ARIA Authoring Practices "carousel" pattern) * ---------------------------------------------------------------- */ private static function render_slideshow( $items, $settings, $dom_id, $lightbox ) { $total = count( $items ); $filmstrip = 'filmstrip' === $settings['ss_mode']; $viewport = $dom_id . '-viewport'; // In DOM order the strip comes first for top/left so tab order matches what people see. $strip_first = $filmstrip && in_array( $settings['ss_strip_pos'], array( 'top', 'left' ), true ); $strip = $filmstrip ? self::render_strip( $items, $settings, $viewport ) : ''; $stage = '
'; $stage .= '
'; $stage .= '
'; foreach ( $items as $i => $image ) { $stage .= '
'; $stage .= '
'; $img = self::img_tag( $image, $settings['image_size'], 'eqfr-img eqfr-slide-img', '(max-width:' . EquaFrame_Settings::BP_TABLET . 'px) 100vw, 1200px', $i > 0 ); $stage .= $lightbox ? self::link_open( $image, $i, $total ) . $img . '' : $img; $caption = self::slide_caption( $image, $settings['ss_caption'] ); if ( '' !== $caption ) { $stage .= '
' . $caption . '
'; } $stage .= '
'; } $stage .= '
'; // viewport. // Controls are hidden by CSS until JavaScript has made them work. if ( $total > 1 && $settings['ss_arrows'] ) { $stage .= ''; $stage .= ''; } $stage .= '
'; // stage. if ( $total > 1 && ! $filmstrip && $settings['ss_dots'] ) { $stage .= '
'; for ( $i = 0; $i < $total; $i++ ) { /* translators: %d: slide number */ $label = sprintf( __( 'Slide %d', 'equaframe' ), $i + 1 ); $stage .= ''; } $stage .= '
'; } $stage .= '
'; // main. return $strip_first ? $strip . $stage : $stage . $strip; } private static function render_strip( $items, $settings, $viewport ) { $total = count( $items ); $html = '
'; $html .= '
'; return $html; } private static function slide_caption( $image, $mode ) { $parts = ''; if ( in_array( $mode, array( 'title', 'both' ), true ) && '' !== $image['title'] ) { $parts .= '' . esc_html( $image['title'] ) . ''; } if ( in_array( $mode, array( 'caption', 'both' ), true ) && '' !== $image['caption'] ) { $parts .= ' ' . esc_html( $image['caption'] ) . ''; } return trim( $parts ); } /* ------------------------------------------------------------------ * Shared pieces * ---------------------------------------------------------------- */ private static function item_id_attr( $image ) { $css_id = $image['css_id']; if ( '' === $css_id || isset( self::$used_ids[ $css_id ] ) ) { return ''; } self::$used_ids[ $css_id ] = true; return ' id="' . esc_attr( $css_id ) . '"'; } private static function img_tag( $image, $size, $class, $sizes_attr, $lazy ) { return wp_get_attachment_image( $image['id'], $size, false, array( 'class' => $class, // The alt text is exactly what was typed for this gallery. Empty means decorative. 'alt' => $image['alt'], 'sizes' => $sizes_attr, 'loading' => $lazy ? 'lazy' : 'eager', 'decoding' => 'async', ) ); } /** * Opening tag of the link that launches the lightbox. It is a plain link to the * full-size file, so it works without JavaScript and with middle-click. */ private static function link_open( $image, $index, $total ) { $attrs = array( 'class' => 'eqfr-link', 'href' => $image['full'], 'aria-haspopup' => 'dialog', 'data-index' => $index, 'data-full' => $image['full'], 'data-alt' => $image['alt'], 'data-title' => $image['title'], 'data-caption' => $image['caption'], ); // A link must have an accessible name even when the picture is decorative. if ( '' === $image['alt'] ) { $name = '' !== $image['title'] ? $image['title'] /* translators: 1: image number, 2: total images */ : sprintf( __( 'View image %1$d of %2$d', 'equaframe' ), $index + 1, $total ); $attrs['aria-label'] = $name; } $out = ' $value ) { $out .= ' ' . $name . '="' . esc_attr( $value ) . '"'; } return $out . '>'; } private static function icon( $name ) { $paths = array( 'prev' => '', 'next' => '', ); return ''; } /* ------------------------------------------------------------------ * Lightbox: a native modal . The browser provides the focus trap, * makes the rest of the page inert, and restores focus; frontend.js adds * navigation, announcements and the optional close behaviors. * ---------------------------------------------------------------- */ private static function render_lightbox( $gallery_name, $settings ) { $classes = array( 'eqfr-lightbox', 'eqfr-lb--close-' . $settings['lb_close_pos'], 'eqfr-lb--close-' . $settings['lb_close_style'], 'eqfr-lb--cap-' . $settings['lb_caption_mode'], ); /* translators: %s: gallery name */ $dialog_label = sprintf( __( '%s: larger view', 'equaframe' ), $gallery_name ); $close_content = 'text' === $settings['lb_close_icon'] ? '' . esc_html__( 'Close', 'equaframe' ) . '' : EquaFrame_Icons::svg( $settings['lb_close_icon'], 'eqfr-icon-close' ); $html = ''; $html .= '
'; $html .= ''; $html .= ''; $html .= ''; $html .= '
'; $html .= '
'; $html .= ''; // Announces "Image 3 of 10" and the title when the picture changes (WCAG 4.1.3). $html .= '
'; $html .= '
'; return $html; } }