PluginProbe
WPVR – 360 Panorama viewer and Virtual Tour Builder for WordPress / 9.2.0
WPVR – 360 Panorama viewer and Virtual Tour Builder for WordPress v9.2.0
9.2.0 9.1.4 9.1.3 9.1.2 9.1.1 9.1.0 9.0.3 9.0.2 9.0.1 9.0.0 8.5.79 8.5.78 8.5.77 8.5.76 8.5.75 8.5.74 8.5.73 8.5.72 8.5.71 8.5.70 8.5.69 8.5.68 8.5.35 8.5.36 8.5.37 All 224 releases
wpvr / src / Frontend / TextOverlayRenderer.php

TextOverlayRenderer.php in WPVR – 360 Panorama viewer and Virtual Tour Builder for WordPress 9.2.0, at src/Frontend/TextOverlayRenderer.php

1,054 lines 49.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace RexTheme\WPVR\Frontend;
4
5 use RexTheme\WPVR\Content\TextOverlayContent;
6
7 /**
8 * TextOverlayRenderer
9 *
10 * Handles frontend rendering of text overlay layers on virtual tours,
11 * including multi-layer overlays, predefined & custom templates, custom corner
12 * border radii, backdrop blur/brightness filters, close/reopen controls,
13 * and seamless Pannellum viewer synchronization.
14 *
15 * @package RexTheme\WPVR\Frontend
16 * @since 9.2.0
17 */
18 class TextOverlayRenderer {
19
20 /**
21 * Predefined template bounding geometries (in percentage).
22 *
23 * @var array<string, array{top: int, left: int, width: int, height: int}>
24 */
25 const PREDEFINED_TEMPLATES = [
26 'left' => [ 'top' => 0, 'left' => 0, 'width' => 50, 'height' => 100 ],
27 'right' => [ 'top' => 0, 'left' => 50, 'width' => 50, 'height' => 100 ],
28 'top' => [ 'top' => 0, 'left' => 0, 'width' => 100, 'height' => 50 ],
29 'bottom' => [ 'top' => 50, 'left' => 0, 'width' => 100, 'height' => 50 ],
30 ];
31
32 /**
33 * Register filter hooks for frontend tour rendering.
34 *
35 * @return void
36 */
37 public static function init() {
38 if ( ! has_filter( 'wpvr_generate_tour_layout_html', [ __CLASS__, 'render' ] ) ) {
39 add_filter( 'wpvr_generate_tour_layout_html', [ __CLASS__, 'render' ], 25, 3 );
40 }
41 }
42
43 /**
44 * Convert HEX color and opacity percentage to RGBA CSS string.
45 *
46 * @param string $hex Hex color code (e.g. #281E19 or 281E19).
47 * @param float|int $opacity_percent Opacity percentage (0 to 100).
48 *
49 * @return string CSS rgba(...) color string.
50 */
51 public static function hex_to_rgba( $hex, $opacity_percent = 55 ) {
52 $hex = ltrim( (string) $hex, '#' );
53 if ( strlen( $hex ) === 3 ) {
54 $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
55 }
56 if ( strlen( $hex ) !== 6 ) {
57 $hex = '281E19';
58 }
59
60 $r = hexdec( substr( $hex, 0, 2 ) );
61 $g = hexdec( substr( $hex, 2, 2 ) );
62 $b = hexdec( substr( $hex, 4, 2 ) );
63 $a = round( max( 0, min( 1, (float) $opacity_percent / 100 ) ), 2 );
64
65 return "rgba({$r}, {$g}, {$b}, {$a})";
66 }
67
68 /**
69 * Filter callback to inject text overlay layers into tour layout HTML.
70 *
71 * @param string $html Generated tour HTML.
72 * @param array $postdata Saved tour settings.
73 * @param int $id Tour post ID.
74 *
75 * @return string Modified HTML with text overlays, scoped CSS, and companion JS.
76 */
77 public static function render( $html, $postdata, $id ) {
78 // Resolve panorama DOM element ID
79 if ( preg_match( '/id=[\'"](pano' . absint( $id ) . '(?:_\d+)?)[\'"]/', $html, $m ) ) {
80 $pano_id = $m[1];
81 } else {
82 $pano_id = 'pano' . absint( $id );
83 }
84
85 // Retrieve scenes list
86 $scenes = self::extract_scenes( $postdata, $id );
87 if ( empty( $scenes ) ) {
88 return $html;
89 }
90
91 // Identify default scene ID
92 $default_scene_id = self::get_default_scene_id( $scenes, $postdata );
93
94 // Extract valid active overlays grouped by scene
95 $overlays_by_scene = self::get_valid_overlays_by_scene( $scenes );
96 if ( empty( $overlays_by_scene ) ) {
97 return $html;
98 }
99
100 // Detect Tour Layout (layout1 is Modern, default/others are Classic)
101 $tour_layout = is_array( $postdata['tourLayout'] ?? null )
102 ? ( $postdata['tourLayout']['layout'] ?? 'default' )
103 : ( $postdata['tourLayout'] ?? 'default' );
104 $is_modern = ( 'layout1' === $tour_layout );
105 $layout_bg_color = is_array( $postdata['tourLayout'] ?? null ) && ! empty( $postdata['tourLayout']['layout_icon_bg_color'] )
106 ? sanitize_hex_color( $postdata['tourLayout']['layout_icon_bg_color'] ) ?: '#5a536e'
107 : '#5a536e';
108 $layout_icon_color = is_array( $postdata['tourLayout'] ?? null ) && ! empty( $postdata['tourLayout']['layout_icon_color'] )
109 ? sanitize_hex_color( $postdata['tourLayout']['layout_icon_color'] ) ?: '#ffffff'
110 : '#ffffff';
111
112 // Generate overlay containers, reopen buttons, and bottom-right toggle button
113 $markup = self::render_markup( $overlays_by_scene, $pano_id, $default_scene_id, $is_modern );
114
115 $wrapper_html = '<div id="wpvr-text-overlays-' . esc_attr( $pano_id ) . '" class="wpvr-frontend-text-overlays" data-pano-id="' . esc_attr( $pano_id ) . '">'
116 . $markup['overlays']
117 . $markup['reopen_buttons']
118 . $markup['toggle_button']
119 . '</div>';
120
121 $scene_toggle_config = [];
122 foreach ( $scenes as $sc ) {
123 $sc_id = (string) ( $sc['scene-id'] ?? $sc['id'] ?? '' );
124 if ( $sc_id !== '' ) {
125 $toggle_val = $sc['scene-show-layer-toggle'] ?? $sc['showLayerToggle'] ?? 'on';
126 $scene_toggle_config[ $sc_id ] = ( $toggle_val !== 'off' && $toggle_val !== false && $toggle_val !== 0 && $toggle_val !== '0' );
127 }
128 }
129
130 $style_html = self::render_styles( $pano_id, $is_modern, $layout_bg_color, $layout_icon_color );
131 $script_html = self::render_script( $pano_id, $default_scene_id, $is_modern, $scene_toggle_config );
132
133 // Insert wrapper directly into the container or append
134 $pattern = '/(<div\s+id=[\'"]' . preg_quote( $pano_id, '/' ) . '[\'"][^>]*>)/i';
135 if ( preg_match( $pattern, $html ) ) {
136 $html = preg_replace( $pattern, '$1' . $wrapper_html, $html, 1 );
137 } else {
138 $html .= $wrapper_html;
139 }
140
141 $html .= $style_html . "\n" . $script_html;
142
143 return $html;
144 }
145
146 /**
147 * Extract scenes from postdata or database post meta.
148 *
149 * @param array $postdata Tour post data array.
150 * @param int $id Tour post ID.
151 *
152 * @return array List of scenes.
153 */
154 public static function extract_scenes( $postdata, $id ) {
155 if ( ! empty( $postdata['panodata']['scene-list'] ) && is_array( $postdata['panodata']['scene-list'] ) ) {
156 return $postdata['panodata']['scene-list'];
157 }
158
159 if ( ! empty( $postdata['scene-list'] ) && is_array( $postdata['scene-list'] ) ) {
160 return $postdata['scene-list'];
161 }
162
163 if ( ! empty( $postdata['panoscenes'] ) && is_array( $postdata['panoscenes'] ) ) {
164 return $postdata['panoscenes'];
165 }
166
167 $meta = get_post_meta( $id, 'panodata', true );
168 if ( is_array( $meta ) ) {
169 if ( ! empty( $meta['panodata']['scene-list'] ) && is_array( $meta['panodata']['scene-list'] ) ) {
170 return $meta['panodata']['scene-list'];
171 }
172 if ( ! empty( $meta['scene-list'] ) && is_array( $meta['scene-list'] ) ) {
173 return $meta['scene-list'];
174 }
175 }
176
177 return [];
178 }
179
180 /**
181 * Determine the initial default scene ID.
182 *
183 * @param array $scenes List of scenes.
184 * @param array $postdata Tour postdata array.
185 *
186 * @return string Default scene ID.
187 */
188 public static function get_default_scene_id( array $scenes, array $postdata ) {
189 $default_scene_id = $postdata['defaultscene'] ?? '';
190
191 if ( empty( $default_scene_id ) ) {
192 foreach ( $scenes as $sc ) {
193 if ( ( $sc['dscene'] ?? 'off' ) === 'on' && ! empty( $sc['scene-id'] ) ) {
194 $default_scene_id = (string) $sc['scene-id'];
195 break;
196 }
197 }
198 }
199
200 if ( empty( $default_scene_id ) && ! empty( $scenes ) ) {
201 $first = reset( $scenes );
202 $default_scene_id = (string) ( $first['scene-id'] ?? $first['id'] ?? '' );
203 }
204
205 return $default_scene_id;
206 }
207
208 /**
209 * Filter and group valid enabled overlays by scene ID.
210 *
211 * @param array $scenes List of scenes.
212 *
213 * @return array<string, array> Grouped overlays by scene ID.
214 */
215 public static function get_valid_overlays_by_scene( array $scenes ) {
216 $overlays_by_scene = [];
217
218 foreach ( $scenes as $scene ) {
219 $scene_id = (string) ( $scene['scene-id'] ?? $scene['id'] ?? '' );
220 if ( $scene_id === '' ) {
221 continue;
222 }
223
224 $raw_overlays = $scene['text-overlays'] ?? $scene['textOverlays'] ?? [];
225 if ( is_string( $raw_overlays ) ) {
226 $raw_overlays = json_decode( $raw_overlays, true ) ?: [];
227 }
228 if ( ! is_array( $raw_overlays ) ) {
229 continue;
230 }
231
232 foreach ( $raw_overlays as $overlay ) {
233 if ( ! is_array( $overlay ) ) {
234 continue;
235 }
236
237 // Check enabled flag
238 $is_enabled = ! isset( $overlay['enabled'] ) || ! empty( $overlay['enabled'] );
239 if ( ! $is_enabled || $overlay['enabled'] === 'false' || $overlay['enabled'] === false ) {
240 continue;
241 }
242
243 // Check valid template
244 $template = $overlay['template'] ?? '';
245 if ( ! in_array( $template, [ 'left', 'right', 'top', 'bottom', 'custom' ], true ) ) {
246 continue;
247 }
248
249 // If custom template, require valid containerRect
250 if ( $template === 'custom' && ( empty( $overlay['containerRect'] ) || ! is_array( $overlay['containerRect'] ) ) ) {
251 continue;
252 }
253
254 // Ignore empty rich text, including editor-only paragraphs and spaces.
255 $overlay['text'] = TextOverlayContent::sanitize( (string) ( $overlay['text'] ?? '' ) );
256 $plain_text = html_entity_decode( wp_strip_all_tags( $overlay['text'] ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
257 $plain_text = preg_replace( '/[\s\x{00A0}\x{200B}-\x{200D}\x{FEFF}]/u', '', $plain_text );
258 if ( $plain_text === '' && ! preg_match( '/<(?:img|video|audio|iframe|svg|canvas|object|embed|hr)\b/i', $overlay['text'] ) ) {
259 continue;
260 }
261
262 if ( ! isset( $overlays_by_scene[ $scene_id ] ) ) {
263 $overlays_by_scene[ $scene_id ] = [];
264 }
265
266 $overlays_by_scene[ $scene_id ][] = $overlay;
267 }
268 }
269
270 return $overlays_by_scene;
271 }
272
273 /**
274 * Render the overlay DOM elements, reopen buttons, and bottom-right toggle button HTML.
275 *
276 * @param array $overlays_by_scene Overlays grouped by scene ID.
277 * @param string $pano_id Panorama container ID.
278 * @param string $default_scene_id Default scene ID.
279 * @param bool $is_modern Whether current tour layout is Modern (layout1).
280 *
281 * @return array{overlays: string, reopen_buttons: string, toggle_button: string}
282 */
283 protected static function render_markup( array $overlays_by_scene, $pano_id, $default_scene_id, $is_modern = false ) {
284 $overlays_html = '';
285 $reopen_buttons_html = '';
286
287 foreach ( $overlays_by_scene as $scene_id => $scene_overlays ) {
288 $total_overlays = count( $scene_overlays );
289 foreach ( $scene_overlays as $idx => $overlay ) {
290 $overlay_id = esc_attr( (string) ( $overlay['id'] ?? wp_generate_uuid4() ) );
291 $overlay_name = sanitize_text_field( $overlay['name'] ?? 'Text Overlay' );
292 $template = $overlay['template'];
293 $z_index = 20 - max( 0, (int) $idx );
294
295 // Container rect
296 if ( $template === 'custom' || ! empty( $overlay['containerRect'] ) ) {
297 $c_rect = $overlay['containerRect'] ?? [];
298 $rect = [
299 'top' => max( 0, min( 100, (float) ( $c_rect['top'] ?? 20 ) ) ),
300 'left' => max( 0, min( 100, (float) ( $c_rect['left'] ?? 20 ) ) ),
301 'width' => max( 5, min( 100, (float) ( $c_rect['width'] ?? 60 ) ) ),
302 'height' => max( 5, min( 100, (float) ( $c_rect['height'] ?? 60 ) ) ),
303 ];
304 } else {
305 $rect = self::PREDEFINED_TEMPLATES[ $template ] ?? [ 'top' => 0, 'left' => 0, 'width' => 50, 'height' => 100 ];
306 }
307
308 // Background color & opacity
309 $bg_color = sanitize_hex_color( $overlay['bgColor'] ?? '' ) ?: '#281E19';
310 $bg_opacity = isset( $overlay['bgOpacity'] ) ? (float) $overlay['bgOpacity'] : 55.0;
311 $bg_opacity = max( 0, min( 100, $bg_opacity ) );
312 $rgba_bg = self::hex_to_rgba( $bg_color, $bg_opacity );
313
314 // Blur & brightness
315 $blur = max( 0, min( 40, isset( $overlay['blur'] ) ? (float) $overlay['blur'] : 14.0 ) );
316 $brightness = max( 0, min( 200, isset( $overlay['brightness'] ) ? (float) $overlay['brightness'] : 85.0 ) );
317 $brightness_ratio = round( $brightness / 100, 2 );
318
319 // Corner border radius
320 $raw_radius = $overlay['borderRadius'] ?? 0;
321 $tl = 0; $tr = 0; $br = 0; $bl = 0;
322 if ( is_array( $raw_radius ) ) {
323 $tl = max( 0, (float) ( $raw_radius['topLeft'] ?? 0 ) );
324 $tr = max( 0, (float) ( $raw_radius['topRight'] ?? 0 ) );
325 $br = max( 0, (float) ( $raw_radius['bottomRight'] ?? 0 ) );
326 $bl = max( 0, (float) ( $raw_radius['bottomLeft'] ?? 0 ) );
327 } elseif ( is_numeric( $raw_radius ) ) {
328 $val = max( 0, (float) $raw_radius );
329 $tl = $tr = $br = $bl = $val;
330 }
331 $border_radius_css = "{$tl}px {$tr}px {$br}px {$bl}px";
332
333 // Text inner positioning & clamping
334 $pos_x = isset( $overlay['position']['x'] ) ? (float) $overlay['position']['x'] : 10.0;
335 $pos_y = isset( $overlay['position']['y'] ) ? (float) $overlay['position']['y'] : 15.0;
336 $width = isset( $overlay['width'] )
337 ? (float) $overlay['width']
338 : ( isset( $overlay['position']['width'] ) ? (float) $overlay['position']['width'] : 75.0 );
339
340 $pos_x = max( 0, min( 95, $pos_x ) );
341 $pos_y = max( 0, min( 95, $pos_y ) );
342 $clamped_width = max( 5, min( 100 - $pos_x, $width ) );
343
344 // Rich text HTML
345 $text_html = $overlay['text'];
346
347 // Close button setting
348 $show_close = ! isset( $overlay['showClose'] ) || ! empty( $overlay['showClose'] );
349
350 // Initial display state matching default scene
351 $is_initial_scene = ( $scene_id === $default_scene_id );
352 $initial_display = $is_initial_scene ? 'block' : 'none';
353
354 $text_align = in_array( $overlay['textAlign'] ?? '', [ 'left', 'center', 'right' ], true ) ? $overlay['textAlign'] : 'left';
355
356 $container_style = 'display: ' . $initial_display . ';'
357 . ' z-index: ' . $z_index . ';'
358 . ' --wpvr-overlay-z-index: ' . $z_index . ';'
359 . ' --wpvr-overlay-close-inset: ' . ( 12 + $tr * 0.3 ) . 'px;'
360 . ' top: ' . $rect['top'] . '%;'
361 . ' left: ' . $rect['left'] . '%;'
362 . ' width: ' . $rect['width'] . '%;'
363 . ' height: ' . $rect['height'] . '%;'
364 . ' background: ' . $rgba_bg . ';'
365 . ' backdrop-filter: blur(' . $blur . 'px) brightness(' . $brightness_ratio . ');'
366 . ' -webkit-backdrop-filter: blur(' . $blur . 'px) brightness(' . $brightness_ratio . ');'
367 . ' border-radius: ' . $border_radius_css . ';'
368 . ' text-align: ' . $text_align . ';';
369
370 $content_style = 'left: ' . $pos_x . '%;'
371 . ' top: ' . $pos_y . '%;'
372 . ' width: ' . $clamped_width . '%;'
373 . ' max-width: calc(100% - ' . $pos_x . '%);'
374 . ' text-align: ' . $text_align . ';';
375
376 $close_btn_html = '';
377 if ( $show_close ) {
378 $close_btn_html = '<button type="button" class="wpvr-text-overlay-close-btn" data-overlay-id="' . $overlay_id . '" aria-label="' . esc_attr__( 'Close overlay', 'wpvr' ) . '">'
379 . '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>'
380 . '</button>';
381
382 $reopen_pos_style = 'top: ' . max( 15, min( 85, $rect['top'] + 2 ) ) . '%; left: ' . max( 15, min( 85, $rect['left'] + 2 ) ) . '%;';
383 if ( $template === 'right' ) {
384 $reopen_pos_style = 'top: 20px; right: 20px;';
385 } elseif ( $template === 'left' ) {
386 $reopen_pos_style = 'top: 20px; left: 70px;';
387 } elseif ( $template === 'top' ) {
388 $reopen_pos_style = 'top: 20px; left: 70px;';
389 } elseif ( $template === 'bottom' ) {
390 $reopen_pos_style = 'bottom: 85px; left: 95px;';
391 }
392
393 $reopen_buttons_html .= '<button type="button" class="wpvr-text-overlay-reopen-btn" data-overlay-id="' . $overlay_id . '" data-scene-id="' . esc_attr( $scene_id ) . '" style="display: none; ' . $reopen_pos_style . '" aria-label="' . esc_attr__( 'Show overlay', 'wpvr' ) . '">'
394 . '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
395 . '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>'
396 . '<polyline points="14 2 14 8 20 8"></polyline>'
397 . '<line x1="16" y1="13" x2="8" y2="13"></line>'
398 . '<line x1="16" y1="17" x2="8" y2="17"></line>'
399 . '</svg>'
400 . '<span>' . esc_html( $overlay_name ) . '</span>'
401 . '</button>';
402 }
403
404 $overlays_html .= '<div id="wpvr-overlay-' . esc_attr( $pano_id ) . '-' . $overlay_id . '" class="wpvr-frontend-text-overlay wpvr-frontend-text-overlay--template-' . esc_attr( $template ) . '" data-overlay-id="' . $overlay_id . '" data-scene-id="' . esc_attr( $scene_id ) . '" style="' . $container_style . '">'
405 . $close_btn_html
406 . '<div class="wpvr-frontend-text-overlay__body" style="border-radius: ' . $border_radius_css . ';">'
407 . '<div class="wpvr-frontend-text-overlay__content" style="' . $content_style . '">'
408 . $text_html
409 . '</div>'
410 . '</div>'
411 . '</div>';
412 }
413 }
414
415 $layout_class = $is_modern ? 'wpvr-text-overlay-toggle-btn--modern' : 'wpvr-text-overlay-toggle-btn--classic';
416 $toggle_btn_html = '<button type="button" id="wpvr-text-overlay-toggle-' . esc_attr( $pano_id ) . '" class="wpvr-text-overlay-toggle-btn ' . esc_attr( $layout_class ) . '" aria-label="' . esc_attr__( 'Hide text overlay', 'wpvr' ) . '" title="' . esc_attr__( 'Hide text overlay', 'wpvr' ) . '" style="display: none;">'
417 . '<svg class="wpvr-toggle-icon-visible" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
418 . '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>'
419 . '<circle cx="12" cy="12" r="3"></circle>'
420 . '</svg>'
421 . '<svg class="wpvr-toggle-icon-hidden" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="display: none;">'
422 . '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>'
423 . '<line x1="1" y1="1" x2="23" y2="23"></line>'
424 . '</svg>'
425 . '</button>';
426
427 return [
428 'overlays' => $overlays_html,
429 'reopen_buttons' => $reopen_buttons_html,
430 'toggle_button' => $toggle_btn_html,
431 ];
432 }
433
434 /**
435 * Render the scoped CSS for the text overlay layer and show/hide controls.
436 *
437 * @param string $pano_id Panorama container ID.
438 * @param bool $is_modern Whether modern tour layout is active.
439 * @param string $layout_bg_color Tour layout background color.
440 * @param string $layout_icon_color Tour layout icon color.
441 *
442 * @return string CSS style block.
443 */
444 protected static function render_styles( $pano_id, $is_modern = false, $layout_bg_color = '#5a536e', $layout_icon_color = '#ffffff' ) {
445 $p = '#' . esc_attr( $pano_id );
446 $bg_color_css = esc_attr( $layout_bg_color );
447 $icon_color_css = esc_attr( $layout_icon_color );
448
449 return '<style id="wpvr-text-overlays-style-' . esc_attr( $pano_id ) . '">
450 /* Text alignment defaults */
451 ' . $p . ' .wpvr-frontend-text-overlay,
452 ' . $p . ' .wpvr-frontend-text-overlay__body,
453 ' . $p . ' .wpvr-frontend-text-overlay__content {
454 text-align: left !important;
455 }
456 ' . $p . ' .wpvr-frontend-text-overlay__content p:not([style*="text-align"]),
457 ' . $p . ' .wpvr-frontend-text-overlay__content h1:not([style*="text-align"]),
458 ' . $p . ' .wpvr-frontend-text-overlay__content h2:not([style*="text-align"]),
459 ' . $p . ' .wpvr-frontend-text-overlay__content h3:not([style*="text-align"]),
460 ' . $p . ' .wpvr-frontend-text-overlay__content h4:not([style*="text-align"]),
461 ' . $p . ' .wpvr-frontend-text-overlay__content h5:not([style*="text-align"]),
462 ' . $p . ' .wpvr-frontend-text-overlay__content h6:not([style*="text-align"]),
463 ' . $p . ' .wpvr-frontend-text-overlay__content div:not([style*="text-align"]),
464 ' . $p . ' .wpvr-frontend-text-overlay__content span:not([style*="text-align"]) {
465 text-align: left !important;
466 }
467
468 /* Hotspots must remain behind text overlays */
469 ' . $p . ' .pnlm-hotspot-base,
470 ' . $p . ' .pnlm-hotspot,
471 ' . $p . ' .pnlm-tooltip {
472 z-index: 2 !important;
473 }
474
475 /* Text overlays layer */
476 ' . $p . ' .wpvr-frontend-text-overlays {
477 z-index: 10 !important;
478 }
479 ' . $p . ' .wpvr-frontend-text-overlay {
480 z-index: var(--wpvr-overlay-z-index, 10) !important;
481 }
482
483 /* Pannellum UI overlay container and drag surface stay below text overlays and controls */
484 ' . $p . ' .pnlm-ui {
485 z-index: auto !important;
486 }
487 ' . $p . ' .pnlm-dragfix {
488 z-index: 1 !important;
489 pointer-events: auto !important;
490 }
491
492 /* Tour control buttons & other tour-related buttons have preference over overlay */
493 ' . $p . ' .pnlm-controls-container,
494 ' . $p . ' .pnlm-controls-container *,
495 ' . $p . ' .pnlm-controls,
496 ' . $p . ' .pnlm-controls *,
497 ' . $p . ' .pnlm-control,
498 ' . $p . ' .pnlm-control *,
499 ' . $p . ' .pnlm-zoom-controls,
500 ' . $p . ' .pnlm-zoom-controls *,
501 ' . $p . ' .pnlm-fullscreen-toggle-button,
502 ' . $p . ' .pnlm-orientation-button,
503 ' . $p . ' .pnlm-compass,
504 ' . $p . ' .pnlm-load-button,
505 ' . $p . ' .pnlm-load-box,
506 ' . $p . ' .pnlm-panorama-info,
507 ' . $p . ' .zoom-in-out-controls,
508 ' . $p . ' .zoom-in-out-controls *,
509 ' . $p . ' .controls,
510 ' . $p . ' .controls *,
511 ' . $p . ' .ctrl,
512 ' . $p . ' .ctrl *,
513 ' . $p . ' .explainer_button,
514 ' . $p . ' .explainer_button *,
515 ' . $p . ' .floor_map_button,
516 ' . $p . ' .floor_map_button *,
517 ' . $p . ' .generic_form_button,
518 ' . $p . ' .generic_form_button *,
519 ' . $p . ' .adcontrol,
520 ' . $p . ' .adcontrol *,
521 ' . $p . ' .audio_control,
522 ' . $p . ' .audio_control *,
523 ' . $p . ' #cp-logo-controls,
524 ' . $p . ' #cp-logo-controls *,
525 ' . $p . ' .cp-logo-ctrl,
526 ' . $p . ' .cp-logo-ctrl *,
527 ' . $p . ' .scene-gallery,
528 ' . $p . ' .scene-gallery *,
529 ' . $p . ' .vrowl-carousel,
530 ' . $p . ' .vrowl-carousel *,
531 ' . $p . ' .wpvr_slider_nav,
532 ' . $p . ' .wpvr_slider_nav *,
533 ' . $p . ' .wpvr_owl_prev,
534 ' . $p . ' .wpvr_owl_prev *,
535 ' . $p . ' .wpvr_owl_next,
536 ' . $p . ' .wpvr_owl_next *,
537 ' . $p . ' .vrgcontrols,
538 ' . $p . ' .vrgcontrols *,
539 ' . $p . ' .wpvr-scene-info-row,
540 ' . $p . ' .wpvr-cardboard-switcher,
541 ' . $p . ' .wpvr-cardboard-switcher *,
542 ' . $p . ' .wpvr-call-to-action-button,
543 ' . $p . ' .fullscreen-button,
544 ' . $p . ' .vr-pointer-container {
545 z-index: 40 !important;
546 pointer-events: auto !important;
547 }
548
549 /* Overlay action buttons (close and reopen) */
550 ' . $p . ' .wpvr-text-overlay-close-btn,
551 ' . $p . ' .wpvr-text-overlay-reopen-btn {
552 z-index: 40 !important;
553 pointer-events: auto !important;
554 }
555
556 /* Show/Hide Text Overlay Toggle Button - Common */
557 ' . $p . ' .wpvr-text-overlay-toggle-btn {
558 position: absolute;
559 display: inline-flex;
560 align-items: center;
561 justify-content: center;
562 z-index: 40 !important;
563 pointer-events: auto !important;
564 cursor: pointer !important;
565 outline: none;
566 padding: 0;
567 margin: 0;
568 box-sizing: border-box;
569 -webkit-tap-highlight-color: transparent;
570 user-select: none;
571 -webkit-user-select: none;
572 }
573
574 /* Show/Hide Toggle Button - Modern Layout (matches layout1 Scene UI) */
575 ' . $p . ' .wpvr-text-overlay-toggle-btn--modern {
576 width: 40px;
577 height: 40px;
578 border-radius: 50% !important;
579 background-color: ' . $bg_color_css . ' !important;
580 color: ' . $icon_color_css . ' !important;
581 border: none !important;
582 box-shadow: 0px 3px 3px 0px rgba(0, 0, 0, 0.16) !important;
583 bottom: 45px;
584 right: 140px;
585 transition: transform 0.2s ease, opacity 0.2s ease;
586 }
587 ' . $p . ' .wpvr-text-overlay-toggle-btn--modern:hover {
588 transform: scale(1.08);
589 }
590 ' . $p . ' .wpvr-text-overlay-toggle-btn--modern svg {
591 width: 20px;
592 height: 20px;
593 stroke: ' . $icon_color_css . ';
594 }
595
596 /* Show/Hide Toggle Button - Classic Layout (matches Classic Explainer Video Button UI & size) */
597 ' . $p . ' .wpvr-text-overlay-toggle-btn--classic {
598 width: 30px !important;
599 height: 30px !important;
600 border-radius: 0 !important;
601 background: transparent !important;
602 background-color: transparent !important;
603 color: #f7fffb !important;
604 border: none !important;
605 box-shadow: none !important;
606 bottom: 49px;
607 right: 110px;
608 filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.85)) !important;
609 transition: transform 0.2s ease, opacity 0.2s ease;
610 }
611 ' . $p . ' .wpvr-text-overlay-toggle-btn--classic:hover {
612 background: transparent !important;
613 background-color: transparent !important;
614 transform: scale(1.15) !important;
615 opacity: 0.9 !important;
616 }
617 ' . $p . ' .wpvr-text-overlay-toggle-btn--classic:focus,
618 ' . $p . ' .wpvr-text-overlay-toggle-btn--classic:active {
619 outline: none !important;
620 background: transparent !important;
621 background-color: transparent !important;
622 box-shadow: none !important;
623 }
624 ' . $p . ' .wpvr-text-overlay-toggle-btn--classic svg {
625 width: 18px !important;
626 height: 18px !important;
627 stroke: #f7fffb !important;
628 fill: none !important;
629 }
630
631 /* Responsive styles for mobile devices */
632 @media (max-width: 767px) {
633 ' . $p . ' .wpvr-text-overlay-toggle-btn--modern {
634 width: 25px !important;
635 height: 25px !important;
636 bottom: 42px;
637 right: 95px;
638 }
639 ' . $p . ' .wpvr-text-overlay-toggle-btn--modern svg {
640 width: 14px !important;
641 height: 14px !important;
642 }
643
644 ' . $p . ' .wpvr-text-overlay-toggle-btn--classic {
645 width: 26px !important;
646 height: 26px !important;
647 bottom: 49px;
648 right: 95px;
649 }
650 ' . $p . ' .wpvr-text-overlay-toggle-btn--classic svg {
651 width: 16px !important;
652 height: 16px !important;
653 }
654 }
655
656 /* Scene Navigation Menu (Hamburger) & Dropdown */
657 #master-container .custom-scene-navigation,
658 #master-container ' . $p . ' .custom-scene-navigation,
659 ' . $p . ' .custom-scene-navigation,
660 ' . $p . ' [id^="custom-scene-navigation"],
661 .custom-scene-navigation {
662 z-index: 40 !important;
663 pointer-events: auto !important;
664 cursor: pointer !important;
665 }
666
667 ' . $p . ' .custom-scene-navigation .hamburger-menu,
668 ' . $p . ' .custom-scene-navigation .hamburger-menu svg,
669 ' . $p . ' .custom-scene-navigation .hamburger-menu svg rect {
670 pointer-events: auto !important;
671 cursor: pointer !important;
672 }
673
674 #master-container .custom-scene-navigation-nav,
675 #master-container ' . $p . ' .custom-scene-navigation-nav,
676 ' . $p . ' .custom-scene-navigation-nav,
677 ' . $p . ' [id^="custom-scene-navigation-nav"],
678 .custom-scene-navigation-nav {
679 z-index: 50 !important;
680 pointer-events: auto !important;
681 }
682
683 ' . $p . ' .custom-scene-navigation-nav ul,
684 ' . $p . ' .custom-scene-navigation-nav ul .scene-navigation-list {
685 pointer-events: auto !important;
686 cursor: pointer !important;
687 }
688 </style>';
689 }
690
691 /**
692 * Render companion script for scene switching, show/hide toggle, close, and Pannellum event isolation.
693 *
694 * @param string $pano_id Panorama container ID.
695 * @param string $default_scene_id Default scene ID.
696 * @param bool $is_modern Whether modern tour layout is active.
697 *
698 * @return string Script block.
699 */
700 protected static function render_script( $pano_id, $default_scene_id, $is_modern = false, array $scene_toggle_config = [] ) {
701 $pano_id_json = wp_json_encode( $pano_id );
702 $default_scene_id_json = wp_json_encode( $default_scene_id );
703 $is_modern_json = wp_json_encode( (bool) $is_modern );
704 $scene_toggle_json = wp_json_encode( (object) $scene_toggle_config );
705
706 return '<script id="wpvr-text-overlays-script-' . esc_attr( $pano_id ) . '">
707 (function () {
708 var panoId = ' . $pano_id_json . ';
709 var defaultSceneId = ' . $default_scene_id_json . ';
710 var isModern = ' . $is_modern_json . ';
711 var sceneToggleConfig = ' . $scene_toggle_json . ';
712
713 function initTextOverlays() {
714 var pano = document.getElementById(panoId);
715 if (!pano) return;
716
717 var wrap = document.getElementById("wpvr-text-overlays-" + panoId);
718 if (wrap && wrap.parentNode !== pano) {
719 pano.appendChild(wrap);
720 }
721
722 var toggleBtn = document.getElementById("wpvr-text-overlay-toggle-" + panoId);
723
724 function insertToggleBtn() {
725 if (!toggleBtn || !pano) return;
726 var compass = pano.querySelector(".pnlm-compass");
727 if (compass && compass.parentNode) {
728 if (toggleBtn.parentNode !== compass.parentNode || toggleBtn.nextSibling !== compass) {
729 compass.parentNode.insertBefore(toggleBtn, compass);
730 }
731 } else {
732 var uiContainer = pano.querySelector(".pnlm-ui");
733 var targetParent = uiContainer || pano;
734 if (toggleBtn.parentNode !== targetParent) {
735 targetParent.insertBefore(toggleBtn, targetParent.firstChild);
736 }
737 }
738 updateToggleBtnPosition();
739 }
740
741 function updateToggleBtnPosition() {
742 if (!toggleBtn || !pano) return;
743
744 var isMobile = window.innerWidth <= 767;
745 var gap = isModern ? (isMobile ? 8 : 10) : 10;
746
747 // Check all bottom-right controls so we position before the leftmost one
748 var selectors = [
749 ".pnlm-compass",
750 ".explainer_button",
751 ".floor_map_button",
752 ".adcontrol",
753 ".pnlm-controls-container"
754 ];
755 if (isModern) {
756 selectors.push(".controls");
757 }
758
759 var maxRightEdge = 0;
760 var matchedBottom = null;
761
762 for (var i = 0; i < selectors.length; i++) {
763 var el = pano.querySelector(selectors[i]);
764 if (!el) continue;
765 var style = window.getComputedStyle(el);
766 if (style.display === "none" || style.visibility === "hidden") continue;
767
768 var r = parseFloat(style.right);
769 var w = parseFloat(style.width);
770 var b = style.bottom;
771
772 var panoW = pano.offsetWidth || 1000;
773 if (!isNaN(r) && r >= 0 && r < panoW * 0.7) {
774 var fallbackWidth = isModern ? 40 : (el.classList.contains("explainer_button") || el.classList.contains("floor_map_button") ? 50 : 30);
775 var elemWidth = (!isNaN(w) && w > 0) ? w : (el.offsetWidth || fallbackWidth);
776 var rightEdge = r + elemWidth;
777 if (rightEdge > maxRightEdge) {
778 maxRightEdge = rightEdge;
779 matchedBottom = b;
780 }
781 }
782 }
783
784 var defaultBottom = isModern ? (isMobile ? "42px" : "45px") : "49px";
785 var finalBottom = matchedBottom || defaultBottom;
786
787 if (maxRightEdge > 0) {
788 toggleBtn.style.right = (maxRightEdge + gap) + "px";
789 toggleBtn.style.bottom = finalBottom;
790 } else {
791 toggleBtn.style.right = isModern ? (isMobile ? "20px" : "40px") : "15px";
792 toggleBtn.style.bottom = defaultBottom;
793 }
794
795 // Dimensions: on Classic layout, match explainer button size (30px / 26px mobile)
796 if (!isModern) {
797 var classicSize = isMobile ? "26px" : "30px";
798 toggleBtn.style.width = classicSize;
799 toggleBtn.style.height = classicSize;
800 }
801 }
802
803 function setToggleState(isVisible) {
804 if (!toggleBtn) return;
805 var iconVisible = toggleBtn.querySelector(".wpvr-toggle-icon-visible");
806 var iconHidden = toggleBtn.querySelector(".wpvr-toggle-icon-hidden");
807 if (isVisible) {
808 if (iconVisible) iconVisible.style.display = "block";
809 if (iconHidden) iconHidden.style.display = "none";
810 toggleBtn.setAttribute("title", "Hide text overlay");
811 toggleBtn.setAttribute("aria-label", "Hide text overlay");
812 toggleBtn.classList.remove("wpvr-toggle-btn--hidden");
813 toggleBtn.classList.add("wpvr-toggle-btn--visible");
814 } else {
815 if (iconVisible) iconVisible.style.display = "none";
816 if (iconHidden) iconHidden.style.display = "block";
817 toggleBtn.setAttribute("title", "Show text overlay");
818 toggleBtn.setAttribute("aria-label", "Show text overlay");
819 toggleBtn.classList.remove("wpvr-toggle-btn--visible");
820 toggleBtn.classList.add("wpvr-toggle-btn--hidden");
821 }
822 }
823
824 var isLifting = false;
825 function liftPannellumControls() {
826 if (!pano || isLifting) return;
827 isLifting = true;
828 try {
829 var pnlmControls = pano.querySelector(".pnlm-ui > .pnlm-controls-container");
830 if (pnlmControls && pnlmControls.parentNode !== pano) {
831 pano.appendChild(pnlmControls);
832 }
833 var pnlmCompass = pano.querySelector(".pnlm-ui > .pnlm-compass");
834 if (pnlmCompass && pnlmCompass.parentNode !== pano) {
835 pano.appendChild(pnlmCompass);
836 }
837 var pnlmInfo = pano.querySelector(".pnlm-ui > .pnlm-panorama-info");
838 if (pnlmInfo && pnlmInfo.parentNode !== pano) {
839 pano.appendChild(pnlmInfo);
840 }
841 insertToggleBtn();
842 } finally {
843 isLifting = false;
844 }
845 }
846
847 liftPannellumControls();
848 insertToggleBtn();
849
850 var activeSceneId = defaultSceneId;
851
852 function syncOverlays(sceneId) {
853 if (!sceneId) return;
854 activeSceneId = sceneId;
855
856 insertToggleBtn();
857
858 var allOverlays = pano.querySelectorAll(".wpvr-frontend-text-overlay[data-scene-id]");
859 var sceneOverlaysCount = 0;
860 var anyVisible = false;
861
862 for (var i = 0; i < allOverlays.length; i++) {
863 var el = allOverlays[i];
864 var overlayScene = el.getAttribute("data-scene-id");
865 var overlayId = el.getAttribute("data-overlay-id");
866
867 if (overlayScene === sceneId) {
868 sceneOverlaysCount++;
869 var isClosed = el.getAttribute("data-closed") === "true";
870 if (isClosed) {
871 el.style.display = "none";
872 } else {
873 el.style.display = "block";
874 anyVisible = true;
875 }
876 } else {
877 el.style.display = "none";
878 }
879 }
880
881 if (toggleBtn) {
882 var isToggleAllowed = sceneToggleConfig && sceneToggleConfig[sceneId] !== false;
883 if (sceneOverlaysCount > 0 && isToggleAllowed) {
884 toggleBtn.style.display = "inline-flex";
885 setToggleState(anyVisible);
886 updateToggleBtnPosition();
887 } else {
888 toggleBtn.style.display = "none";
889 }
890 }
891 }
892
893 function attachViewer(viewer) {
894 if (!viewer || viewer._wpvrTextOverlaysBound) return;
895 viewer._wpvrTextOverlaysBound = true;
896 liftPannellumControls();
897
898 viewer.on("scenechange", function (newSceneId) {
899 syncOverlays(newSceneId);
900 liftPannellumControls();
901 });
902 viewer.on("load", function () {
903 var s = viewer.getScene();
904 if (s) syncOverlays(s);
905 liftPannellumControls();
906 });
907
908 var initial = viewer.getScene();
909 if (initial) {
910 syncOverlays(initial);
911 }
912 }
913
914 if (window.wpvrViewers && window.wpvrViewers[panoId]) {
915 attachViewer(window.wpvrViewers[panoId]);
916 }
917
918 document.addEventListener("wpvr:viewer-ready", function (e) {
919 if (e.detail && e.detail.containerId === panoId && e.detail.viewer) {
920 attachViewer(e.detail.viewer);
921 }
922 });
923
924 if (window.jQuery) {
925 window.jQuery(document).on("wpvr_scene_changed", function (e, scId) {
926 syncOverlays(scId);
927 });
928 }
929
930 window.addEventListener("resize", updateToggleBtnPosition);
931
932 var attempts = 0;
933 var checkInterval = setInterval(function () {
934 attempts++;
935 liftPannellumControls();
936 if (window.wpvrViewers && window.wpvrViewers[panoId]) {
937 attachViewer(window.wpvrViewers[panoId]);
938 clearInterval(checkInterval);
939 } else if (attempts > 40) {
940 clearInterval(checkInterval);
941 }
942 }, 100);
943
944 if (window.MutationObserver) {
945 var obs = new MutationObserver(function () {
946 if (!isLifting) {
947 liftPannellumControls();
948 }
949 });
950 obs.observe(pano, { childList: true });
951 }
952
953 syncOverlays(defaultSceneId);
954
955 // Show/Hide toggle button click handler
956 if (toggleBtn) {
957 toggleBtn.addEventListener("click", function (e) {
958 e.stopPropagation();
959 e.preventDefault();
960 var sceneOverlays = pano.querySelectorAll(\'.wpvr-frontend-text-overlay[data-scene-id="\' + activeSceneId + \'"]\');
961 if (!sceneOverlays.length) return;
962
963 var anyVisible = false;
964 for (var i = 0; i < sceneOverlays.length; i++) {
965 if (sceneOverlays[i].getAttribute("data-closed") !== "true" && sceneOverlays[i].style.display !== "none") {
966 anyVisible = true;
967 break;
968 }
969 }
970
971 if (anyVisible) {
972 for (var j = 0; j < sceneOverlays.length; j++) {
973 sceneOverlays[j].setAttribute("data-closed", "true");
974 sceneOverlays[j].style.display = "none";
975 }
976 setToggleState(false);
977 } else {
978 for (var k = 0; k < sceneOverlays.length; k++) {
979 sceneOverlays[k].removeAttribute("data-closed");
980 sceneOverlays[k].style.display = "block";
981 }
982 setToggleState(true);
983 }
984 });
985 }
986
987 // Window resize repositioning
988 window.addEventListener("resize", updateToggleBtnPosition);
989
990 // Prevent dragging panorama when interacting with overlay or toggle button
991 var stopEvents = ["mousedown", "pointerdown", "touchstart", "touchmove", "wheel", "dblclick"];
992 var stopFn = function (e) {
993 e.stopPropagation();
994 };
995
996 var stopTargets = pano.querySelectorAll(".wpvr-frontend-text-overlay, .wpvr-text-overlay-reopen-btn, .wpvr-text-overlay-toggle-btn");
997 for (var j = 0; j < stopTargets.length; j++) {
998 for (var k = 0; k < stopEvents.length; k++) {
999 stopTargets[j].addEventListener(stopEvents[k], stopFn, { passive: stopEvents[k].indexOf("touch") === 0 || stopEvents[k] === "wheel" });
1000 }
1001 }
1002
1003 // Close buttons
1004 var closeButtons = pano.querySelectorAll(".wpvr-text-overlay-close-btn");
1005 for (var c = 0; c < closeButtons.length; c++) {
1006 closeButtons[c].addEventListener("click", function (e) {
1007 e.stopPropagation();
1008 e.preventDefault();
1009 var overlayId = this.getAttribute("data-overlay-id");
1010 var overlayEl = pano.querySelector(\'.wpvr-frontend-text-overlay[data-overlay-id="\' + overlayId + \'"]\');
1011 if (overlayEl) {
1012 overlayEl.setAttribute("data-closed", "true");
1013 overlayEl.style.display = "none";
1014 }
1015 var sceneOverlays = pano.querySelectorAll(\'.wpvr-frontend-text-overlay[data-scene-id="\' + activeSceneId + \'"]\');
1016 var anyVisible = false;
1017 for (var s = 0; s < sceneOverlays.length; s++) {
1018 if (sceneOverlays[s].getAttribute("data-closed") !== "true" && sceneOverlays[s].style.display !== "none") {
1019 anyVisible = true;
1020 break;
1021 }
1022 }
1023 setToggleState(anyVisible);
1024 });
1025 }
1026
1027 // Reopen buttons (if present)
1028 var reopenButtons = pano.querySelectorAll(".wpvr-text-overlay-reopen-btn");
1029 for (var r = 0; r < reopenButtons.length; r++) {
1030 reopenButtons[r].addEventListener("click", function (e) {
1031 e.stopPropagation();
1032 e.preventDefault();
1033 var overlayId = this.getAttribute("data-overlay-id");
1034 var overlayEl = pano.querySelector(\'.wpvr-frontend-text-overlay[data-overlay-id="\' + overlayId + \'"]\');
1035 if (overlayEl) {
1036 overlayEl.removeAttribute("data-closed");
1037 overlayEl.style.display = "block";
1038 }
1039 this.style.display = "none";
1040 setToggleState(true);
1041 });
1042 }
1043 }
1044
1045 if (document.readyState === "loading") {
1046 document.addEventListener("DOMContentLoaded", initTextOverlays);
1047 } else {
1048 initTextOverlays();
1049 }
1050 }());
1051 </script>';
1052 }
1053 }
1054