*/ const PREDEFINED_TEMPLATES = [ 'left' => [ 'top' => 0, 'left' => 0, 'width' => 50, 'height' => 100 ], 'right' => [ 'top' => 0, 'left' => 50, 'width' => 50, 'height' => 100 ], 'top' => [ 'top' => 0, 'left' => 0, 'width' => 100, 'height' => 50 ], 'bottom' => [ 'top' => 50, 'left' => 0, 'width' => 100, 'height' => 50 ], ]; /** * Register filter hooks for frontend tour rendering. * * @return void */ public static function init() { if ( ! has_filter( 'wpvr_generate_tour_layout_html', [ __CLASS__, 'render' ] ) ) { add_filter( 'wpvr_generate_tour_layout_html', [ __CLASS__, 'render' ], 25, 3 ); } } /** * Convert HEX color and opacity percentage to RGBA CSS string. * * @param string $hex Hex color code (e.g. #281E19 or 281E19). * @param float|int $opacity_percent Opacity percentage (0 to 100). * * @return string CSS rgba(...) color string. */ public static function hex_to_rgba( $hex, $opacity_percent = 55 ) { $hex = ltrim( (string) $hex, '#' ); if ( strlen( $hex ) === 3 ) { $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; } if ( strlen( $hex ) !== 6 ) { $hex = '281E19'; } $r = hexdec( substr( $hex, 0, 2 ) ); $g = hexdec( substr( $hex, 2, 2 ) ); $b = hexdec( substr( $hex, 4, 2 ) ); $a = round( max( 0, min( 1, (float) $opacity_percent / 100 ) ), 2 ); return "rgba({$r}, {$g}, {$b}, {$a})"; } /** * Filter callback to inject text overlay layers into tour layout HTML. * * @param string $html Generated tour HTML. * @param array $postdata Saved tour settings. * @param int $id Tour post ID. * * @return string Modified HTML with text overlays, scoped CSS, and companion JS. */ public static function render( $html, $postdata, $id ) { // Resolve panorama DOM element ID if ( preg_match( '/id=[\'"](pano' . absint( $id ) . '(?:_\d+)?)[\'"]/', $html, $m ) ) { $pano_id = $m[1]; } else { $pano_id = 'pano' . absint( $id ); } // Retrieve scenes list $scenes = self::extract_scenes( $postdata, $id ); if ( empty( $scenes ) ) { return $html; } // Identify default scene ID $default_scene_id = self::get_default_scene_id( $scenes, $postdata ); // Extract valid active overlays grouped by scene $overlays_by_scene = self::get_valid_overlays_by_scene( $scenes ); if ( empty( $overlays_by_scene ) ) { return $html; } // Detect Tour Layout (layout1 is Modern, default/others are Classic) $tour_layout = is_array( $postdata['tourLayout'] ?? null ) ? ( $postdata['tourLayout']['layout'] ?? 'default' ) : ( $postdata['tourLayout'] ?? 'default' ); $is_modern = ( 'layout1' === $tour_layout ); $layout_bg_color = is_array( $postdata['tourLayout'] ?? null ) && ! empty( $postdata['tourLayout']['layout_icon_bg_color'] ) ? sanitize_hex_color( $postdata['tourLayout']['layout_icon_bg_color'] ) ?: '#5a536e' : '#5a536e'; $layout_icon_color = is_array( $postdata['tourLayout'] ?? null ) && ! empty( $postdata['tourLayout']['layout_icon_color'] ) ? sanitize_hex_color( $postdata['tourLayout']['layout_icon_color'] ) ?: '#ffffff' : '#ffffff'; // Generate overlay containers, reopen buttons, and bottom-right toggle button $markup = self::render_markup( $overlays_by_scene, $pano_id, $default_scene_id, $is_modern ); $wrapper_html = '
' . $markup['overlays'] . $markup['reopen_buttons'] . $markup['toggle_button'] . '
'; $scene_toggle_config = []; foreach ( $scenes as $sc ) { $sc_id = (string) ( $sc['scene-id'] ?? $sc['id'] ?? '' ); if ( $sc_id !== '' ) { $toggle_val = $sc['scene-show-layer-toggle'] ?? $sc['showLayerToggle'] ?? 'on'; $scene_toggle_config[ $sc_id ] = ( $toggle_val !== 'off' && $toggle_val !== false && $toggle_val !== 0 && $toggle_val !== '0' ); } } $style_html = self::render_styles( $pano_id, $is_modern, $layout_bg_color, $layout_icon_color ); $script_html = self::render_script( $pano_id, $default_scene_id, $is_modern, $scene_toggle_config ); // Insert wrapper directly into the container or append $pattern = '/(]*>)/i'; if ( preg_match( $pattern, $html ) ) { $html = preg_replace( $pattern, '$1' . $wrapper_html, $html, 1 ); } else { $html .= $wrapper_html; } $html .= $style_html . "\n" . $script_html; return $html; } /** * Extract scenes from postdata or database post meta. * * @param array $postdata Tour post data array. * @param int $id Tour post ID. * * @return array List of scenes. */ public static function extract_scenes( $postdata, $id ) { if ( ! empty( $postdata['panodata']['scene-list'] ) && is_array( $postdata['panodata']['scene-list'] ) ) { return $postdata['panodata']['scene-list']; } if ( ! empty( $postdata['scene-list'] ) && is_array( $postdata['scene-list'] ) ) { return $postdata['scene-list']; } if ( ! empty( $postdata['panoscenes'] ) && is_array( $postdata['panoscenes'] ) ) { return $postdata['panoscenes']; } $meta = get_post_meta( $id, 'panodata', true ); if ( is_array( $meta ) ) { if ( ! empty( $meta['panodata']['scene-list'] ) && is_array( $meta['panodata']['scene-list'] ) ) { return $meta['panodata']['scene-list']; } if ( ! empty( $meta['scene-list'] ) && is_array( $meta['scene-list'] ) ) { return $meta['scene-list']; } } return []; } /** * Determine the initial default scene ID. * * @param array $scenes List of scenes. * @param array $postdata Tour postdata array. * * @return string Default scene ID. */ public static function get_default_scene_id( array $scenes, array $postdata ) { $default_scene_id = $postdata['defaultscene'] ?? ''; if ( empty( $default_scene_id ) ) { foreach ( $scenes as $sc ) { if ( ( $sc['dscene'] ?? 'off' ) === 'on' && ! empty( $sc['scene-id'] ) ) { $default_scene_id = (string) $sc['scene-id']; break; } } } if ( empty( $default_scene_id ) && ! empty( $scenes ) ) { $first = reset( $scenes ); $default_scene_id = (string) ( $first['scene-id'] ?? $first['id'] ?? '' ); } return $default_scene_id; } /** * Filter and group valid enabled overlays by scene ID. * * @param array $scenes List of scenes. * * @return array Grouped overlays by scene ID. */ public static function get_valid_overlays_by_scene( array $scenes ) { $overlays_by_scene = []; foreach ( $scenes as $scene ) { $scene_id = (string) ( $scene['scene-id'] ?? $scene['id'] ?? '' ); if ( $scene_id === '' ) { continue; } $raw_overlays = $scene['text-overlays'] ?? $scene['textOverlays'] ?? []; if ( is_string( $raw_overlays ) ) { $raw_overlays = json_decode( $raw_overlays, true ) ?: []; } if ( ! is_array( $raw_overlays ) ) { continue; } foreach ( $raw_overlays as $overlay ) { if ( ! is_array( $overlay ) ) { continue; } // Check enabled flag $is_enabled = ! isset( $overlay['enabled'] ) || ! empty( $overlay['enabled'] ); if ( ! $is_enabled || $overlay['enabled'] === 'false' || $overlay['enabled'] === false ) { continue; } // Check valid template $template = $overlay['template'] ?? ''; if ( ! in_array( $template, [ 'left', 'right', 'top', 'bottom', 'custom' ], true ) ) { continue; } // If custom template, require valid containerRect if ( $template === 'custom' && ( empty( $overlay['containerRect'] ) || ! is_array( $overlay['containerRect'] ) ) ) { continue; } // Ignore empty rich text, including editor-only paragraphs and spaces. $overlay['text'] = TextOverlayContent::sanitize( (string) ( $overlay['text'] ?? '' ) ); $plain_text = html_entity_decode( wp_strip_all_tags( $overlay['text'] ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $plain_text = preg_replace( '/[\s\x{00A0}\x{200B}-\x{200D}\x{FEFF}]/u', '', $plain_text ); if ( $plain_text === '' && ! preg_match( '/<(?:img|video|audio|iframe|svg|canvas|object|embed|hr)\b/i', $overlay['text'] ) ) { continue; } if ( ! isset( $overlays_by_scene[ $scene_id ] ) ) { $overlays_by_scene[ $scene_id ] = []; } $overlays_by_scene[ $scene_id ][] = $overlay; } } return $overlays_by_scene; } /** * Render the overlay DOM elements, reopen buttons, and bottom-right toggle button HTML. * * @param array $overlays_by_scene Overlays grouped by scene ID. * @param string $pano_id Panorama container ID. * @param string $default_scene_id Default scene ID. * @param bool $is_modern Whether current tour layout is Modern (layout1). * * @return array{overlays: string, reopen_buttons: string, toggle_button: string} */ protected static function render_markup( array $overlays_by_scene, $pano_id, $default_scene_id, $is_modern = false ) { $overlays_html = ''; $reopen_buttons_html = ''; foreach ( $overlays_by_scene as $scene_id => $scene_overlays ) { $total_overlays = count( $scene_overlays ); foreach ( $scene_overlays as $idx => $overlay ) { $overlay_id = esc_attr( (string) ( $overlay['id'] ?? wp_generate_uuid4() ) ); $overlay_name = sanitize_text_field( $overlay['name'] ?? 'Text Overlay' ); $template = $overlay['template']; $z_index = 20 - max( 0, (int) $idx ); // Container rect if ( $template === 'custom' || ! empty( $overlay['containerRect'] ) ) { $c_rect = $overlay['containerRect'] ?? []; $rect = [ 'top' => max( 0, min( 100, (float) ( $c_rect['top'] ?? 20 ) ) ), 'left' => max( 0, min( 100, (float) ( $c_rect['left'] ?? 20 ) ) ), 'width' => max( 5, min( 100, (float) ( $c_rect['width'] ?? 60 ) ) ), 'height' => max( 5, min( 100, (float) ( $c_rect['height'] ?? 60 ) ) ), ]; } else { $rect = self::PREDEFINED_TEMPLATES[ $template ] ?? [ 'top' => 0, 'left' => 0, 'width' => 50, 'height' => 100 ]; } // Background color & opacity $bg_color = sanitize_hex_color( $overlay['bgColor'] ?? '' ) ?: '#281E19'; $bg_opacity = isset( $overlay['bgOpacity'] ) ? (float) $overlay['bgOpacity'] : 55.0; $bg_opacity = max( 0, min( 100, $bg_opacity ) ); $rgba_bg = self::hex_to_rgba( $bg_color, $bg_opacity ); // Blur & brightness $blur = max( 0, min( 40, isset( $overlay['blur'] ) ? (float) $overlay['blur'] : 14.0 ) ); $brightness = max( 0, min( 200, isset( $overlay['brightness'] ) ? (float) $overlay['brightness'] : 85.0 ) ); $brightness_ratio = round( $brightness / 100, 2 ); // Corner border radius $raw_radius = $overlay['borderRadius'] ?? 0; $tl = 0; $tr = 0; $br = 0; $bl = 0; if ( is_array( $raw_radius ) ) { $tl = max( 0, (float) ( $raw_radius['topLeft'] ?? 0 ) ); $tr = max( 0, (float) ( $raw_radius['topRight'] ?? 0 ) ); $br = max( 0, (float) ( $raw_radius['bottomRight'] ?? 0 ) ); $bl = max( 0, (float) ( $raw_radius['bottomLeft'] ?? 0 ) ); } elseif ( is_numeric( $raw_radius ) ) { $val = max( 0, (float) $raw_radius ); $tl = $tr = $br = $bl = $val; } $border_radius_css = "{$tl}px {$tr}px {$br}px {$bl}px"; // Text inner positioning & clamping $pos_x = isset( $overlay['position']['x'] ) ? (float) $overlay['position']['x'] : 10.0; $pos_y = isset( $overlay['position']['y'] ) ? (float) $overlay['position']['y'] : 15.0; $width = isset( $overlay['width'] ) ? (float) $overlay['width'] : ( isset( $overlay['position']['width'] ) ? (float) $overlay['position']['width'] : 75.0 ); $pos_x = max( 0, min( 95, $pos_x ) ); $pos_y = max( 0, min( 95, $pos_y ) ); $clamped_width = max( 5, min( 100 - $pos_x, $width ) ); // Rich text HTML $text_html = $overlay['text']; // Close button setting $show_close = ! isset( $overlay['showClose'] ) || ! empty( $overlay['showClose'] ); // Initial display state matching default scene $is_initial_scene = ( $scene_id === $default_scene_id ); $initial_display = $is_initial_scene ? 'block' : 'none'; $text_align = in_array( $overlay['textAlign'] ?? '', [ 'left', 'center', 'right' ], true ) ? $overlay['textAlign'] : 'left'; $container_style = 'display: ' . $initial_display . ';' . ' z-index: ' . $z_index . ';' . ' --wpvr-overlay-z-index: ' . $z_index . ';' . ' --wpvr-overlay-close-inset: ' . ( 12 + $tr * 0.3 ) . 'px;' . ' top: ' . $rect['top'] . '%;' . ' left: ' . $rect['left'] . '%;' . ' width: ' . $rect['width'] . '%;' . ' height: ' . $rect['height'] . '%;' . ' background: ' . $rgba_bg . ';' . ' backdrop-filter: blur(' . $blur . 'px) brightness(' . $brightness_ratio . ');' . ' -webkit-backdrop-filter: blur(' . $blur . 'px) brightness(' . $brightness_ratio . ');' . ' border-radius: ' . $border_radius_css . ';' . ' text-align: ' . $text_align . ';'; $content_style = 'left: ' . $pos_x . '%;' . ' top: ' . $pos_y . '%;' . ' width: ' . $clamped_width . '%;' . ' max-width: calc(100% - ' . $pos_x . '%);' . ' text-align: ' . $text_align . ';'; $close_btn_html = ''; if ( $show_close ) { $close_btn_html = ''; $reopen_pos_style = 'top: ' . max( 15, min( 85, $rect['top'] + 2 ) ) . '%; left: ' . max( 15, min( 85, $rect['left'] + 2 ) ) . '%;'; if ( $template === 'right' ) { $reopen_pos_style = 'top: 20px; right: 20px;'; } elseif ( $template === 'left' ) { $reopen_pos_style = 'top: 20px; left: 70px;'; } elseif ( $template === 'top' ) { $reopen_pos_style = 'top: 20px; left: 70px;'; } elseif ( $template === 'bottom' ) { $reopen_pos_style = 'bottom: 85px; left: 95px;'; } $reopen_buttons_html .= ''; } $overlays_html .= '
' . $close_btn_html . '
' . '
' . $text_html . '
' . '
' . '
'; } } $layout_class = $is_modern ? 'wpvr-text-overlay-toggle-btn--modern' : 'wpvr-text-overlay-toggle-btn--classic'; $toggle_btn_html = ''; return [ 'overlays' => $overlays_html, 'reopen_buttons' => $reopen_buttons_html, 'toggle_button' => $toggle_btn_html, ]; } /** * Render the scoped CSS for the text overlay layer and show/hide controls. * * @param string $pano_id Panorama container ID. * @param bool $is_modern Whether modern tour layout is active. * @param string $layout_bg_color Tour layout background color. * @param string $layout_icon_color Tour layout icon color. * * @return string CSS style block. */ protected static function render_styles( $pano_id, $is_modern = false, $layout_bg_color = '#5a536e', $layout_icon_color = '#ffffff' ) { $p = '#' . esc_attr( $pano_id ); $bg_color_css = esc_attr( $layout_bg_color ); $icon_color_css = esc_attr( $layout_icon_color ); return ''; } /** * Render companion script for scene switching, show/hide toggle, close, and Pannellum event isolation. * * @param string $pano_id Panorama container ID. * @param string $default_scene_id Default scene ID. * @param bool $is_modern Whether modern tour layout is active. * * @return string Script block. */ protected static function render_script( $pano_id, $default_scene_id, $is_modern = false, array $scene_toggle_config = [] ) { $pano_id_json = wp_json_encode( $pano_id ); $default_scene_id_json = wp_json_encode( $default_scene_id ); $is_modern_json = wp_json_encode( (bool) $is_modern ); $scene_toggle_json = wp_json_encode( (object) $scene_toggle_config ); return ''; } }