post_content, 'a360_panorama')
|| has_block('a360/panorama-hero', $post->post_content)
);
if (! $has_panorama && is_front_page() && function_exists('get_block_template')) {
$template = get_block_template(get_stylesheet() . '//front-page', 'wp_template');
$has_panorama = $template && has_block('a360/panorama-hero', (string) $template->content);
}
if ($has_panorama) {
wp_enqueue_style('a360-panorama');
wp_enqueue_script('a360-panorama-viewer');
}
}
add_action('wp_enqueue_scripts', 'a360_panorama_maybe_enqueue_assets', 20);
/**
* Prints a late-enqueued stylesheet when a page builder renders the shortcode
* after wp_head has already run.
*/
function a360_panorama_print_late_styles()
{
if (wp_style_is('a360-panorama', 'enqueued') && ! wp_style_is('a360-panorama', 'done')) {
wp_print_styles('a360-panorama');
}
}
add_action('wp_footer', 'a360_panorama_print_late_styles', 1);
/**
* Resolves a shortcode image value.
*
* Supported values:
* - attachment ID,
* - absolute http(s) URL,
* - root-relative URL,
* - filename stored in the plugin's panoramas directory.
*
* @param string $image Shortcode image value.
* @return string
*/
function a360_panorama_resolve_image_url($image)
{
$image = trim((string) $image);
if ('' === $image) {
return '';
}
if (ctype_digit($image)) {
$attachment_url = wp_get_attachment_url((int) $image);
return $attachment_url ? $attachment_url : '';
}
if (preg_match('#^https?://#i', $image)) {
return esc_url_raw($image);
}
if (0 === strpos($image, '/')) {
return esc_url_raw(home_url($image));
}
$filename = wp_basename($image);
return esc_url_raw(A360_PANORAMA_URL . 'panoramas/' . rawurlencode($filename));
}
/**
* Renders a single, automatically rotating panorama.
*
* @param array $atts Shortcode attributes.
* @return string
*/
function a360_panorama_shortcode($atts)
{
$atts = shortcode_atts(
array(
'image' => '',
'images' => '',
'interval' => '8',
'random' => '0',
'poster' => '',
'height' => '600',
'speed' => '2',
'fov' => '75',
'resume' => '4',
'autoplay' => 'always',
'label' => __('Interactive 360-degree panorama', 'a360-panorama-hero'),
), $atts, 'a360_panorama'
);
$image_values = array_filter(array_map('trim', explode(',', (string) $atts['images'])));
if (empty($image_values) && '' !== trim((string) $atts['image'])) $image_values[] = $atts['image'];
$image_urls = array_values(array_unique(array_filter(array_map('a360_panorama_resolve_image_url', $image_values))));
if (empty($image_urls)) {
return current_user_can('edit_posts') ? '
' . esc_html__('A360 Panorama: provide an image using the image or images parameter.', 'a360-panorama-hero') . '
' : '';
}
$image_url = $image_urls[0];
$poster_url = a360_panorama_resolve_image_url($atts['poster']) ?: A360_PANORAMA_DEFAULT_POSTER;
$height = max(240, min(1200, absint($atts['height'])));
$speed = max(0.1, min(12, (float) $atts['speed']));
$fov = max(35, min(110, (float) $atts['fov']));
$resume = max(0, min(30, (float) $atts['resume']));
$interval = max(1, min(300, absint($atts['interval'])));
$random_start = absint($atts['random']) > 0;
$autoplay = in_array($atts['autoplay'], array('respect', 'always', 'off'), true) ? $atts['autoplay'] : 'always';
$id = wp_unique_id('a360-panorama-');
$scenes = array_map(static function ($url) { return array('url' => $url, 'hotspots' => array()); }, $image_urls);
wp_enqueue_style('a360-panorama');
wp_enqueue_script('a360-panorama-viewer');
return sprintf(
'
%9$s
%10$s
',
esc_attr($id), esc_attr($atts['label']), $height, esc_url($image_url), esc_attr($speed), esc_attr($fov), esc_attr($resume), esc_attr($autoplay),
esc_html__('Loading panorama…', 'a360-panorama-hero'), esc_html__('The panorama could not be loaded.', 'a360-panorama-hero'), esc_url($poster_url),
esc_attr__('Pause panorama rotation', 'a360-panorama-hero'), esc_attr__('Resume panorama rotation', 'a360-panorama-hero'),
esc_attr(wp_json_encode($image_urls)), esc_attr(wp_json_encode($scenes)), esc_attr($interval), $random_start ? 'true' : 'false'
);
}
add_shortcode('a360_panorama', 'a360_panorama_shortcode');
/** Sanitizes hotspot data stored for an individual panorama. */
function a360_panorama_sanitize_hotspots($source)
{
$result = array();
if (! is_array($source)) {
return $result;
}
foreach ($source as $hotspot) {
if (! is_array($hotspot)) continue;
$type_value = $hotspot['type'] ?? 'info';
$type = in_array($type_value, array('info', 'product', 'link'), true) ? $type_value : 'info';
$destination_type_value = $hotspot['destinationTypee'] ?? 'url';
$destination_type = in_array($destination_type_value, array('url', 'panorama'), true) ? $destination_type_value : 'url';
$gallery = array();
foreach ((array) ($hotspot['gallery'] ?? array()) as $image) {
if (! is_array($image)) continue;
$gallery[] = array('id' => absint($image['id'] ?? 0), 'url' => esc_url_raw($image['url'] ?? ''), 'alt' => sanitize_text_field($image['alt'] ?? ''));
}
$result[] = array(
'id' => sanitize_key($hotspot['id'] ?? wp_unique_id('hotspot-')),
'yaw' => max(-180, min(180, (float) ($hotspot['yaw'] ?? 0))),
'pitch' => max(-89, min(89, (float) ($hotspot['pitch'] ?? 0))),
'type' => $type,
'title' => sanitize_text_field($hotspot['title'] ?? ''),
'visible' => ! array_key_exists('visible', $hotspot) || ! empty($hotspot['visible']),
'description' => sanitize_textarea_field($hotspot['description'] ?? ''),
'gallery' => $gallery,
'youtube' => esc_url_raw($hotspot['youtube'] ?? ''),
'productId' => absint($hotspot['productId'] ?? 0),
'destinationTypee' => $destination_type,
'url' => esc_url_raw($hotspot['url'] ?? ''),
'targetBlank' => ! empty($hotspot['targetBlank']),
'targetImageId' => absint($hotspot['targetImageId'] ?? 0),
'targetImageUrl' => esc_url_raw($hotspot['targetImageUrl'] ?? ''),
);
}
return $result;
}
/** Renderuje blok Hero, zachowując jego zawartość jako natywne bloki WP. */
function a360_panorama_render_hero_block($attributes, $content)
{
$image_id = absint($attributes['imageId'] ?? 0);
$image_url = esc_url_raw($attributes['imageUrl'] ?? '');
$image_url = $image_url ?: ($image_id ? wp_get_attachment_image_url($image_id, 'full') : '');
$panoramas = array();
if (! empty($attributes['panoramas']) && is_array($attributes['panoramas'])) {
foreach ($attributes['panoramas'] as $panorama) {
if (! is_array($panorama)) {
continue;
}
$panorama_id = absint($panorama['id'] ?? 0);
$panorama_url = esc_url_raw($panorama['url'] ?? '');
$panorama_url = $panorama_url ?: ($panorama_id ? wp_get_attachment_image_url($panorama_id, 'full') : '');
if ($panorama_url) {
$panoramas[] = array('id' => $panorama_id, 'url' => $panorama_url);
}
}
}
if (empty($panoramas) && $image_url) {
$panoramas[] = array('id' => $image_id, 'url' => $image_url);
}
if (! empty($panoramas)) {
$image_id = $panoramas[0]['id'];
$image_url = $panoramas[0]['url'];
}
if (! $image_url) {
return current_user_can('edit_posts') ? '' . esc_html__('A360 Panorama Hero: select a spherical image.', 'a360-panorama-hero') . '
' : '';
}
$poster_id = absint($attributes['posterId'] ?? 0);
$poster_url = esc_url_raw($attributes['posterUrl'] ?? '');
$poster_url = $poster_url ?: ($poster_id ? wp_get_attachment_image_url($poster_id, 'full') : '');
$poster_url = $poster_url ?: A360_PANORAMA_DEFAULT_POSTER;
$nadir_logo_id = absint($attributes['nadirLogoId'] ?? 0);
$nadir_logo_url = esc_url_raw($attributes['nadirLogoUrl'] ?? '');
$nadir_logo_url = $nadir_logo_url ?: ($nadir_logo_id ? wp_get_attachment_image_url($nadir_logo_id, 'full') : '');
$nadir_rotation = max(-180, min(180, (float) ($attributes['nadirRotation'] ?? 0)));
$nadir_size = max(10, min(100, (float) ($attributes['nadirSize'] ?? 50)));
$interaction_hint_enabled = ! array_key_exists('interactionHint', $attributes) || ! empty($attributes['interactionHint']);
$block_mouse_interaction = ! array_key_exists('blockMouseInteraction', $attributes) || ! empty($attributes['blockMouseInteraction']);
$interaction_hint_text = sanitize_text_field($attributes['interactionHintText'] ?? 'Click and drag mouse to explore');
$interaction_hint = '';
if (! $block_mouse_interaction && $interaction_hint_enabled && '' !== $interaction_hint_text) {
$interaction_hint = sprintf(
'',
esc_html($interaction_hint_text)
);
}
$height = max(200, min(1200, absint($attributes['height'] ?? 720)));
$speed = max(.1, min(12, (float) ($attributes['speed'] ?? 2)));
$fov = max(35, min(110, (float) ($attributes['fov'] ?? 75)));
$yaw = max(-180, min(180, (float) ($attributes['yaw'] ?? 0)));
$resume = max(0, min(30, (float) ($attributes['resume'] ?? 4)));
$panorama_interval = max(1, min(300, absint($attributes['panoramaInterval'] ?? 8)));
$random_start = ! empty($attributes['randomStart']) || ! empty($attributes['randomPanoramaCount']);
$overlay = max(0, min(90, absint($attributes['overlayOpacity'] ?? 45)));
$content_padding = max(0, min(160, absint($attributes['contentPadding'] ?? 50)));
$horizontal = in_array($attributes['contentHorizontalAlign'] ?? 'left', array('left', 'center', 'right'), true) ? $attributes['contentHorizontalAlign'] : 'left';
$vertical = in_array($attributes['contentVerticalAlign'] ?? 'top', array('top', 'center', 'bottom'), true) ? $attributes['contentVerticalAlign'] : 'top';
$align_items = array('left' => 'flex-start', 'center' => 'center', 'right' => 'flex-end')[$horizontal];
$justify_content = array('top' => 'flex-start', 'center' => 'center', 'bottom' => 'flex-end')[$vertical];
$autoplay = in_array($attributes['autoplay'] ?? 'always', array('respect', 'always', 'off'), true) ? $attributes['autoplay'] : 'always';
$hotspots = array();
$hotspot_image_id = absint($attributes['hotspotImageId'] ?? 0);
$hotspots_match_image = 0 === $hotspot_image_id || $hotspot_image_id === $image_id;
if ($hotspots_match_image && ! empty($attributes['hotspots']) && is_array($attributes['hotspots'])) {
foreach ($attributes['hotspots'] as $hotspot) {
if (! is_array($hotspot)) {
continue;
}
$type_value = $hotspot['type'] ?? 'info';
$type = in_array($type_value, array('info', 'product', 'link'), true) ? $type_value : 'info';
$destination_type_value = $hotspot['destinationTypee'] ?? 'url';
$destination_type = in_array($destination_type_value, array('url', 'panorama'), true) ? $destination_type_value : 'url';
$gallery = array();
if (! empty($hotspot['gallery']) && is_array($hotspot['gallery'])) {
foreach ($hotspot['gallery'] as $image) {
if (! is_array($image)) {
continue;
}
$gallery[] = array(
'id' => absint($image['id'] ?? 0),
'url' => esc_url_raw($image['url'] ?? ''),
'alt' => sanitize_text_field($image['alt'] ?? ''),
);
}
}
$hotspots[] = array(
'id' => sanitize_key($hotspot['id'] ?? wp_unique_id('hotspot-')),
'yaw' => max(-180, min(180, (float) ($hotspot['yaw'] ?? 0))),
'pitch' => max(-89, min(89, (float) ($hotspot['pitch'] ?? 0))),
'type' => $type,
'title' => sanitize_text_field($hotspot['title'] ?? ''),
'visible' => ! array_key_exists('visible', $hotspot) || ! empty($hotspot['visible']),
'description' => sanitize_textarea_field($hotspot['description'] ?? ''),
'gallery' => $gallery,
'youtube' => esc_url_raw($hotspot['youtube'] ?? ''),
'productId' => absint($hotspot['productId'] ?? 0),
'destinationTypee' => $destination_type,
'url' => esc_url_raw($hotspot['url'] ?? ''),
'targetBlank' => ! empty($hotspot['targetBlank']),
'targetImageId' => absint($hotspot['targetImageId'] ?? 0),
'targetImageUrl' => esc_url_raw($hotspot['targetImageUrl'] ?? ''),
);
}
}
$hotspots_json = wp_json_encode($hotspots);
$hotspot_map = ! empty($attributes['hotspotsByPanorama']) && is_array($attributes['hotspotsByPanorama']) ? $attributes['hotspotsByPanorama'] : array();
$scenes = array();
foreach ($panoramas as $index => $panorama) {
$key = $panorama['id'] ? 'id:' . $panorama['id'] : 'url:' . $panorama['url'];
$scene_hotspots = isset($hotspot_map[$key]) ? a360_panorama_sanitize_hotspots($hotspot_map[$key]) : (0 === $index ? $hotspots : array());
$scenes[] = array('url' => $panorama['url'], 'hotspots' => $scene_hotspots);
}
$scenes_json = wp_json_encode($scenes);
$panoramas_json = wp_json_encode(array_values(array_map(static function ($panorama) {
return $panorama['url'];
}, $panoramas)));
$height_class = $height < 400 ? ' is-short-height is-micro-height' : ($height < 560 ? ' is-short-height' : '');
$wrapper_class = 'a360-panorama-hero' . $height_class;
$wrapper = get_block_wrapper_attributes(array('class' => $wrapper_class, 'style' => sprintf('--a360-hero-height:%dpx;--a360-hero-overlay:%.2f;--a360-hero-content-padding:%dpx;--a360-hero-content-x:%s;--a360-hero-content-y:%s;--a360-hero-text-align:%s', $height, $overlay / 100, $content_padding, $align_items, $justify_content, $horizontal)));
$poster = $poster_url ? sprintf('
', esc_url($poster_url)) : '';
$show_credit = ! array_key_exists('showCredit', $attributes) || ! empty($attributes['showCredit']);
$credit = $show_credit ? sprintf(
'%s',
esc_url('https://wordpress.org/plugins/a360-panorama-hero/'),
esc_html__('Powered by A360 Panorama Hero', 'a360-panorama-hero')
) : '';
wp_enqueue_style('a360-panorama');
wp_enqueue_style('a360-panorama-hero');
wp_enqueue_script('a360-panorama-viewer');
return sprintf(
'',
$wrapper,
$poster,
esc_attr($attributes['label'] ?? __('Interactive 360-degree panorama', 'a360-panorama-hero')),
esc_url($image_url),
esc_attr($speed),
esc_attr($fov),
esc_attr($resume),
esc_attr($autoplay),
esc_html__('Loading panorama…', 'a360-panorama-hero'),
esc_html__('The panorama could not be loaded.', 'a360-panorama-hero'),
$content,
esc_attr__('Pause panorama rotation', 'a360-panorama-hero'),
esc_attr__('Resume panorama rotation', 'a360-panorama-hero'),
esc_attr($yaw),
esc_attr($hotspots_json),
esc_url($nadir_logo_url),
esc_attr($nadir_rotation),
esc_attr($nadir_size),
$interaction_hint,
esc_attr($panoramas_json),
esc_attr($panorama_interval),
esc_attr($scenes_json),
$block_mouse_interaction ? 'true' : 'false',
$random_start ? 'true' : 'false',
$credit
);
}
function a360_panorama_register_hero_block()
{
wp_register_style('a360-panorama-hero', A360_PANORAMA_URL . 'assets/css/a360-panorama-hero.css', array('a360-panorama'), A360_PANORAMA_VERSION);
wp_register_style('a360-panorama-hero-editor', A360_PANORAMA_URL . 'assets/css/a360-panorama-hero-editor.css', array('a360-panorama-hero'), A360_PANORAMA_VERSION);
wp_register_script('a360-panorama-hero-editor', A360_PANORAMA_URL . 'assets/js/a360-panorama-hero-editor.js', array('a360-panorama-viewer', 'wp-blocks', 'wp-block-editor', 'wp-components', 'wp-element', 'wp-i18n'), A360_PANORAMA_VERSION, true);
wp_localize_script('a360-panorama-hero-editor', 'A360PanoramaHeroL10n', array(
'credit' => __('Powered by A360 Panorama Hero', 'a360-panorama-hero'),
'showCredit' => __('Show plugin credit', 'a360-panorama-hero'),
));
wp_set_script_translations("a360-panorama-hero-editor", "a360-panorama-hero", A360_PANORAMA_PATH . "languages");
register_block_type(A360_PANORAMA_PATH . 'blocks/panorama-hero', array('render_callback' => 'a360_panorama_render_hero_block'));
}
add_action('init', 'a360_panorama_register_hero_block');
/**
* Adds the standard plugin details link to the Plugins screen.
*
* @param string[] $plugin_meta Plugin metadata links.
* @param string $plugin_file Plugin basename.
* @return string[]
*/
function a360_panorama_plugin_row_meta($plugin_meta, $plugin_file)
{
if (plugin_basename(A360_PANORAMA_FILE) !== $plugin_file) {
return $plugin_meta;
}
$details_url = add_query_arg(
array(
'tab' => 'plugin-information',
'plugin' => 'a360-panorama-hero',
'TB_iframe' => 'true',
'width' => '600',
'height' => '550',
),
self_admin_url('plugin-install.php')
);
$plugin_meta[] = sprintf(
'%3$s',
esc_url($details_url),
esc_attr__('View details for A360 Panorama Hero', 'a360-panorama-hero'),
esc_html__('View details', 'a360-panorama-hero')
);
return $plugin_meta;
}
add_filter('plugin_row_meta', 'a360_panorama_plugin_row_meta', 10, 2);
/**
* Supplies plugin information before the WordPress.org listing is available.
* WordPress.org data takes precedence once the plugin is published there.
*
* @param false|object|array $result Plugin information result.
* @param string $action Requested API action.
* @param object $args API request arguments.
* @return false|object|array
*/
function a360_panorama_plugin_information($result, $action, $args)
{
if ('plugin_information' !== $action || empty($args->slug) || 'a360-panorama-hero' !== $args->slug) {
return $result;
}
$host = (string) wp_parse_url(home_url(), PHP_URL_HOST);
$is_ddev = strlen($host) >= 10 && 0 === substr_compare($host, '.ddev.site', -10);
if (! in_array(wp_get_environment_type(), array('local', 'development'), true) && ! $is_ddev) {
return $result;
}
$asset_url = A360_PANORAMA_URL . 'wporg-assets/';
return (object) array(
'name' => 'A360 Panorama Hero',
'slug' => 'a360-panorama-hero',
'version' => A360_PANORAMA_VERSION,
'author' => 'Igor Rubas',
'author_profile' => 'https://github.com/IgorRubas',
'homepage' => 'https://a360.pl/a360-panorama-hero/',
'requires' => '6.0',
'tested' => '7.1',
'requires_php' => '7.4',
'last_updated' => gmdate('Y-m-d'),
'short_description' => __('Turn any WordPress Hero into an immersive, interactive 360° experience.', 'a360-panorama-hero'),
'sections' => array(
'description' => 'Make the first screen unforgettable
A360 Panorama Hero replaces a flat Hero background with an interactive 360° world—without taking away the WordPress editing experience you already know.
Add headings, paragraphs, buttons, groups, or columns as native blocks. Then place the content exactly where it belongs with compact alignment and padding controls.
Lightweight by design
The plugin keeps its footprint focused and loads panorama assets only on pages where the block or shortcode is actually used. There is no tracking, no remote platform, and no unnecessary site-wide script payload.
Built for attention. Designed for WordPress.
- Native dynamic Gutenberg block with InnerBlocks.
- Focused, lightweight assets loaded only where needed.
- Smooth WebGL panorama with touch and keyboard navigation plus optional mouse control.
- Automatic rotation with configurable speed and restart delay.
- Default-enabled mouse interaction lock that keeps automatic rotation running.
- Responsive height, field of view, overlay, alignment, and padding.
- Optional loading poster for a polished first impression.
- No tracking and no external service dependency.
',
'installation' => '- Activate A360 Panorama Hero.
- Add the block in the WordPress editor.
- Select a 2:1 equirectangular image from the Media Library.
- Edit the foreground with native blocks and publish.
',
'faq' => 'Do I need a special panorama format?
Use a standard 2:1 equirectangular JPEG or WebP image.
Can I use normal WordPress buttons and headings?
Yes. Foreground content is made from native WordPress blocks.
Does it connect to an external platform?
No. Rendering happens directly in the visitor’s browser.
',
'changelog' => '1.0
- Initial public release.
- Native panorama Hero block and standalone shortcode.
- Responsive content alignment and padding controls.
',
),
'banners' => array(
'low' => $asset_url . 'banner-772x250.png',
'high' => $asset_url . 'banner-1544x500.png',
),
'icons' => array(
'1x' => $asset_url . 'icon-128x128.png',
'2x' => $asset_url . 'icon-256x256.png',
),
);
}
add_filter('plugins_api', 'a360_panorama_plugin_information', 20, 3);