From 6f2544944fd0a85cc0eb02a7c1b9c7a80f8dec19 Mon Sep 17 00:00:00 2001 From: Andrew Nacin Date: Tue, 11 Mar 2014 04:12:17 +0000 Subject: [PATCH] Add header image uploads with cropping to the customizer. props mcsf, ehg, gcorne. see #21785. git-svn-id: https://develop.svn.wordpress.org/trunk@27497 602fd350-edb4-49c9-b593-d223f7449a82 --- src/wp-admin/css/customize-controls.css | 161 ++++++++ src/wp-admin/custom-header.php | 275 +++++++++++-- src/wp-admin/js/customize-controls.js | 244 ++++++++++-- .../class-wp-customize-control.php | 375 +++++++++++++----- src/wp-includes/css/media-views.css | 13 + src/wp-includes/js/customize-models.js | 249 ++++++++++++ src/wp-includes/js/customize-views.js | 232 +++++++++++ src/wp-includes/js/media-views.js | 150 +++++++ src/wp-includes/media.php | 17 + src/wp-includes/script-loader.php | 4 +- 10 files changed, 1553 insertions(+), 167 deletions(-) create mode 100644 src/wp-includes/js/customize-models.js create mode 100644 src/wp-includes/js/customize-views.js diff --git a/src/wp-admin/css/customize-controls.css b/src/wp-admin/css/customize-controls.css index c76a6e5fa3..448f1b5593 100644 --- a/src/wp-admin/css/customize-controls.css +++ b/src/wp-admin/css/customize-controls.css @@ -455,6 +455,167 @@ body { -webkit-overflow-scrolling: touch; } +/** Header control **/ + +#customize-control-header_image .current { + margin-bottom: 8px; +} + +#customize-control-header_image .uploaded { + margin-bottom: 18px; +} + +/* Header control: current image container */ + +#customize-control-header_image .current .container { + overflow: hidden; + border-radius: 2px; +} + +#customize-control-header_image .placeholder { + width: 100%; + position: relative; + background: #262626; + text-align: center; + cursor: default; +} + +#customize-control-header_image .inner { + display: none; + position: absolute; + width: 100%; + height: 18px; + margin-top: -9px; + top: 50%; + color: #eee; +} + +/* Header control: overlay "close" button */ + +#customize-control-header_image .header-view { + position: relative; +} + +#customize-control-header_image .uploaded .header-view .close { + font-size: 2em; + color: grey; + position: absolute; + visibility: hidden; + top: 10px; + right: 10px; + z-index: 1; + width: 20px; + height: 20px; + cursor: pointer; +} + +#customize-control-header_image .uploaded .header-view .close:hover { + color: black; + text-shadow: + -1px -1px 0 #fff, + 1px -1px 0 #fff, + -1px 1px 0 #fff, + 1px 1px 0 #fff; +} + +#customize-control-header_image .header-view:hover .close { + visibility: visible; +} + +/* Header control: randomiz(s)er */ + +#customize-control-header_image .random.placeholder { + cursor: pointer; + border-radius: 2px; + height: 40px; +} + +#customize-control-header_image .random .inner { + display: block; +} + +#customize-control-header_image .dice { + font-size: 16px; + vertical-align: -1px; +} + +#customize-control-header_image .placeholder:hover .dice { + -webkit-animation: dice-color-change 3s infinite; + -moz-animation: dice-color-change 3s infinite; + -ms-animation: dice-color-change 3s infinite; + animation: dice-color-change 3s infinite; +} + +@-webkit-keyframes dice-color-change { + 0% { color: #d4b146; } + 50% { color: #ef54b0; } + 75% { color: #7190d3; } + 100% { color: #d4b146; } +} + +@-moz-keyframes dice-color-change { + 0% { color: #d4b146; } + 50% { color: #ef54b0; } + 75% { color: #7190d3; } + 100% { color: #d4b146; } +} + +@-ms-keyframes dice-color-change { + 0% { color: #d4b146; } + 50% { color: #ef54b0; } + 75% { color: #7190d3; } + 100% { color: #d4b146; } +} + +@keyframes dice-color-change { + 0% { color: #d4b146; } + 50% { color: #ef54b0; } + 75% { color: #7190d3; } + 100% { color: #d4b146; } +} + +/* Header control: actions and choices */ + +#customize-control-header_image .actions { + margin-bottom: 32px; +} + +#customize-control-header_image .choice { + position: relative; + display: block; + margin-bottom: 9px; +} + +#customize-control-header_image .choice.random:before { + position: absolute; + content: attr(data-label); + left: 0; + top: 0; +} + +#customize-control-header_image .uploaded div:last-child > .choice { + margin-bottom: 0; +} + +#customize-control-header_image .choices hr { + visibility: hidden; +} + +#customize-control-header_image img { + width: 100%; + border-radius: 2px; +} + +#customize-control-header_image .remove { + float: left; + margin-right: 3px; +} + +#customize-control-header_image .new { + float: right; +} + + /** Handle cheaters. */ body.cheatin { min-width: 0; diff --git a/src/wp-admin/custom-header.php b/src/wp-admin/custom-header.php index 1a3db1a4a5..07c5dd8101 100644 --- a/src/wp-admin/custom-header.php +++ b/src/wp-admin/custom-header.php @@ -43,7 +43,7 @@ class Custom_Image_Header { var $default_headers = array(); /** - * Holds custom headers uploaded by the user + * Holds custom headers uploaded by the user. * * @var array * @since 3.2.0 @@ -73,6 +73,11 @@ class Custom_Image_Header { $this->admin_image_div_callback = $admin_image_div_callback; add_action( 'admin_menu', array( $this, 'init' ) ); + + add_action( 'customize_save_after', array( $this, 'customize_set_last_used' ) ); + add_action( 'wp_ajax_custom-header-crop', array( $this, 'ajax_header_crop' ) ); + add_action( 'wp_ajax_custom-header-add', array( $this, 'ajax_header_add' ) ); + add_action( 'wp_ajax_custom-header-remove', array( $this, 'ajax_header_remove' ) ); } /** @@ -93,6 +98,7 @@ class Custom_Image_Header { add_action("admin_head-$page", array($this, 'js'), 50); if ( $this->admin_header_callback ) add_action("admin_head-$page", $this->admin_header_callback, 51); + } /** @@ -819,32 +825,15 @@ wp_nonce_field( 'custom-header-options', '_wpnonce-custom-header-options' ); ?> $attachment_id = absint( $_POST['attachment_id'] ); $original = get_attached_file($attachment_id); - - $max_width = 0; - // For flex, limit size of image displayed to 1500px unless theme says otherwise - if ( current_theme_supports( 'custom-header', 'flex-width' ) ) - $max_width = 1500; - - if ( current_theme_supports( 'custom-header', 'max-width' ) ) - $max_width = max( $max_width, get_theme_support( 'custom-header', 'max-width' ) ); - $max_width = max( $max_width, get_theme_support( 'custom-header', 'width' ) ); - - if ( ( current_theme_supports( 'custom-header', 'flex-height' ) && ! current_theme_supports( 'custom-header', 'flex-width' ) ) || $_POST['width'] > $max_width ) - $dst_height = absint( $_POST['height'] * ( $max_width / $_POST['width'] ) ); - elseif ( current_theme_supports( 'custom-header', 'flex-height' ) && current_theme_supports( 'custom-header', 'flex-width' ) ) - $dst_height = absint( $_POST['height'] ); - else - $dst_height = get_theme_support( 'custom-header', 'height' ); - - if ( ( current_theme_supports( 'custom-header', 'flex-width' ) && ! current_theme_supports( 'custom-header', 'flex-height' ) ) || $_POST['width'] > $max_width ) - $dst_width = absint( $_POST['width'] * ( $max_width / $_POST['width'] ) ); - elseif ( current_theme_supports( 'custom-header', 'flex-width' ) && current_theme_supports( 'custom-header', 'flex-height' ) ) - $dst_width = absint( $_POST['width'] ); - else - $dst_width = get_theme_support( 'custom-header', 'width' ); + $dimensions = $this->get_header_dimensions( array( + 'height' => $_POST['height'], + 'width' => $_POST['width'], + ) ); + $height = $dimensions['dst_height']; + $width = $dimensions['dst_width']; if ( empty( $_POST['skip-cropping'] ) ) - $cropped = wp_crop_image( $attachment_id, (int) $_POST['x1'], (int) $_POST['y1'], (int) $_POST['width'], (int) $_POST['height'], $dst_width, $dst_height ); + $cropped = wp_crop_image( $attachment_id, (int) $_POST['x1'], (int) $_POST['y1'], (int) $_POST['width'], (int) $_POST['height'], $width, $height ); elseif ( ! empty( $_POST['create-new-attachment'] ) ) $cropped = _copy_image_file( $attachment_id ); else @@ -856,31 +845,15 @@ wp_nonce_field( 'custom-header-options', '_wpnonce-custom-header-options' ); ?> /** This filter is documented in wp-admin/custom-header.php */ $cropped = apply_filters( 'wp_create_file_in_uploads', $cropped, $attachment_id ); // For replication - $parent = get_post($attachment_id); - $parent_url = $parent->guid; - $url = str_replace( basename( $parent_url ), basename( $cropped ), $parent_url ); + $object = $this->create_attachment_object( $cropped, $attachment_id ); - $size = @getimagesize( $cropped ); - $image_type = ( $size ) ? $size['mime'] : 'image/jpeg'; - - // Construct the object array - $object = array( - 'ID' => $attachment_id, - 'post_title' => basename($cropped), - 'post_content' => $url, - 'post_mime_type' => $image_type, - 'guid' => $url, - 'context' => 'custom-header' - ); if ( ! empty( $_POST['create-new-attachment'] ) ) unset( $object['ID'] ); // Update the attachment - $attachment_id = wp_insert_attachment( $object, $cropped ); - wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $cropped ) ); + $attachment_id = $this->insert_attachment( $object, $cropped ); - $width = $dst_width; - $height = $dst_height; + $url = $object['guid']; $this->set_header_image( compact( 'url', 'attachment_id', 'width', 'height' ) ); // cleanup @@ -1041,4 +1014,218 @@ wp_nonce_field( 'custom-header-options', '_wpnonce-custom-header-options' ); ?> set_theme_mod( 'header_image', $default ); set_theme_mod( 'header_image_data', (object) $default_data ); } + + /** + * Calculate width and height based on what the currently selected theme supports. + * + * @return array dst_height and dst_width of header image. + */ + final public function get_header_dimensions( $dimensions ) { + $max_width = 0; + $width = absint( $dimensions['width'] ); + $height = absint( $dimensions['height'] ); + $theme_height = get_theme_support( 'custom-header', 'height' ); + $theme_width = get_theme_support( 'custom-header', 'width' ); + $has_flex_width = current_theme_supports( 'custom-header', 'flex-width' ); + $has_flex_height = current_theme_supports( 'custom-header', 'flex-height' ); + $has_max_width = current_theme_supports( 'custom-header', 'max-width' ) ; + $dst = array( 'dst_height' => null, 'dst_height' => null ); + + // For flex, limit size of image displayed to 1500px unless theme says otherwise + if ( $has_flex_width ) { + $max_width = 1500; + } + + if ( $has_max_width ) { + $max_width = max( $max_width, get_theme_support( 'custom-header', 'max-width' ) ); + } + $max_width = max( $max_width, $theme_width ); + + if ( $has_flex_height && ( ! $has_flex_width || $width > $max_width ) ) { + $dst['dst_height'] = absint( $height * ( $max_width / $width ) ); + } + elseif ( $has_flex_height && $has_flex_width ) { + $dst['dst_height'] = $height; + } + else { + $dst['dst_height'] = $theme_height; + } + + if ( $has_flex_width && ( ! $has_flex_height || $width > $max_width ) ) { + $dst['dst_width'] = absint( $width * ( $max_width / $width ) ); + } + elseif ( $has_flex_width && $has_flex_height ) { + $dst['dst_width'] = $width; + } + else { + $dst['dst_width'] = $theme_width; + } + + return $dst; + } + + /** + * Create an attachment 'object'. + * + * @param string $cropped Cropped image URL. + * @param int $parent_attachment_id Attachment ID of parent image. + * + * @return array Attachment object. + */ + final public function create_attachment_object( $cropped, $parent_attachment_id ) { + $parent = get_post( $parent_attachment_id ); + $parent_url = $parent->guid; + $url = str_replace( basename( $parent_url ), basename( $cropped ), $parent_url ); + + $size = @getimagesize( $cropped ); + $image_type = ( $size ) ? $size['mime'] : 'image/jpeg'; + + $object = array( + 'ID' => $parent_attachment_id, + 'post_title' => basename($cropped), + 'post_content' => $url, + 'post_mime_type' => $image_type, + 'guid' => $url, + 'context' => 'custom-header' + ); + + return $object; + } + + /** + * Insert an attachment & its metadata. + * + * @param array $object Attachment object. + * @param string $cropped Cropped image URL. + * + * @return int Attachment ID. + */ + final public function insert_attachment( $object, $cropped ) { + $attachment_id = wp_insert_attachment( $object, $cropped ); + $metadata = wp_generate_attachment_metadata( $attachment_id, $cropped ); + /** + * Allows us to insert custom meta data for an attachment. + * + */ + $metadata = apply_filters( 'wp_header_image_attachment_metadata', $metadata ); + wp_update_attachment_metadata( $attachment_id, $metadata ); + return $attachment_id; + } + + /** + * Gets attachment uploaded by Media Manager, crops it, then saves it as a + * new object. Returns JSON-encoded object details. + */ + function ajax_header_crop() { + check_ajax_referer( 'image_editor-' . $_POST['id'], 'nonce' ); + + if ( ! current_user_can( 'edit_theme_options' ) ) { + wp_send_json_error(); + } + + if ( ! current_theme_supports( 'custom-header', 'uploads' ) ) { + wp_send_json_error(); + } + + $crop_details = $_POST['cropDetails']; + + $dimensions = $this->get_header_dimensions( array( + 'height' => $crop_details['height'], + 'width' => $crop_details['width'], + ) ); + + $attachment_id = absint( $_POST['id'] ); + + $cropped = wp_crop_image( + $attachment_id, + (int) $crop_details['x1'], + (int) $crop_details['y1'], + (int) $crop_details['width'], + (int) $crop_details['height'], + (int) $dimensions['dst_width'], + (int) $dimensions['dst_height'] + ); + + if ( ! $cropped || is_wp_error( $cropped ) ) { + wp_send_json_error( array( 'message' => __( 'Image could not be processed. Please go back and try again.' ) ) ); + } + + $cropped = apply_filters( 'wp_create_file_in_uploads', $cropped, $attachment_id ); // For replication + + $object = $this->create_attachment_object( $cropped, $attachment_id ); + + unset( $object['ID'] ); + + $new_attachment_id = $this->insert_attachment( $object, $cropped ); + + $object['attachment_id'] = $new_attachment_id; + $object['width'] = $dimensions['dst_width']; + $object['height'] = $dimensions['dst_height']; + + wp_send_json_success( $object ); + } + + /** + * Given an attachment ID for a header image, updates its "last used" + * timestamp to now. + * + * Triggered when the user tries adds a new header image from the + * Media Manager, even if s/he doesn't save that change. + */ + function ajax_header_add() { + check_ajax_referer( 'header-add', 'nonce' ); + + if ( ! current_user_can( 'edit_theme_options' ) ) { + wp_send_json_error(); + } + + $attachment_id = absint( $_POST['attachment_id'] ); + if ( $attachment_id < 1 ) { + wp_send_json_error(); + } + + $key = '_wp_attachment_custom_header_last_used_' . get_stylesheet(); + update_post_meta( $attachment_id, $key, time() ); + update_post_meta( $attachment_id, '_wp_attachment_is_custom_header', get_stylesheet() ); + + wp_send_json_success(); + } + + /** + * Given an attachment ID for a header image, unsets it as a user-uploaded + * header image for the current theme. + * + * Triggered when the user clicks the overlay "X" button next to each image + * choice in the Customizer's Header tool. + */ + function ajax_header_remove() { + check_ajax_referer( 'header-remove', 'nonce' ); + + if ( ! current_user_can( 'edit_theme_options' ) ) { + wp_send_json_error(); + } + + $attachment_id = absint( $_POST['attachment_id'] ); + if ( $attachment_id < 1 ) { + wp_send_json_error(); + } + + $key = '_wp_attachment_custom_header_last_used_' . get_stylesheet(); + delete_post_meta( $attachment_id, $key ); + delete_post_meta( $attachment_id, '_wp_attachment_is_custom_header', get_stylesheet() ); + + wp_send_json_success(); + } + + function customize_set_last_used( $wp_customize ) { + $data = $wp_customize->get_setting( 'header_image_data' )->post_value(); + + if ( ! isset( $data['attachment_id'] ) ) { + return; + } + + $attachment_id = $data['attachment_id']; + $key = '_wp_attachment_custom_header_last_used_' . get_stylesheet(); + update_post_meta( $attachment_id, $key, time() ); + } } diff --git a/src/wp-admin/js/customize-controls.js b/src/wp-admin/js/customize-controls.js index 3a05ad437f..3bd7b414a1 100644 --- a/src/wp-admin/js/customize-controls.js +++ b/src/wp-admin/js/customize-controls.js @@ -1,3 +1,4 @@ +/* globals _wpCustomizeHeader, _wpMediaViewsL10n */ (function( exports, $ ){ var api = wp.customize; @@ -306,6 +307,217 @@ } }); + api.HeaderControl = api.Control.extend({ + ready: function() { + this.btnRemove = $('.actions .remove'); + this.btnNew = $('.actions .new'); + + _.bindAll(this, 'openMedia', 'removeImage'); + + this.btnNew.on( 'click', this.openMedia ); + this.btnRemove.on( 'click', this.removeImage ); + + api.HeaderTool.currentHeader = new api.HeaderTool.ImageModel(); + + new api.HeaderTool.CurrentView({ + model: api.HeaderTool.currentHeader, + el: '.current .container' + }); + + new api.HeaderTool.ChoiceListView({ + collection: api.HeaderTool.UploadsList = new api.HeaderTool.ChoiceList(), + el: '.choices .uploaded .list' + }); + + new api.HeaderTool.ChoiceListView({ + collection: api.HeaderTool.DefaultsList = new api.HeaderTool.DefaultsList(), + el: '.choices .default .list' + }); + + api.HeaderTool.combinedList = api.HeaderTool.CombinedList = new api.HeaderTool.CombinedList([ + api.HeaderTool.UploadsList, + api.HeaderTool.DefaultsList + ]); + }, + + /** + * Returns a set of options, computed from the attached image data and + * theme-specific data, to be fed to the imgAreaSelect plugin in + * wp.media.view.Cropper. + * + * @param {wp.media.model.Attachment} attachment + * @param {wp.media.controller.Cropper} controller + * @returns {Object} Options + */ + calculateImageSelectOptions: function(attachment, controller) { + var xInit = parseInt(_wpCustomizeHeader.data.width, 10), + yInit = parseInt(_wpCustomizeHeader.data.height, 10), + flexWidth = !! parseInt(_wpCustomizeHeader.data['flex-width'], 10), + flexHeight = !! parseInt(_wpCustomizeHeader.data['flex-height'], 10), + ratio, xImg, yImg, realHeight, realWidth, + imgSelectOptions; + + realWidth = attachment.get('width'); + realHeight = attachment.get('height'); + + this.headerImage = new api.HeaderTool.ImageModel(); + this.headerImage.set({ + themeWidth: xInit, + themeHeight: yInit, + themeFlexWidth: flexWidth, + themeFlexHeight: flexHeight, + imageWidth: realWidth, + imageHeight: realHeight + }); + + controller.set( 'canSkipCrop', ! this.headerImage.shouldBeCropped() ); + + ratio = xInit / yInit; + xImg = realWidth; + yImg = realHeight; + + if ( xImg / yImg > ratio ) { + yInit = yImg; + xInit = yInit * ratio; + } else { + xInit = xImg; + yInit = xInit / ratio; + } + + imgSelectOptions = { + handles: true, + keys: true, + instance: true, + persistent: true, + parent: this.$el, + imageWidth: realWidth, + imageHeight: realHeight, + x1: 0, + y1: 0, + x2: xInit, + y2: yInit + }; + + if (flexHeight === false && flexWidth === false) { + imgSelectOptions.aspectRatio = xInit + ':' + yInit; + } + if (flexHeight === false ) { + imgSelectOptions.maxHeight = yInit; + } + if (flexWidth === false ) { + imgSelectOptions.maxWidth = xInit; + } + + return imgSelectOptions; + }, + + /** + * Sets up and opens the Media Manager in order to select an image. + * Depending on both the size of the image and the properties of the + * current theme, a cropping step after selection may be required or + * skippable. + * + * @param {event} event + */ + openMedia: function(event) { + var title, suggestedWidth, suggestedHeight, + l10n = _wpMediaViewsL10n; + + event.preventDefault(); + + suggestedWidth = l10n.suggestedWidth.replace('%d', _wpCustomizeHeader.data.width); + suggestedHeight = l10n.suggestedHeight.replace('%d', _wpCustomizeHeader.data.height); + + /* '' + suggestedWidth + ' ' + suggestedHeight + '' */ + + this.frame = wp.media({ + title: l10n.chooseImage, + library: { + type: 'image' + }, + button: { + text: l10n.selectAndCrop, + close: false + }, + multiple: false, + imgSelectOptions: this.calculateImageSelectOptions + }); + + this.frame.states.add([new wp.media.controller.Cropper()]); + + this.frame.on('select', this.onSelect, this); + this.frame.on('cropped', this.onCropped, this); + this.frame.on('skippedcrop', this.onSkippedCrop, this); + + this.frame.open(); + }, + + onSelect: function() { + this.frame.setState('cropper'); + }, + onCropped: function(croppedImage) { + var url = croppedImage.post_content, + attachmentId = croppedImage.attachment_id, + w = croppedImage.width, + h = croppedImage.height; + this.setImageFromURL(url, attachmentId, w, h); + }, + onSkippedCrop: function(selection) { + var url = selection.get('url'), + w = selection.get('width'), + h = selection.get('height'); + this.setImageFromURL(url, selection.id, w, h); + }, + + /** + * Creates a new wp.customize.HeaderTool.ImageModel from provided + * header image data and inserts it into the user-uploaded headers + * collection. + * + * @param {String} url + * @param {Number} attachmentId + * @param {Number} width + * @param {Number} height + */ + setImageFromURL: function(url, attachmentId, width, height) { + var choice, data = {}; + + data.url = url; + data.thumbnail_url = url; + + if (attachmentId) { + data.attachment_id = attachmentId; + } + + if (width) { + data.width = width; + } + + if (height) { + data.height = height; + } + + choice = new api.HeaderTool.ImageModel({ + header: data, + choice: url.split('/').pop() + }); + api.HeaderTool.UploadsList.add(choice); + api.HeaderTool.currentHeader.set(choice.toJSON()); + choice.save(); + choice.importImage(); + }, + + /** + * Triggers the necessary events to deselect an image which was set as + * the currently selected one. + */ + removeImage: function() { + api.HeaderTool.currentHeader.trigger('hide'); + api.HeaderTool.CombinedList.trigger('control:removeImage'); + } + + }); + // Change objects contained within the main customize object to Settings. api.defaultConstructor = api.Setting; @@ -686,7 +898,8 @@ api.controlConstructor = { color: api.ColorControl, upload: api.UploadControl, - image: api.ImageControl + image: api.ImageControl, + header: api.HeaderControl }; $( function() { @@ -961,35 +1174,6 @@ }); }); - // Handle header image data - api.control( 'header_image', function( control ) { - control.setting.bind( function( to ) { - if ( to === control.params.removed ) - control.settings.data.set( false ); - }); - - control.library.on( 'click', 'a', function() { - control.settings.data.set( $(this).data('customizeHeaderImageData') ); - }); - - control.uploader.success = function( attachment ) { - var data; - - api.ImageControl.prototype.success.call( control, attachment ); - - data = { - attachment_id: attachment.get('id'), - url: attachment.get('url'), - thumbnail_url: attachment.get('url'), - height: attachment.get('height'), - width: attachment.get('width') - }; - - attachment.element.data( 'customizeHeaderImageData', data ); - control.settings.data.set( data ); - }; - }); - api.trigger( 'ready' ); // Make sure left column gets focus diff --git a/src/wp-includes/class-wp-customize-control.php b/src/wp-includes/class-wp-customize-control.php index 8dedf10fde..896bdd9390 100644 --- a/src/wp-includes/class-wp-customize-control.php +++ b/src/wp-includes/class-wp-customize-control.php @@ -708,37 +708,9 @@ class WP_Customize_Background_Image_Control extends WP_Customize_Image_Control { } } -/** - * Customize Header Image Control Class - * - * @package WordPress - * @subpackage Customize - * @since 3.4.0 - */ -class WP_Customize_Header_Image_Control extends WP_Customize_Image_Control { - /** - * The processed default headers. - * @since 3.4.2 - * @var array - */ - protected $default_headers; +final class WP_Customize_Header_Image_Control extends WP_Customize_Image_Control { + public $type = 'header'; - /** - * The uploaded headers. - * @since 3.4.2 - * @var array - */ - protected $uploaded_headers; - - /** - * Constructor. - * - * @since 3.4.0 - * @uses WP_Customize_Image_Control::__construct() - * @uses WP_Customize_Image_Control::add_tab() - * - * @param WP_Customize_Manager $manager - */ public function __construct( $manager ) { parent::__construct( $manager, 'header_image', array( 'label' => __( 'Header Image' ), @@ -750,86 +722,305 @@ class WP_Customize_Header_Image_Control extends WP_Customize_Image_Control { 'context' => 'custom-header', 'removed' => 'remove-header', 'get_url' => 'get_header_image', - 'statuses' => array( - '' => __('Default'), - 'remove-header' => __('No Image'), - 'random-default-image' => __('Random Default Image'), - 'random-uploaded-image' => __('Random Uploaded Image'), + ) ); + + } + + public function to_json() { + parent::to_json(); + } + + public function enqueue() { + wp_enqueue_media(); + wp_enqueue_script( 'customize-views' ); + + $this->prepare_control(); + + wp_localize_script( 'customize-views', '_wpCustomizeHeader', array( + 'data' => array( + 'width' => absint( get_theme_support( 'custom-header', 'width' ) ), + 'height' => absint( get_theme_support( 'custom-header', 'height' ) ), + 'flex-width' => absint( get_theme_support( 'custom-header', 'flex-width' ) ), + 'flex-height' => absint( get_theme_support( 'custom-header', 'flex-height' ) ), + 'currentImgSrc' => $this->get_current_image_src(), + ), + 'nonces' => array( + 'add' => wp_create_nonce( 'header-add' ), + 'remove' => wp_create_nonce( 'header-remove' ), + ), + 'l10n' => array( + /* translators: header images uploaded by user */ + 'uploaded' => __( 'uploaded' ), + /* translators: header images suggested by the current theme */ + 'default' => __( 'suggested' ) + ), + 'uploads' => $this->uploaded_headers, + 'defaults' => $this->default_headers + ) ); + + parent::enqueue(); + } + + public function get_default_header_images() { + global $custom_image_header; + + // Get *the* default image if there is one + $default = get_theme_support( 'custom-header', 'default-image' ); + + if ( ! $default ) { // If not, + return $custom_image_header->default_headers; // easy peasy. + } + + $default = sprintf( $default, + get_template_directory_uri(), + get_stylesheet_directory_uri() ); + + $header_images = array(); + $already_has_default = false; + + // Get the whole set of default images + $default_header_images = $custom_image_header->default_headers; + foreach ( $default_header_images as $k => $h ) { + if ( $h['url'] == $default ) { + $already_has_default = true; + break; + } + } + + // If *the one true image* isn't included in the default set, add it in + // first position + if ( ! $already_has_default ) { + $header_images['default'] = array( + 'url' => $default, + 'thumbnail_url' => $default, + 'description' => 'Default' + ); + } + + // The rest of the set comes after + $header_images = array_merge( $header_images, $default_header_images ); + + return $header_images; + } + + public function get_uploaded_header_images() { + $key = '_wp_attachment_custom_header_last_used_' . get_stylesheet(); + $header_images = array(); + + $headers_not_dated = get_posts( array( + 'post_type' => 'attachment', + 'meta_key' => '_wp_attachment_is_custom_header', + 'meta_value' => get_option('stylesheet'), + 'orderby' => 'none', + 'nopaging' => true, + 'meta_query' => array( + array( + 'key' => '_wp_attachment_is_custom_header', + 'value' => get_option( 'stylesheet' ), + 'compare' => 'LIKE' + ), + array( + 'key' => $key, + 'value' => 'this string must not be empty', + 'compare' => 'NOT EXISTS' + ), ) ) ); - // Remove the upload tab. - $this->remove_tab( 'upload-new' ); + $headers_dated = get_posts( array( + 'post_type' => 'attachment', + 'meta_key' => $key, + 'orderby' => 'meta_value_num', + 'order' => 'DESC', + 'nopaging' => true, + 'meta_query' => array( + array( + 'key' => '_wp_attachment_is_custom_header', + 'value' => get_option( 'stylesheet' ), + 'compare' => 'LIKE' + ), + ), + ) ); + + $limit = apply_filters( 'custom_header_uploaded_limit', 15 ); + $headers = array_merge( $headers_dated, $headers_not_dated ); + $headers = array_slice( $headers, 0, $limit ); + + foreach ( (array) $headers as $header ) { + $url = esc_url_raw( $header->guid ); + $header_data = wp_get_attachment_metadata( $header->ID ); + $timestamp = get_post_meta( $header->ID, + '_wp_attachment_custom_header_last_used_' . get_stylesheet(), + true ); + + $h = array( + 'attachment_id' => $header->ID, + 'url' => $url, + 'thumbnail_url' => $url, + 'timestamp' => $timestamp ? $timestamp : 0, + ); + + if ( isset( $header_data['width'] ) ) { + $h['width'] = $header_data['width']; + } + if ( isset( $header_data['height'] ) ) { + $h['height'] = $header_data['height']; + } + + $header_images[] = $h; + } + + return $header_images; } - /** - * Prepares the control. - * - * If no tabs exist, removes the control from the manager. - * - * @since 3.4.2 - */ public function prepare_control() { global $custom_image_header; - if ( empty( $custom_image_header ) ) - return parent::prepare_control(); + if ( empty( $custom_image_header ) ) { + return; + } // Process default headers and uploaded headers. $custom_image_header->process_default_headers(); - $this->default_headers = $custom_image_header->default_headers; - $this->uploaded_headers = get_uploaded_header_images(); - - if ( $this->default_headers ) - $this->add_tab( 'default', __('Default'), array( $this, 'tab_default_headers' ) ); - - if ( ! $this->uploaded_headers ) - $this->remove_tab( 'uploaded' ); - - return parent::prepare_control(); + $this->default_headers = $this->get_default_header_images(); + $this->uploaded_headers = $this->get_uploaded_header_images(); } - /** - * @since 3.4.0 - * - * @param mixed $choice Which header image to select. (@see Custom_Image_Header::get_header_image() ) - * @param array $header - */ - public function print_header_image( $choice, $header ) { - $header['url'] = set_url_scheme( $header['url'] ); - $header['thumbnail_url'] = set_url_scheme( $header['thumbnail_url'] ); - - $header_image_data = array( 'choice' => $choice ); - foreach ( array( 'attachment_id', 'width', 'height', 'url', 'thumbnail_url' ) as $key ) { - if ( isset( $header[ $key ] ) ) - $header_image_data[ $key ] = $header[ $key ]; - } - - + function print_header_image_template() { ?> - - - + + +
uploaded_headers as $choice => $header ) - $this->print_header_image( $choice, $header ); + public function get_current_image_src() { + $src = $this->value(); + if ( isset( $this->get_url ) ) { + $src = call_user_func( $this->get_url, $src ); + return $src; + } + return null; } - /** - * @since 3.4.0 - */ - public function tab_default_headers() { - foreach ( $this->default_headers as $choice => $header ) - $this->print_header_image( $choice, $header ); + public function render_content() { + $this->print_header_image_template(); + $visibility = $this->get_current_image_src() ? '' : ' style="display:none" '; + $width = absint( get_theme_support( 'custom-header', 'width' ) ); + $height = absint( get_theme_support( 'custom-header', 'height' ) ); + ?> + + +
+

+ + %s, your theme recommends a header size of %dx%d pixels.' ), + _x( 'Add new', 'header image' ), $width, $height ); + } else { + if ( $width ) { + printf( __( 'While you can crop images to your liking after clicking %s, your theme recommends a header width of %d pixels.' ), + _x( 'Add new', 'header image' ), $width ); + } + if ( $height ) { + printf( __( 'While you can crop images to your liking after clicking %s, your theme recommends a header height of %d pixels.' ), + _x( 'Add new', 'header image' ), $height ); + } + } + ?> +

+
+ + + +
+
+
+ +
+ + + +
+
+
+
+ + + +
+
+
+
+
+
+ 0) { + this.addRandomChoice(current); + } + }, + + maybeAddRandomChoice: function() { + if (this.size() === 1) { + this.addRandomChoice(); + } + }, + + addRandomChoice: function(initialChoice) { + var isRandomSameType = RegExp(this.type).test(initialChoice), + randomChoice = 'random-' + this.type + '-image'; + + this.add({ + header: { + timestamp: 0, + random: randomChoice, + width: 245, + height: 41 + }, + choice: randomChoice, + random: true, + hidden: isRandomSameType + }); + }, + + isRandomChoice: function(choice) { + return (/^random-(uploaded|default)-image$/).test(choice); + }, + + shouldHideTitle: function() { + return _.every(this.pluck('hidden')); + }, + + setImage: function(model) { + this.each(function(m) { + m.set('hidden', false); + }); + + if (model) { + model.set('hidden', true); + // Bump images to top except for special "Randomize" images + if (!model.get('random')) { + model.get('header').timestamp = Date.now(); + this.sort(); + } + } + }, + + removeImage: function() { + this.each(function(m) { + m.set('hidden', false); + }); + }, + + shown: function() { + var filtered = this.where({ hidden: false }); + return new api.HeaderTool.ChoiceList( filtered ); + } + }); + + + /** + * wp.customize.HeaderTool.DefaultsList + * + * @constructor + * @augments wp.customize.HeaderTool.ChoiceList + * @augments Backbone.Collection + */ + api.HeaderTool.DefaultsList = api.HeaderTool.ChoiceList.extend({ + initialize: function() { + this.type = 'default'; + this.data = _wpCustomizeHeader.defaults; + api.HeaderTool.ChoiceList.prototype.initialize.apply(this); + } + }); + +})( jQuery, window.wp ); diff --git a/src/wp-includes/js/customize-views.js b/src/wp-includes/js/customize-views.js new file mode 100644 index 0000000000..99a004edb8 --- /dev/null +++ b/src/wp-includes/js/customize-views.js @@ -0,0 +1,232 @@ +/* globals _wpCustomizeHeader */ +(function( $, wp, _ ) { + + if ( ! wp || ! wp.customize ) { return; } + var api = wp.customize; + + + /** + * wp.customize.HeaderTool.CurrentView + * + * Displays the currently selected header image, or a placeholder in lack + * thereof. + * + * Instantiate with model wp.customize.HeaderTool.currentHeader. + * + * @constructor + * @augments wp.Backbone.View + */ + api.HeaderTool.CurrentView = wp.Backbone.View.extend({ + template: wp.template('header-current'), + + initialize: function() { + this.listenTo(this.model, 'change', this.render); + this.render(); + }, + + render: function() { + this.$el.html(this.template(this.model.toJSON())); + this.setPlaceholder(); + this.setButtons(); + return this; + }, + + getHeight: function() { + var image = this.$el.find('img'), + saved = this.model.get('savedHeight'), + height = image.height() || saved, + headerImageData; + + if (image.length) { + this.$el.find('.inner').hide(); + } else { + this.$el.find('.inner').show(); + } + + // happens at ready + if (!height) { + headerImageData = api.get().header_image_data; + + if (headerImageData && headerImageData.width && headerImageData.height) { + // hardcoded container width + height = 260 / headerImageData.width * headerImageData.height; + } + else { + // fallback for when no image is set + height = 40; + } + } + + return height; + }, + + setPlaceholder: function(_height) { + var height = _height || this.getHeight(); + this.model.set('savedHeight', height); + this.$el + .add(this.$el.find('.placeholder')) + .height(height); + }, + + setButtons: function() { + var elements = $('.actions .remove'); + if (this.model.get('choice')) { + elements.show(); + } else { + elements.hide(); + } + } + }); + + + /** + * wp.customize.HeaderTool.ChoiceView + * + * Represents a choosable header image, be it user-uploaded, + * theme-suggested or a special Randomize choice. + * + * Takes a wp.customize.HeaderTool.ImageModel. + * + * Manually changes model wp.customize.HeaderTool.currentHeader via the + * `select` method. + * + * @constructor + * @augments wp.Backbone.View + */ + (function () { // closures FTW + var lastHeight = 0; + api.HeaderTool.ChoiceView = wp.Backbone.View.extend({ + template: wp.template('header-choice'), + + className: 'header-view', + + events: { + 'click .choice,.random': 'select', + 'click .close': 'removeImage' + }, + + initialize: function() { + var properties = [ + this.model.get('header').url, + this.model.get('choice') + ]; + + this.listenTo(this.model, 'change', this.render); + + if (_.contains(properties, api.get().header_image)) { + api.HeaderTool.currentHeader.set(this.extendedModel()); + } + }, + + render: function() { + var model = this.model; + + this.$el.html(this.template(this.extendedModel())); + + if (model.get('random')) { + this.setPlaceholder(40); + } + else { + lastHeight = this.getHeight(); + } + + this.$el.toggleClass('hidden', model.get('hidden')); + return this; + }, + + extendedModel: function() { + var c = this.model.get('collection'), + t = _wpCustomizeHeader.l10n[c.type] || ''; + + return _.extend(this.model.toJSON(), { + // -1 to exclude the randomize button + nImages: c.size() - 1, + type: t + }); + }, + + getHeight: api.HeaderTool.CurrentView.prototype.getHeight, + + setPlaceholder: api.HeaderTool.CurrentView.prototype.setPlaceholder, + + select: function() { + this.model.save(); + api.HeaderTool.currentHeader.set(this.extendedModel()); + }, + + removeImage: function(e) { + e.stopPropagation(); + this.model.destroy(); + this.remove(); + } + }); + })(); + + + /** + * wp.customize.HeaderTool.ChoiceListView + * + * A container for ChoiceViews. These choices should be of one same type: + * user-uploaded headers or theme-defined ones. + * + * Takes a wp.customize.HeaderTool.ChoiceList. + * + * @constructor + * @augments wp.Backbone.View + */ + api.HeaderTool.ChoiceListView = wp.Backbone.View.extend({ + initialize: function() { + this.listenTo(this.collection, 'add', this.addOne); + this.listenTo(this.collection, 'remove', this.render); + this.listenTo(this.collection, 'sort', this.render); + this.listenTo(this.collection, 'change:hidden', this.toggleTitle); + this.listenTo(this.collection, 'change:hidden', this.setMaxListHeight); + this.render(); + }, + + render: function() { + this.$el.empty(); + this.collection.each(this.addOne, this); + this.toggleTitle(); + }, + + addOne: function(choice) { + var view; + choice.set({ collection: this.collection }); + view = new api.HeaderTool.ChoiceView({ model: choice }); + this.$el.append(view.render().el); + }, + + toggleTitle: function() { + var title = this.$el.parents().prev('.customize-control-title'); + if (this.collection.shouldHideTitle()) { + title.hide(); + } else { + title.show(); + } + } + }); + + + /** + * wp.customize.HeaderTool.CombinedList + * + * Aggregates wp.customize.HeaderTool.ChoiceList collections (or any + * Backbone object, really) and acts as a bus to feed them events. + * + * @constructor + * @augments wp.Backbone.View + */ + api.HeaderTool.CombinedList = wp.Backbone.View.extend({ + initialize: function(collections) { + this.collections = collections; + this.on('all', this.propagate, this); + }, + propagate: function(event, arg) { + _.each(this.collections, function(collection) { + collection.trigger(event, arg); + }); + } + }); + +})( jQuery, window.wp, _ ); diff --git a/src/wp-includes/js/media-views.js b/src/wp-includes/js/media-views.js index e0d181c472..2603cf3cb7 100644 --- a/src/wp-includes/js/media-views.js +++ b/src/wp-includes/js/media-views.js @@ -1316,6 +1316,109 @@ } }); + /** + * wp.media.controller.Cropper + * + * Allows for a cropping step. + * + * @constructor + * @augments wp.media.controller.State + * @augments Backbone.Model + */ + media.controller.Cropper = media.controller.State.extend({ + defaults: { + id: 'cropper', + title: l10n.cropImage, + toolbar: 'crop', + content: 'crop', + router: false, + canSkipCrop: false + }, + + activate: function() { + this.frame.on( 'content:create:crop', this.createCropContent, this ); + this.frame.on( 'close', this.removeCropper, this ); + this.set('selection', new Backbone.Collection(this.frame._selection.single)); + }, + + deactivate: function() { + this.frame.toolbar.mode('browse'); + }, + + createCropContent: function() { + this.cropperView = new wp.media.view.Cropper({controller: this, + attachment: this.get('selection').first() }); + this.cropperView.on('image-loaded', this.createCropToolbar, this); + this.frame.content.set(this.cropperView); + + }, + removeCropper: function() { + this.imgSelect.cancelSelection(); + this.imgSelect.setOptions({remove: true}); + this.imgSelect.update(); + this.cropperView.remove(); + }, + createCropToolbar: function() { + var canSkipCrop, toolbarOptions; + + canSkipCrop = this.get('canSkipCrop') || false; + + toolbarOptions = { + controller: this.frame, + items: { + insert: { + style: 'primary', + text: l10n.cropImage, + priority: 80, + requires: { library: false, selection: false }, + + click: function() { + var self = this, + selection = this.controller.state().get('selection').first(); + + selection.set({cropDetails: this.controller.state().imgSelect.getSelection()}); + + this.$el.text(l10n.cropping); + this.$el.attr('disabled', true); + this.controller.state().doCrop( selection ).done( function( croppedImage ) { + console.log( croppedImage ); + self.controller.trigger('cropped', croppedImage ); + self.controller.close(); + }); + } + } + } + }; + + if ( canSkipCrop ) { + _.extend( toolbarOptions.items, { + skip: { + style: 'secondary', + text: l10n.skipCropping, + priority: 70, + requires: { library: false, selection: false }, + click: function() { + var selection = this.controller.state().get('selection').first(); + this.controller.state().cropperView.remove(); + this.controller.trigger('skippedcrop', selection); + this.controller.close(); + } + } + }); + } + + this.frame.toolbar.set( new wp.media.view.Toolbar(toolbarOptions) ); + }, + + doCrop: function( attachment ) { + return wp.ajax.post( 'custom-header-crop', { + nonce: attachment.get('nonces').edit, + id: attachment.get('id'), + cropDetails: attachment.get('cropDetails') + } ); + } + }); + /** * ======================================================================== * VIEWS @@ -6323,6 +6426,53 @@ } }); + /** + * wp.media.view.Cropper + * + * Uses the imgAreaSelect plugin to allow a user to crop an image. + * + * Takes imgAreaSelect options from + * wp.customize.HeaderControl.calculateImageSelectOptions via + * wp.customize.HeaderControl.openMM. + * + * @constructor + * @augments wp.media.View + * @augments wp.Backbone.View + * @augments Backbone.View + */ + media.view.Cropper = media.View.extend({ + tagName: 'img', + className: 'crop-content', + initialize: function() { + _.bindAll(this, 'onImageLoad'); + this.$el.attr('src', this.options.attachment.get('url')); + }, + ready: function() { + this.$el.on('load', this.onImageLoad); + $(window).on('resize.cropper', _.debounce(this.onImageLoad, 250)); + }, + remove: function() { + $(window).off('resize.cropper'); + this.$el.remove(); + this.$el.off(); + wp.media.View.prototype.remove.apply(this, arguments); + }, + prepare: function() { + return { + title: l10n.cropYourImage, + url: this.options.attachment.get('url') + }; + }, + onImageLoad: function() { + var imgOptions = this.controller.frame.options.imgSelectOptions; + if (typeof imgOptions === 'function') { + imgOptions = imgOptions(this.options.attachment, this.controller); + } + this.trigger('image-loaded'); + this.controller.imgSelect = this.$el.imgAreaSelect(imgOptions); + } + + }); media.view.EditImage = media.View.extend({ diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index b2896a563b..b52f2973e5 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -2476,6 +2476,23 @@ function wp_enqueue_media( $args = array() ) { 'imageDetailsCancel' => __( 'Cancel Edit' ), 'editImage' => __( 'Edit Image' ), + // Crop Image + /* translators: title for Media Manager library view */ + 'chooseImage' => __( 'Choose Image' ), + /* translators: button to select an image from the MM library to crop */ + 'selectAndCrop' => __( 'Select and Crop' ), + /* translators: button to choose not to crop the selected image */ + 'skipCropping' => __( 'Skip Cropping' ), + /* translators: button to choose to crop the selected image */ + 'cropImage' => __( 'Crop Image' ), + 'cropYourImage' => __( 'Crop your image' ), + /* translators: button label changes to this while the image is being cropped server-side */ + 'cropping' => __( 'Cropping...' ), + /* translators: suggested width of header image in pixels */ + 'suggestedWidth' => __( 'Suggested width is %d pixels.' ), + /* translators: suggested height of header image in pixels */ + 'suggestedHeight' => __( 'Suggested height is %d pixels.' ), + // Edit Audio 'audioDetailsTitle' => __( 'Audio Details' ), 'audioReplaceTitle' => __( 'Replace Audio' ), diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index c80ed52ffa..728f87e683 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -363,6 +363,8 @@ function wp_default_scripts( &$scripts ) { $scripts->add( 'customize-base', "/wp-includes/js/customize-base$suffix.js", array( 'jquery', 'json2' ), false, 1 ); $scripts->add( 'customize-loader', "/wp-includes/js/customize-loader$suffix.js", array( 'customize-base' ), false, 1 ); $scripts->add( 'customize-preview', "/wp-includes/js/customize-preview$suffix.js", array( 'customize-base' ), false, 1 ); + $scripts->add( 'customize-models', "/wp-includes/js/customize-models.js", array( 'underscore', 'backbone' ), false, 1 ); + $scripts->add( 'customize-views', "/wp-includes/js/customize-views.js", array( 'jquery', 'underscore', 'imgareaselect', 'customize-models' ), false, 1 ); $scripts->add( 'customize-controls', "/wp-admin/js/customize-controls$suffix.js", array( 'customize-base' ), false, 1 ); did_action( 'init' ) && $scripts->localize( 'customize-controls', '_wpCustomizeControlsL10n', array( 'activate' => __( 'Save & Activate' ), @@ -600,7 +602,7 @@ function wp_default_styles( &$styles ) { $styles->add( 'login', "/wp-admin/css/login$suffix.css", array( 'buttons', 'open-sans', 'dashicons' ) ); $styles->add( 'install', "/wp-admin/css/install$suffix.css", array( 'buttons', 'open-sans' ) ); $styles->add( 'wp-color-picker', "/wp-admin/css/color-picker$suffix.css" ); - $styles->add( 'customize-controls', "/wp-admin/css/customize-controls$suffix.css", array( 'wp-admin', 'colors', 'ie' ) ); + $styles->add( 'customize-controls', "/wp-admin/css/customize-controls$suffix.css", array( 'wp-admin', 'colors', 'ie', 'imgareaselect' ) ); $styles->add( 'ie', "/wp-admin/css/ie$suffix.css" ); $styles->add_data( 'ie', 'conditional', 'lte IE 7' );