*/
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 '';
}
}