packages/kirki-framework/control-repeater/src/control.js
import "./control.scss";
/* global kirkiControlLoader */
/* eslint max-depth: 0 */
/* eslint no-useless-escape: 0 */
var RepeaterRow = function( rowIndex, container, label, control ) {
var self = this;
this.rowIndex = rowIndex;
this.container = container;
this.label = label;
this.header = this.container.find( '.repeater-row-header' );
this.header.on( 'click', function() {
self.toggleMinimize();
} );
this.container.on( 'click', '.repeater-row-remove', function() {
self.remove();
} );
this.header.on( 'mousedown', function() {
self.container.trigger( 'row:start-dragging' );
} );
this.container.on( 'keyup change', 'input, select, textarea', function( e ) {
self.container.trigger( 'row:update', [ self.rowIndex, jQuery( e.target ).data( 'field' ), e.target ] );
} );
this.setRowIndex = function( rowNum ) {
this.rowIndex = rowNum;
this.container.attr( 'data-row', rowNum );
this.container.data( 'row', rowNum );
this.updateLabel();
};
this.toggleMinimize = function() {
// Store the previous state.
this.container.toggleClass( 'minimized' );
this.header.find( '.dashicons' ).toggleClass( 'dashicons-arrow-up' ).toggleClass( 'dashicons-arrow-down' );
};
this.remove = function() {
this.container.slideUp( 300, function() {
jQuery( this ).detach();
} );
this.container.trigger( 'row:remove', [ this.rowIndex ] );
};
this.updateLabel = function() {
var rowLabelField,
rowLabel,
rowLabelSelector;
if ( 'field' === this.label.type ) {
rowLabelField = this.container.find( '.repeater-field [data-field="' + this.label.field + '"]' );
if ( _.isFunction( rowLabelField.val ) ) {
rowLabel = rowLabelField.val();
if ( '' !== rowLabel ) {
if ( ! _.isUndefined( control.params.fields[ this.label.field ] ) ) {
if ( ! _.isUndefined( control.params.fields[ this.label.field ].type ) ) {
if ( 'select' === control.params.fields[ this.label.field ].type ) {
if ( ! _.isUndefined( control.params.fields[ this.label.field ].choices ) && ! _.isUndefined( control.params.fields[ this.label.field ].choices[ rowLabelField.val() ] ) ) {
rowLabel = control.params.fields[ this.label.field ].choices[ rowLabelField.val() ];
}
} else if ( 'radio' === control.params.fields[ this.label.field ].type || 'radio-image' === control.params.fields[ this.label.field ].type ) {
rowLabelSelector = control.selector + ' [data-row="' + this.rowIndex + '"] .repeater-field [data-field="' + this.label.field + '"]:checked';
rowLabel = jQuery( rowLabelSelector ).val();
}
}
}
this.header.find( '.repeater-row-label' ).text( rowLabel );
return;
}
}
}
this.header.find( '.repeater-row-label' ).text( this.label.value + ' ' + ( this.rowIndex + 1 ) );
};
this.updateLabel();
};
wp.customize.controlConstructor.repeater = wp.customize.Control.extend( {
// When we're finished loading continue processing
ready: function() {
var control = this;
// Init the control.
if ( ! _.isUndefined( window.kirkiControlLoader ) && _.isFunction( kirkiControlLoader ) ) {
kirkiControlLoader( control );
} else {
control.initKirkiControl();
}
},
initKirkiControl: function( control ) {
var limit, theNewRow, settingValue;
control = control || this;
// The current value set in Control Class (set in Kirki_Customize_Repeater_Control::to_json() function)
settingValue = control.params.value;
// The hidden field that keeps the data saved (though we never update it)
control.settingField = control.container.find( '[data-customize-setting-link]' ).first();
// Set the field value for the first time, we'll fill it up later
control.setValue( [], false );
// The DIV that holds all the rows
control.repeaterFieldsContainer = control.container.find( '.repeater-fields' ).first();
// Set number of rows to 0
control.currentIndex = 0;
// Save the rows objects
control.rows = [];
// Default limit choice
limit = false;
if ( ! _.isUndefined( control.params.choices.limit ) ) {
limit = ( 0 >= control.params.choices.limit ) ? false : parseInt( control.params.choices.limit, 10 );
}
control.container.on( 'click', 'button.repeater-add', function( e ) {
e.preventDefault();
if ( ! limit || control.currentIndex < limit ) {
theNewRow = control.addRow();
theNewRow.toggleMinimize();
control.initColorPicker();
control.initSelect( theNewRow );
} else {
jQuery( control.selector + ' .limit' ).addClass( 'highlight' );
}
} );
control.container.on( 'click', '.repeater-row-remove', function() {
control.currentIndex--;
if ( ! limit || control.currentIndex < limit ) {
jQuery( control.selector + ' .limit' ).removeClass( 'highlight' );
}
} );
control.container.on( 'click keypress', '.repeater-field-image .upload-button,.repeater-field-cropped_image .upload-button,.repeater-field-upload .upload-button', function( e ) {
e.preventDefault();
control.$thisButton = jQuery( this );
control.openFrame( e );
} );
control.container.on( 'click keypress', '.repeater-field-image .remove-button,.repeater-field-cropped_image .remove-button', function( e ) {
e.preventDefault();
control.$thisButton = jQuery( this );
control.removeImage( e );
} );
control.container.on( 'click keypress', '.repeater-field-upload .remove-button', function( e ) {
e.preventDefault();
control.$thisButton = jQuery( this );
control.removeFile( e );
} );
/**
* Function that loads the Mustache template
*/
control.repeaterTemplate = _.memoize( function() {
var compiled,
/*
* Underscore's default ERB-style templates are incompatible with PHP
* when asp_tags is enabled, so WordPress uses Mustache-inspired templating syntax.
*
* @see trac ticket #22344.
*/
options = {
evaluate: /<#([\s\S]+?)#>/g,
interpolate: /\{\{\{([\s\S]+?)\}\}\}/g,
escape: /\{\{([^\}]+?)\}\}(?!\})/g,
variable: 'data'
};
return function( data ) {
compiled = _.template( control.container.find( '.customize-control-repeater-content' ).first().html(), null, options );
return compiled( data );
};
} );
// When we load the control, the fields have not been filled up
// This is the first time that we create all the rows
if ( settingValue.length ) {
_.each( settingValue, function( subValue ) {
theNewRow = control.addRow( subValue );
control.initColorPicker();
control.initSelect( theNewRow, subValue );
} );
}
control.repeaterFieldsContainer.sortable( {
handle: '.repeater-row-header',
update: function() {
control.sort();
}
} );
},
/**
* Open the media modal.
*
* @param {Object} event - The JS event.
* @returns {void}
*/
openFrame: function( event ) {
if ( wp.customize.utils.isKeydownButNotEnterEvent( event ) ) {
return;
}
if ( this.$thisButton.closest( '.repeater-field' ).hasClass( 'repeater-field-cropped_image' ) ) {
this.initCropperFrame();
} else {
this.initFrame();
}
this.frame.open();
},
initFrame: function() {
var libMediaType = this.getMimeType();
this.frame = wp.media( {
states: [
new wp.media.controller.Library( {
library: wp.media.query( { type: libMediaType } ),
multiple: false,
date: false
} )
]
} );
// When a file is selected, run a callback.
this.frame.on( 'select', this.onSelect, this );
},
/**
* Create a media modal select frame, and store it so the instance can be reused when needed.
* This is mostly a copy/paste of Core api.CroppedImageControl in /wp-admin/js/customize-control.js
*
* @returns {void}
*/
initCropperFrame: function() {
// We get the field id from which this was called
var currentFieldId = this.$thisButton.siblings( 'input.hidden-field' ).attr( 'data-field' ),
attrs = [ 'width', 'height', 'flex_width', 'flex_height' ], // A list of attributes to look for
libMediaType = this.getMimeType();
// Make sure we got it
if ( _.isString( currentFieldId ) && '' !== currentFieldId ) {
// Make fields is defined and only do the hack for cropped_image
if ( _.isObject( this.params.fields[ currentFieldId ] ) && 'cropped_image' === this.params.fields[ currentFieldId ].type ) {
//Iterate over the list of attributes
attrs.forEach( function( el ) {
// If the attribute exists in the field
if ( ! _.isUndefined( this.params.fields[ currentFieldId ][ el ] ) ) {
// Set the attribute in the main object
this.params[ el ] = this.params.fields[ currentFieldId ][ el ];
}
}.bind( this ) );
}
}
this.frame = wp.media( {
button: {
text: 'Select and Crop',
close: false
},
states: [
new wp.media.controller.Library( {
library: wp.media.query( { type: libMediaType } ),
multiple: false,
date: false,
suggestedWidth: this.params.width,
suggestedHeight: this.params.height
} ),
new wp.media.controller.CustomizeImageCropper( {
imgSelectOptions: this.calculateImageSelectOptions,
control: this
} )
]
} );
this.frame.on( 'select', this.onSelectForCrop, this );
this.frame.on( 'cropped', this.onCropped, this );
this.frame.on( 'skippedcrop', this.onSkippedCrop, this );
},
onSelect: function() {
var attachment = this.frame.state().get( 'selection' ).first().toJSON();
if ( this.$thisButton.closest( '.repeater-field' ).hasClass( 'repeater-field-upload' ) ) {
this.setFileInRepeaterField( attachment );
} else {
this.setImageInRepeaterField( attachment );
}
},
/**
* After an image is selected in the media modal, switch to the cropper
* state if the image isn't the right size.
*/
onSelectForCrop: function() {
var attachment = this.frame.state().get( 'selection' ).first().toJSON();
if ( this.params.width === attachment.width && this.params.height === attachment.height && ! this.params.flex_width && ! this.params.flex_height ) {
this.setImageInRepeaterField( attachment );
} else {
this.frame.setState( 'cropper' );
}
},
/**
* After the image has been cropped, apply the cropped image data to the setting.
*
* @param {object} croppedImage Cropped attachment data.
* @returns {void}
*/
onCropped: function( croppedImage ) {
this.setImageInRepeaterField( croppedImage );
},
/**
* Returns a set of options, computed from the attached image data and
* control-specific data, to be fed to the imgAreaSelect plugin in
* wp.media.view.Cropper.
*
* @param {wp.media.model.Attachment} attachment - The attachment from the WP API.
* @param {wp.media.controller.Cropper} controller - Media controller.
* @returns {Object} - Options.
*/
calculateImageSelectOptions: function( attachment, controller ) {
var control = controller.get( 'control' ),
flexWidth = !! parseInt( control.params.flex_width, 10 ),
flexHeight = !! parseInt( control.params.flex_height, 10 ),
realWidth = attachment.get( 'width' ),
realHeight = attachment.get( 'height' ),
xInit = parseInt( control.params.width, 10 ),
yInit = parseInt( control.params.height, 10 ),
ratio = xInit / yInit,
xImg = realWidth,
yImg = realHeight,
x1,
y1,
imgSelectOptions;
controller.set( 'canSkipCrop', ! control.mustBeCropped( flexWidth, flexHeight, xInit, yInit, realWidth, realHeight ) );
if ( xImg / yImg > ratio ) {
yInit = yImg;
xInit = yInit * ratio;
} else {
xInit = xImg;
yInit = xInit / ratio;
}
x1 = ( xImg - xInit ) / 2;
y1 = ( yImg - yInit ) / 2;
imgSelectOptions = {
handles: true,
keys: true,
instance: true,
persistent: true,
imageWidth: realWidth,
imageHeight: realHeight,
x1: x1,
y1: y1,
x2: xInit + x1,
y2: yInit + y1
};
if ( false === flexHeight && false === flexWidth ) {
imgSelectOptions.aspectRatio = xInit + ':' + yInit;
}
if ( false === flexHeight ) {
imgSelectOptions.maxHeight = yInit;
}
if ( false === flexWidth ) {
imgSelectOptions.maxWidth = xInit;
}
return imgSelectOptions;
},
/**
* Return whether the image must be cropped, based on required dimensions.
*
* @param {bool} flexW - The flex-width.
* @param {bool} flexH - The flex-height.
* @param {int} dstW - Initial point distance in the X axis.
* @param {int} dstH - Initial point distance in the Y axis.
* @param {int} imgW - Width.
* @param {int} imgH - Height.
* @returns {bool} - Whether the image must be cropped or not based on required dimensions.
*/
mustBeCropped: function( flexW, flexH, dstW, dstH, imgW, imgH ) {
return ! ( ( true === flexW && true === flexH ) || ( true === flexW && dstH === imgH ) || ( true === flexH && dstW === imgW ) || ( dstW === imgW && dstH === imgH ) || ( imgW <= dstW ) );
},
/**
* If cropping was skipped, apply the image data directly to the setting.
*
* @returns {void}
*/
onSkippedCrop: function() {
var attachment = this.frame.state().get( 'selection' ).first().toJSON();
this.setImageInRepeaterField( attachment );
},
/**
* Updates the setting and re-renders the control UI.
*
* @param {object} attachment - The attachment object.
* @returns {void}
*/
setImageInRepeaterField: function( attachment ) {
var $targetDiv = this.$thisButton.closest( '.repeater-field-image,.repeater-field-cropped_image' );
$targetDiv.find( '.kirki-image-attachment' ).html( '<img src="' + attachment.url + '">' ).hide().slideDown( 'slow' );
$targetDiv.find( '.hidden-field' ).val( attachment.id );
this.$thisButton.text( this.$thisButton.data( 'alt-label' ) );
$targetDiv.find( '.remove-button' ).show();
//This will activate the save button
$targetDiv.find( 'input, textarea, select' ).trigger( 'change' );
this.frame.close();
},
/**
* Updates the setting and re-renders the control UI.
*
* @param {object} attachment - The attachment object.
* @returns {void}
*/
setFileInRepeaterField: function( attachment ) {
var $targetDiv = this.$thisButton.closest( '.repeater-field-upload' );
$targetDiv.find( '.kirki-file-attachment' ).html( '<span class="file"><span class="dashicons dashicons-media-default"></span> ' + attachment.filename + '</span>' ).hide().slideDown( 'slow' );
$targetDiv.find( '.hidden-field' ).val( attachment.id );
this.$thisButton.text( this.$thisButton.data( 'alt-label' ) );
$targetDiv.find( '.upload-button' ).show();
$targetDiv.find( '.remove-button' ).show();
//This will activate the save button
$targetDiv.find( 'input, textarea, select' ).trigger( 'change' );
this.frame.close();
},
getMimeType: function() {
// We get the field id from which this was called
var currentFieldId = this.$thisButton.siblings( 'input.hidden-field' ).attr( 'data-field' );
// Make sure we got it
if ( _.isString( currentFieldId ) && '' !== currentFieldId ) {
// Make fields is defined and only do the hack for cropped_image
if ( _.isObject( this.params.fields[ currentFieldId ] ) && 'upload' === this.params.fields[ currentFieldId ].type ) {
// If the attribute exists in the field
if ( ! _.isUndefined( this.params.fields[ currentFieldId ].mime_type ) ) {
// Set the attribute in the main object
return this.params.fields[ currentFieldId ].mime_type;
}
}
}
return 'image';
},
removeImage: function( event ) {
var $targetDiv,
$uploadButton;
if ( wp.customize.utils.isKeydownButNotEnterEvent( event ) ) {
return;
}
$targetDiv = this.$thisButton.closest( '.repeater-field-image,.repeater-field-cropped_image,.repeater-field-upload' );
$uploadButton = $targetDiv.find( '.upload-button' );
$targetDiv.find( '.kirki-image-attachment' ).slideUp( 'fast', function() {
jQuery( this ).show().html( jQuery( this ).data( 'placeholder' ) );
} );
$targetDiv.find( '.hidden-field' ).val( '' );
$uploadButton.text( $uploadButton.data( 'label' ) );
this.$thisButton.hide();
$targetDiv.find( 'input, textarea, select' ).trigger( 'change' );
},
removeFile: function( event ) {
var $targetDiv,
$uploadButton;
if ( wp.customize.utils.isKeydownButNotEnterEvent( event ) ) {
return;
}
$targetDiv = this.$thisButton.closest( '.repeater-field-upload' );
$uploadButton = $targetDiv.find( '.upload-button' );
$targetDiv.find( '.kirki-file-attachment' ).slideUp( 'fast', function() {
jQuery( this ).show().html( jQuery( this ).data( 'placeholder' ) );
} );
$targetDiv.find( '.hidden-field' ).val( '' );
$uploadButton.text( $uploadButton.data( 'label' ) );
this.$thisButton.hide();
$targetDiv.find( 'input, textarea, select' ).trigger( 'change' );
},
/**
* Get the current value of the setting
*
* @returns {Object} - Returns the value.
*/
getValue: function() {
// The setting is saved in JSON
return JSON.parse( decodeURI( this.setting.get() ) );
},
/**
* Set a new value for the setting
*
* @param {Object} newValue - The new value.
* @param {bool} refresh - If we want to refresh the previewer or not
* @param {bool} filtering - If we want to filter or not.
* @returns {void}
*/
setValue: function( newValue, refresh, filtering ) {
// We need to filter the values after the first load to remove data requrired for diplay but that we don't want to save in DB
var filteredValue = newValue,
filter = [];
if ( filtering ) {
jQuery.each( this.params.fields, function( index, value ) {
if ( 'image' === value.type || 'cropped_image' === value.type || 'upload' === value.type ) {
filter.push( index );
}
} );
jQuery.each( newValue, function( index, value ) {
jQuery.each( filter, function( ind, field ) {
if ( ! _.isUndefined( value[ field ] ) && ! _.isUndefined( value[ field ].id ) ) {
filteredValue[index][ field ] = value[ field ].id;
}
} );
} );
}
this.setting.set( encodeURI( JSON.stringify( filteredValue ) ) );
if ( refresh ) {
// Trigger the change event on the hidden field so
// previewer refresh the website on Customizer
this.settingField.trigger( 'change' );
}
},
/**
* Add a new row to repeater settings based on the structure.
*
* @param {Object} data - (Optional) Object of field => value pairs (undefined if you want to get the default values)
* @returns {Object} - Returns the new row.
*/
addRow: function( data ) {
var control = this,
template = control.repeaterTemplate(), // The template for the new row (defined on Kirki_Customize_Repeater_Control::render_content() ).
settingValue = this.getValue(), // Get the current setting value.
newRowSetting = {}, // Saves the new setting data.
templateData, // Data to pass to the template
newRow,
i;
if ( template ) {
// The control structure is going to define the new fields
// We need to clone control.params.fields. Assigning it
// ould result in a reference assignment.
templateData = jQuery.extend( true, {}, control.params.fields );
// But if we have passed data, we'll use the data values instead
if ( data ) {
for ( i in data ) {
if ( data.hasOwnProperty( i ) && templateData.hasOwnProperty( i ) ) {
templateData[ i ].default = data[ i ];
}
}
}
templateData.index = this.currentIndex;
// Append the template content
template = template( templateData );
// Create a new row object and append the element
newRow = new RepeaterRow(
control.currentIndex,
jQuery( template ).appendTo( control.repeaterFieldsContainer ),
control.params.row_label,
control
);
newRow.container.on( 'row:remove', function( e, rowIndex ) {
control.deleteRow( rowIndex );
} );
newRow.container.on( 'row:update', function( e, rowIndex, fieldName, element ) {
control.updateField.call( control, e, rowIndex, fieldName, element ); // eslint-disable-line no-useless-call
newRow.updateLabel();
} );
// Add the row to rows collection
this.rows[ this.currentIndex ] = newRow;
for ( i in templateData ) {
if ( templateData.hasOwnProperty( i ) ) {
newRowSetting[ i ] = templateData[ i ].default;
}
}
settingValue[ this.currentIndex ] = newRowSetting;
this.setValue( settingValue, true );
this.currentIndex++;
return newRow;
}
},
sort: function() {
var control = this,
$rows = this.repeaterFieldsContainer.find( '.repeater-row' ),
newOrder = [],
settings = control.getValue(),
newRows = [],
newSettings = [];
$rows.each( function( i, element ) {
newOrder.push( jQuery( element ).data( 'row' ) );
} );
jQuery.each( newOrder, function( newPosition, oldPosition ) {
newRows[ newPosition ] = control.rows[ oldPosition ];
newRows[ newPosition ].setRowIndex( newPosition );
newSettings[ newPosition ] = settings[ oldPosition ];
} );
control.rows = newRows;
control.setValue( newSettings );
},
/**
* Delete a row in the repeater setting
*
* @param {int} index - Position of the row in the complete Setting Array
* @returns {void}
*/
deleteRow: function( index ) {
var currentSettings = this.getValue(),
row,
prop;
if ( currentSettings[ index ] ) {
// Find the row
row = this.rows[ index ];
if ( row ) {
// Remove the row settings
delete currentSettings[ index ];
// Remove the row from the rows collection
delete this.rows[ index ];
// Update the new setting values
this.setValue( currentSettings, true );
}
}
// Remap the row numbers
for ( prop in this.rows ) {
if ( this.rows.hasOwnProperty( prop ) && this.rows[ prop ] ) {
this.rows[ prop ].updateLabel();
}
}
},
/**
* Update a single field inside a row.
* Triggered when a field has changed
*
* @param {Object} e - Event Object
* @param {int} rowIndex - The row's index as an integer.
* @param {string} fieldId - The field ID.
* @param {string|Object} element - The element's identifier, or jQuery Object of the element.
* @returns {void}
*/
updateField: function( e, rowIndex, fieldId, element ) {
var type,
row,
currentSettings;
if ( ! this.rows[ rowIndex ] ) {
return;
}
if ( ! this.params.fields[ fieldId ] ) {
return;
}
type = this.params.fields[ fieldId].type;
row = this.rows[ rowIndex ];
currentSettings = this.getValue();
element = jQuery( element );
if ( _.isUndefined( currentSettings[ row.rowIndex ][ fieldId ] ) ) {
return;
}
if ( 'checkbox' === type ) {
currentSettings[ row.rowIndex ][ fieldId ] = element.is( ':checked' );
} else {
// Update the settings
currentSettings[ row.rowIndex ][ fieldId ] = element.val();
}
this.setValue( currentSettings, true );
},
/**
* Init the color picker on color fields
* Called after AddRow
*
* @returns {void}
*/
initColorPicker: function() {
var control = this,
colorPicker = control.container.find( '.color-picker-hex' ),
options = {},
fieldId = colorPicker.data( 'field' );
// We check if the color palette parameter is defined.
if ( ! _.isUndefined( fieldId ) && ! _.isUndefined( control.params.fields[ fieldId ] ) && ! _.isUndefined( control.params.fields[ fieldId ].palettes ) && _.isObject( control.params.fields[ fieldId ].palettes ) ) {
options.palettes = control.params.fields[ fieldId ].palettes;
}
// When the color picker value is changed we update the value of the field
options.change = function( event, ui ) {
var currentPicker = jQuery( event.target ),
row = currentPicker.closest( '.repeater-row' ),
rowIndex = row.data( 'row' ),
currentSettings = control.getValue();
currentSettings[ rowIndex ][ currentPicker.data( 'field' ) ] = ui.color.toString();
control.setValue( currentSettings, true );
};
// Init the color picker
if ( 0 !== colorPicker.length ) {
colorPicker.wpColorPicker( options );
}
},
/**
* Init the dropdown-pages field.
* Called after AddRow
*
* @param {object} theNewRow the row that was added to the repeater
* @param {object} data the data for the row if we're initializing a pre-existing row
* @returns {void}
*/
initSelect: function( theNewRow, data ) {
var control = this,
dropdown = theNewRow.container.find( '.repeater-field select' ),
dataField;
if ( 0 === dropdown.length ) {
return;
}
dataField = dropdown.data( 'field' );
multiple = jQuery( dropdown ).data( 'multiple' );
data = data || {};
data[ dataField ] = data[ dataField ] || '';
jQuery( dropdown ).val( data[ dataField ] || jQuery( dropdown ).val() );
this.container.on( 'change', '.repeater-field select', function( event ) {
var currentDropdown = jQuery( event.target ),
row = currentDropdown.closest( '.repeater-row' ),
rowIndex = row.data( 'row' ),
currentSettings = control.getValue();
currentSettings[ rowIndex ][ currentDropdown.data( 'field' ) ] = jQuery( this ).val();
control.setValue( currentSettings );
} );
}
} );