Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
300 changes: 295 additions & 5 deletions assets/src/js/wpbakery/wpbakery-video-selector-param.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { stripHtmlTags, isFilenameLikeTitle } from '../../blocks/godam-player/utils/index.js';
import { stripHtmlTags, isFilenameLikeTitle, appendTimezoneOffsetToUTC } from '../../blocks/godam-player/utils/index.js';

( function( $ ) {
'use strict';
Expand Down Expand Up @@ -39,6 +39,18 @@ import { stripHtmlTags, isFilenameLikeTitle } from '../../blocks/godam-player/ut

const attachmentDataCache = new LRUCache( ATTACHMENT_CACHE_MAX_ENTRIES );

/**
* SEO fields that always come from the selected video and can't be edited,
* as in the Gutenberg "Video SEO Schema" modal and the Elementor popover.
*/
const SEO_READONLY_FIELDS = [ 'seo_content_url', 'seo_upload_date', 'seo_duration', 'seo_thumbnail_url' ];

/**
* SEO fields that show the Media Library values and become editable only
* when "Override SEO" is Yes.
*/
const SEO_OVERRIDABLE_FIELDS = [ 'seo_headline', 'seo_description', 'seo_family_friendly' ];

// Initialize video selector on document ready and when WPBakery reloads the params
$( document ).ready( initVideoSelector );
$( document ).on( 'vc.reload', initVideoSelector );
Expand Down Expand Up @@ -174,6 +186,20 @@ import { stripHtmlTags, isFilenameLikeTitle } from '../../blocks/godam-player/ut
$removes
.off( 'click.godam-vs' )
.on( 'click.godam-vs', removeVideoSelector );

// An element's edit panel was rendered: check the title and fill
// the SEO fields. Deferred so WPBakery finishes setting up the
// params (dependencies, select2) first.
$( node )
.find( '.wpb_vc_param_value[name="seo_override"]' )
.add( $( node ).filter( '.wpb_vc_param_value[name="seo_override"]' ) )
.each( function() {
const $scope = getSeoScope( $( this ) );
setTimeout( () => {
updateFilenameTitleWarning( $scope );
syncSeoPanel( $scope );
}, 0 );
} );
}
}
} );
Expand Down Expand Up @@ -210,20 +236,284 @@ import { stripHtmlTags, isFilenameLikeTitle } from '../../blocks/godam-player/ut
} );

$( document ).on( 'change', '.wpb_vc_param_value[name="seo_override"]', function() {
updateFilenameTitleWarning( getSeoScope( $( this ) ) );
const $scope = getSeoScope( $( this ) );
updateFilenameTitleWarning( $scope );
syncSeoPanel( $scope );
} );

$( document ).on( 'change', '.video_selector_field[name="id"]', function() {
syncSeoPanel( getSeoScope( $( this ) ) );
} );

$( document ).on( 'click', '.godam-wpb-seo-reset', function( event ) {
event.preventDefault();
resetSeoToMediaDefaults( getSeoScope( $( this ) ) );
} );

$( document ).on( 'input change', '.wpb_vc_param_value[name="seo_headline"]', function() {
updateFilenameTitleWarning( getSeoScope( $( this ) ) );
} );

// No panel-open hook needed: WPBakery fires `change` on dependent params
// (seo_override, id) while it sets up the edit panel, which runs the
// handlers above. (`vc.reload` is never triggered by WPBakery.)
// The panel-open sync runs from setupParamGroupObserver() when the SEO
// params are added to the DOM. WPBakery never triggers `vc.reload`, so
// that can't be relied on.

isSeoPrefillListenerBound = true;
}

/**
* Get the editor wrapper of a param (label, field and description).
*
* @param {Object} $field - The param's value field (jQuery object).
* @return {Object} jQuery object.
*/
function getParamWrapper( $field ) {
const $wrapper = $field.closest( '.vc_shortcode-param' );
return $wrapper.length ? $wrapper : $field.parent();
}

/**
* Make a param read-only (or editable again) without disabling it, so
* WPBakery still saves its value.
*
* Dropdowns: WPBakery 9 hides the native <select> behind its select2
* widget (`wpbselect2`), so the lock goes on the widget: a CSS rule on
* the wrapper class blocks clicks (it also covers a widget created after
* this runs), `select2:opening` is cancelled for keyboard access, and the
* widget's focusable box is taken out of the tab order. The hidden
* <select> itself is never touched: select2 copies its tabindex when it
* is created and re-applies it whenever the <select>'s attributes change,
* which would leave the dropdown out of the tab order after unlocking.
* A plain <select> (older WPBakery) is locked directly; its original
* tabindex is restored when it becomes editable.
*
* @param {Object} $field - The param's value field (jQuery object).
* @param {boolean} readOnly - Whether the field should be read-only.
*/
function setParamReadOnly( $field, readOnly ) {
if ( ! $field.length ) {
return;
}

const $wrapper = getParamWrapper( $field );

if ( $field.is( 'select' ) ) {
ensureReadOnlyStyles();

$field.data( 'godamSeoReadOnly', readOnly );
$field
.off( 'select2:opening.godam-seo' )
.on( 'select2:opening.godam-seo', function( event ) {
if ( $( this ).data( 'godamSeoReadOnly' ) ) {
event.preventDefault();
}
} );

// `wpb-form-select` dropdowns become select2 widgets, possibly after
// this runs.
if ( $field.hasClass( 'wpb-form-select' ) || $field.data( 'select2' ) ) {
const select2 = $field.data( 'select2' );
const $select2 = $field.next( '.select2-container' );

if ( $select2.length ) {
// select2's own tab position, or the default when it has none.
const ownTabindex = select2?.selection?._tabindex;
const editableTabindex = undefined === ownTabindex || null === ownTabindex || '-1' === String( ownTabindex ) ? '0' : ownTabindex;
$select2.find( '.select2-selection' ).attr( 'tabindex', readOnly ? '-1' : editableTabindex );
$select2.attr( 'aria-readonly', readOnly ? 'true' : null );
}
} else {
if ( undefined === $field.data( 'godamSeoTabindex' ) ) {
$field.data( 'godamSeoTabindex', $field.attr( 'tabindex' ) ?? null );
}
$field.attr( 'aria-readonly', readOnly ? 'true' : null );
$field.css( 'pointer-events', readOnly ? 'none' : '' );
$field.attr( 'tabindex', readOnly ? '-1' : $field.data( 'godamSeoTabindex' ) );
}
} else {
$field.prop( 'readonly', readOnly );
}

$wrapper.toggleClass( 'godam-wpb-seo-readonly', readOnly );
$field.css( 'background-color', readOnly ? '#f0f0f1' : '' );
$wrapper.css( 'opacity', readOnly ? '0.75' : '' );
}

/**
* Add the stylesheet that blocks clicks on read-only select2 dropdowns.
* Added once per page.
*/
function ensureReadOnlyStyles() {
if ( document.getElementById( 'godam-wpb-seo-readonly-styles' ) ) {
return;
}

$( '<style id="godam-wpb-seo-readonly-styles"></style>' )
.text( '.godam-wpb-seo-readonly .select2-container{pointer-events:none;}.godam-wpb-seo-readonly .select2-container .select2-selection{background-color:#f0f0f1;}' )
.appendTo( document.head );
}

/**
* Set a param's value and notify WPBakery, only when it changed.
*
* @param {Object} $field - The param's value field (jQuery object).
* @param {string} value - New value.
*/
function setParamValue( $field, value ) {
if ( ! $field.length ) {
return;
}

const next = undefined === value || null === value ? '' : String( value );
if ( String( $field.val() ?? '' ) !== next ) {
$field.val( next ).trigger( 'change' );
}
}

/**
* SEO values for a video, the same way the Gutenberg modal builds them.
*
* @param {Object} attachmentData - `/wp/v2/media/<id>` response.
* @return {Object} SEO values keyed by WPBakery param name.
*/
function getSeoParamValuesFromAttachment( attachmentData ) {
return {
// Google indexes HLS (.m3u8) but not DASH (.mpd), so prefer the HLS stream.
seo_content_url: getFirstNonEmpty( attachmentData?.meta?.rtgodam_hls_transcoded_url, attachmentData?.source_url ),
seo_headline: stripHtmlTags( getFirstNonEmpty( attachmentData?.title?.rendered, attachmentData?.title ) ),
seo_description: stripHtmlTags( getFirstNonEmpty( attachmentData?.description?.rendered, attachmentData?.description ) ),
seo_upload_date: attachmentData?.date_gmt ? appendTimezoneOffsetToUTC( attachmentData.date_gmt ) : '',
seo_duration: getFirstNonEmpty( attachmentData?.video_duration_iso8601 ),
seo_thumbnail_url: getFirstNonEmpty( attachmentData?.meta?.rtgodam_media_video_thumbnail ),
seo_family_friendly: '1',
};
}

/**
* Show the sync / override notice under "Override SEO", like the Gutenberg
* modal: an info notice when synced, a warning with "Reset to media library
* defaults" when overridden.
*
* @param {Object} $scope - Edit panel scope (jQuery object).
* @param {boolean} isOverride - Whether "Override SEO" is Yes.
* @param {boolean} hasVideo - Whether a video is selected.
*/
function renderSeoNotice( $scope, isOverride, hasVideo ) {
const $overrideField = getParamField( $scope, 'seo_override' );
if ( ! $overrideField.length ) {
return;
}

const $anchor = getParamWrapper( $overrideField );
let $notice = $anchor.siblings( '.godam-wpb-seo-notice' );

if ( ! $notice.length ) {
$notice = $( '<div class="godam-wpb-seo-notice vc_col-xs-12" style="margin:8px 0;padding:8px 12px;border-left:4px solid #72aee6;background:#f0f6fc;"></div>' );
$anchor.after( $notice );
}

if ( ! hasVideo ) {
$notice.hide();
return;
}

if ( isOverride ) {
$notice
.css( { borderLeftColor: '#dba617', background: '#fcf9e8' } )
.empty()
.append( document.createTextNode( __( 'You have overridden the default SEO. Changes to this video in the media library will not update the SEO for this element.', 'godam' ) + ' ' ) )
.append(
$( '<a href="#" class="godam-wpb-seo-reset"></a>' ).text( __( 'Reset to media library defaults', 'godam' ) ),
)
.show();
} else {
$notice
.css( { borderLeftColor: '#72aee6', background: '#f0f6fc' } )
.text( __( 'SEO data is automatically synced from the media library. Any changes made to the video in the media library will be reflected here.', 'godam' ) )
.show();
}
}

/**
* Fill and lock the SEO Settings fields to match the Gutenberg modal.
*
* Content URL, Upload Date, Duration and Thumbnail URL always come from
* the selected video and are read-only. Headline, Description and Is
* Family Friendly show the Media Library values and are read-only while
* "Override SEO" is No; they become editable (keeping their values) when
* it is Yes.
*
* @param {Object} $scope - Edit panel scope (jQuery object).
*/
function syncSeoPanel( $scope ) {
const $overrideField = getParamField( $scope, 'seo_override' );
if ( ! $overrideField.length ) {
return;
}

const isOverride = '1' === String( $overrideField.val() );
const attachmentId = parseInt( getParamField( $scope, 'id' ).val(), 10 );

SEO_READONLY_FIELDS.forEach( ( name ) => setParamReadOnly( getParamField( $scope, name ), true ) );
SEO_OVERRIDABLE_FIELDS.forEach( ( name ) => setParamReadOnly( getParamField( $scope, name ), ! isOverride ) );
renderSeoNotice( $scope, isOverride, !! attachmentId );

if ( ! attachmentId ) {
return;
}

// Only the latest call may write, so a slow lookup for a previously
// selected video can't overwrite the current one.
const requestId = ( $overrideField.data( 'godamSeoSyncId' ) || 0 ) + 1;
$overrideField.data( 'godamSeoSyncId', requestId );

fetchAttachmentData( attachmentId ).then( ( attachmentData ) => {
if ( ! attachmentData || $overrideField.data( 'godamSeoSyncId' ) !== requestId ) {
return;
}

const values = getSeoParamValuesFromAttachment( attachmentData );
const overrideNow = '1' === String( $overrideField.val() );

// Apply the lock again: when the panel has just opened, WPBakery may
// only now have created the select2 widget for Family Friendly.
SEO_OVERRIDABLE_FIELDS.forEach( ( name ) => setParamReadOnly( getParamField( $scope, name ), ! overrideNow ) );

SEO_READONLY_FIELDS.forEach( ( name ) => setParamValue( getParamField( $scope, name ), values[ name ] ) );

if ( ! overrideNow ) {
SEO_OVERRIDABLE_FIELDS.forEach( ( name ) => setParamValue( getParamField( $scope, name ), values[ name ] ) );
}
} ).catch( () => {
// Leave the fields as they are; the schema is built server-side anyway.
} );
}

/**
* "Reset to media library defaults": restore the Media Library values and
* switch "Override SEO" back to No.
*
* @param {Object} $scope - Edit panel scope (jQuery object).
*/
function resetSeoToMediaDefaults( $scope ) {
const attachmentId = parseInt( getParamField( $scope, 'id' ).val(), 10 );
const $overrideField = getParamField( $scope, 'seo_override' );

if ( ! attachmentId || ! $overrideField.length ) {
return;
}

fetchAttachmentData( attachmentId ).then( ( attachmentData ) => {
if ( ! attachmentData ) {
return;
}

const values = getSeoParamValuesFromAttachment( attachmentData );
SEO_OVERRIDABLE_FIELDS.forEach( ( name ) => setParamValue( getParamField( $scope, name ), values[ name ] ) );
setParamValue( $overrideField, '0' );
} ).catch( () => {} );
}

/**
* Show or hide a warning in the SEO Settings tab when the video's SEO title
* looks like a raw file name (hyphens/underscores and no spaces, or the
Expand Down
36 changes: 33 additions & 3 deletions inc/classes/class-seo.php
Original file line number Diff line number Diff line change
Expand Up @@ -1581,6 +1581,27 @@ public function elementor_save_seo_data_as_postmeta( $post_ID ) {
}
}

/**
* Undo WPBakery's shortcode-safe encoding of a param value.
*
* WPBakery stores `"` as two backticks, and `[` / `]` as `{` / `}`
* wrapped in backticks, so they can't break the shortcode. Same mapping
* as WPBakery's `vc_value_from_safe()`, without depending on it being
* loaded.
*
* @since 2.4.1
*
* @param mixed $value Raw shortcode attribute value.
* @return string Decoded value.
*/
public static function decode_wpbakery_param( $value ) {
if ( ! is_scalar( $value ) ) {
return '';
}

return str_replace( array( '`{`', '`}`', '``' ), array( '[', ']', '"' ), (string) $value );
}

/**
* Extract SEO data from WPBakery godam_video shortcodes.
*
Expand Down Expand Up @@ -1633,12 +1654,21 @@ public function godam_get_video_seo_data_from_wpbakery( $content, $track_attachm
}
}

// Use shortcode attributes if seo_override is true or no attachment data available.
// Override = No: the SEO fields only mirror the Media Library (the
// editor fills them in), so stored copies are never used. With no
// usable attachment data (video deleted, title cleared) nothing is
// output, rather than a stale VideoObject.
if ( ! $seo_override ) {
Comment thread
rudrakshi-gupta marked this conversation as resolved.
continue;
}

// Override = Yes: use the shortcode attributes, with the
// attachment's technical values taking priority when it exists.
if ( ! empty( $atts['seo_headline'] ) ) {
$video_seo = array(
'contentUrl' => ! empty( $media_seo['contentUrl'] ) ? $media_seo['contentUrl'] : ( isset( $atts['seo_content_url'] ) ? $atts['seo_content_url'] : '' ),
'headline' => isset( $atts['seo_headline'] ) ? $atts['seo_headline'] : '',
'description' => isset( $atts['seo_description'] ) ? $atts['seo_description'] : '',
'headline' => self::decode_wpbakery_param( $atts['seo_headline'] ),
'description' => isset( $atts['seo_description'] ) ? self::decode_wpbakery_param( $atts['seo_description'] ) : '',
'uploadDate' => ! empty( $media_seo['uploadDate'] ) ? $media_seo['uploadDate'] : ( isset( $atts['seo_upload_date'] ) ? $atts['seo_upload_date'] : '' ),
'thumbnailUrl' => ! empty( $media_seo['thumbnailUrl'] ) ? $media_seo['thumbnailUrl'] : ( isset( $atts['seo_thumbnail_url'] ) ? $atts['seo_thumbnail_url'] : '' ),
'isFamilyFriendly' => isset( $atts['seo_family_friendly'] ) ? '1' === $atts['seo_family_friendly'] : true,
Expand Down
Loading
Loading