Introduce Edit Image (single mode) in the media modal and use it to edit images inserted in the editor. Adds new feature: replace an image in the editor. Props gcorne, see #24409.

git-svn-id: https://develop.svn.wordpress.org/trunk@27050 602fd350-edb4-49c9-b593-d223f7449a82
This commit is contained in:
Andrew Ozz 2014-01-28 21:16:42 +00:00
parent c712a321ad
commit 20f89d7c4b
7 changed files with 755 additions and 8 deletions

View File

@ -228,7 +228,6 @@ final class _WP_Editors {
'paste',
'tabfocus',
'textcolor',
'image',
'fullscreen',
'wordpress',
'wpeditimage',

View File

@ -86,6 +86,10 @@
color: #a9a9a9;
}
.media-frame .hidden {
display: none;
}
/* Enable draggable on IE10 touch events until it's rolled into jQuery UI core */
.ui-sortable,
.ui-draggable {
@ -1411,7 +1415,7 @@
}
/**
* Embed from URL
* Embed from URL and Image Details
*/
.embed-url {
display: block;
@ -1452,6 +1456,10 @@
overflow: auto;
}
.image-details .embed-image-settings {
top: 0;
}
.media-embed .thumbnail {
max-width: 100%;
max-height: 200px;
@ -1484,6 +1492,10 @@
clear: both;
}
.media-embed .setting .hidden {
display: none;
}
.media-embed .setting span {
display: block;
width: 200px;
@ -1926,4 +1938,4 @@
.media-frame .spinner {
background-image: url('../images/spinner-2x.gif');
}
}
}

View File

@ -2,7 +2,7 @@
window.wp = window.wp || {};
(function($){
var Attachment, Attachments, Query, compare, l10n, media;
var Attachment, Attachments, Query, PostImage, compare, l10n, media;
/**
* wp.media( attributes )
@ -30,6 +30,8 @@ window.wp = window.wp || {};
frame = new MediaFrame.Select( attributes );
} else if ( 'post' === attributes.frame && MediaFrame.Post ) {
frame = new MediaFrame.Post( attributes );
} else if ( 'image' === attributes.frame && MediaFrame.ImageDetails ) {
frame = new MediaFrame.ImageDetails( attributes );
}
delete attributes.frame;
@ -339,6 +341,121 @@ window.wp = window.wp || {};
})
});
/**
* wp.media.model.Attachment
*
* @constructor
* @augments Backbone.Model
*
**/
PostImage = media.model.PostImage = Backbone.Model.extend({
initialize: function( attributes ) {
this.attachment = false;
if ( attributes.attachment_id ) {
this.attachment = media.model.Attachment.get( attributes.attachment_id );
this.dfd = this.attachment.fetch();
this.bindAttachmentListeners();
}
// keep url in sync with changes to the type of link
this.on( 'change:link', this.updateLinkUrl, this );
this.on( 'change:size', this.updateSize, this );
this.setLinkTypeFromUrl();
},
bindAttachmentListeners: function() {
this.listenTo( this.attachment, 'sync', this.setLinkTypeFromUrl );
},
changeAttachment: function( attachment, props ) {
this.stopListening( this.attachment );
this.attachment = attachment;
this.bindAttachmentListeners();
this.set( 'attachment_id', this.attachment.get( 'id' ) );
this.set( 'caption', this.attachment.get( 'caption' ) );
this.set( 'alt', this.attachment.get( 'alt' ) );
this.set( 'size', props.get( 'size' ) );
this.set( 'align', props.get( 'align' ) );
this.set( 'link', props.get( 'link' ) );
this.updateLinkUrl();
this.updateSize();
},
setLinkTypeFromUrl: function() {
var linkUrl = this.get( 'linkUrl' ),
type;
if ( ! linkUrl ) {
this.set( 'link', 'none' );
return;
}
// default to custom if there is a linkUrl
type = 'custom';
if ( this.attachment ) {
if ( this.attachment.get( 'url' ) === linkUrl ) {
type = 'file';
} else if ( this.attachment.get( 'link' ) === linkUrl ) {
type = 'post';
}
} else {
if ( this.get( 'url' ) === linkUrl ) {
type = 'file';
}
}
this.set( 'link', type );
},
updateLinkUrl: function() {
var link = this.get( 'link' ),
url;
switch( link ) {
case 'file':
if ( this.attachment ) {
url = this.attachment.get( 'url' );
} else {
url = this.get( 'url' );
}
this.set( 'linkUrl', url );
break;
case 'post':
this.set( 'linkUrl', this.attachment.get( 'link' ) );
break;
case 'none':
this.set( 'linkUrl', '' );
break;
}
},
updateSize: function() {
var size;
if ( ! this.attachment ) {
return;
}
size = this.attachment.get( 'sizes' )[ this.get( 'size' ) ];
this.set( 'url', size.url );
this.set( 'width', size.width );
this.set( 'height', size.height );
}
});
/**
* wp.media.model.Attachments
*
@ -1170,4 +1287,4 @@ window.wp = window.wp || {};
window.wp = null;
});
}(jQuery));
}(jQuery));

View File

@ -980,6 +980,107 @@
}
});
media.controller.ImageDetails = media.controller.State.extend({
defaults: _.defaults({
id: 'image-details',
toolbar: 'image-details',
title: l10n.imageDetailsTitle,
content: 'image-details',
menu: 'image-details',
router: false,
attachment: false,
priority: 60,
editing: false
}, media.controller.Library.prototype.defaults ),
initialize: function( options ) {
this.image = options.image;
media.controller.State.prototype.initialize.apply( this, arguments );
}
});
/**
* wp.media.controller.ReplaceImage
*
* Replace a selected single image
*
**/
media.controller.ReplaceImage = media.controller.Library.extend({
defaults: _.defaults({
id: 'replace-image',
filterable: 'uploaded',
multiple: false,
toolbar: 'replace',
title: l10n.replaceImageTitle,
priority: 60,
syncSelection: false
}, media.controller.Library.prototype.defaults ),
initialize: function( options ) {
var library, comparator;
this.image = options.image;
// If we haven't been provided a `library`, create a `Selection`.
if ( ! this.get('library') ) {
this.set( 'library', media.query({ type: 'image' }) );
}
/**
* call 'initialize' directly on the parent class
*/
media.controller.Library.prototype.initialize.apply( this, arguments );
library = this.get('library');
comparator = library.comparator;
// Overload the library's comparator to push items that are not in
// the mirrored query to the front of the aggregate collection.
library.comparator = function( a, b ) {
var aInQuery = !! this.mirroring.get( a.cid ),
bInQuery = !! this.mirroring.get( b.cid );
if ( ! aInQuery && bInQuery ) {
return -1;
} else if ( aInQuery && ! bInQuery ) {
return 1;
} else {
return comparator.apply( this, arguments );
}
};
// Add all items in the selection to the library, so any featured
// images that are not initially loaded still appear.
library.observe( this.get('selection') );
},
activate: function() {
this.updateSelection();
/**
* call 'activate' directly on the parent class
*/
media.controller.Library.prototype.activate.apply( this, arguments );
},
deactivate: function() {
/**
* call 'deactivate' directly on the parent class
*/
media.controller.Library.prototype.deactivate.apply( this, arguments );
},
updateSelection: function() {
var selection = this.get('selection'),
attachment = this.image.attachment;
selection.reset( attachment ? [ attachment ] : [] );
}
});
/**
* wp.media.controller.Embed
*
@ -1924,8 +2025,157 @@
}
}) );
}
});
media.view.MediaFrame.ImageDetails = media.view.MediaFrame.Select.extend({
defaults: {
id: 'image',
url: '',
menu: 'image-details',
content: 'image-details',
toolbar: 'image-details',
type: 'link',
title: l10n.imageDetailsTitle,
priority: 120
},
initialize: function( options ) {
this.image = new media.model.PostImage( options.metadata );
this.options.selection = new media.model.Selection( this.image.attachment, { multiple: false } );
media.view.MediaFrame.Select.prototype.initialize.apply( this, arguments );
},
bindHandlers: function() {
media.view.MediaFrame.Select.prototype.bindHandlers.apply( this, arguments );
this.on( 'menu:create:image-details', this.createMenu, this );
this.on( 'content:render:image-details', this.renderImageDetailsContent, this );
this.on( 'menu:render:image-details', this.renderMenu, this );
this.on( 'toolbar:render:image-details', this.renderImageDetailsToolbar, this );
// override the select toolbar
this.on( 'toolbar:render:replace', this.renderReplaceImageToolbar, this );
},
createStates: function() {
this.states.add([
new media.controller.ImageDetails({
image: this.image,
editable: false,
menu: 'image-details'
}),
new media.controller.ReplaceImage({
id: 'replace-image',
library: media.query( { type: 'image' } ),
image: this.image,
multiple: false,
title: l10n.imageReplaceTitle,
menu: 'image-details',
toolbar: 'replace',
priority: 80,
displaySettings: true
})
]);
},
renderImageDetailsContent: function() {
var view = new media.view.ImageDetails({
controller: this,
model: this.state().image,
attachment: this.state().image.attachment
}).render();
this.content.set( view );
},
renderMenu: function( view ) {
var lastState = this.lastState(),
previous = lastState && lastState.id,
frame = this;
view.set({
cancel: {
text: l10n.imageDetailsCancel,
priority: 20,
click: function() {
if ( previous ) {
frame.setState( previous );
} else {
frame.close();
}
}
},
separateCancel: new media.View({
className: 'separator',
priority: 40
})
});
},
renderImageDetailsToolbar: function() {
this.toolbar.set( new media.view.Toolbar({
controller: this,
items: {
select: {
style: 'primary',
text: l10n.update,
priority: 80,
click: function() {
var controller = this.controller,
state = controller.state();
controller.close();
// not sure if we want to use wp.media.string.image which will create a shortcode or
// perhaps wp.html.string to at least to build the <img />
state.trigger( 'update', controller.image.toJSON() );
// Restore and reset the default state.
controller.setState( controller.options.state );
controller.reset();
}
}
}
}) );
},
renderReplaceImageToolbar: function() {
this.toolbar.set( new media.view.Toolbar({
controller: this,
items: {
replace: {
style: 'primary',
text: l10n.replace,
priority: 80,
click: function() {
var controller = this.controller,
state = controller.state(),
selection = state.get( 'selection' ),
attachment = selection.single();
controller.close();
controller.image.changeAttachment( attachment, state.display( attachment ) );
// not sure if we want to use wp.media.string.image which will create a shortcode or
// perhaps wp.html.string to at least to build the <img />
state.trigger( 'replace', controller.image.toJSON() );
// Restore and reset the default state.
controller.setState( controller.options.state );
controller.reset();
}
}
}
}) );
}
});
/**
* wp.media.view.Modal
*
@ -4555,7 +4805,7 @@
attachment = this.options.attachment;
if ( 'none' === linkTo || 'embed' === linkTo || ( ! attachment && 'custom' !== linkTo ) ) {
$input.hide();
$input.addClass( 'hidden' );
return;
}
@ -4571,7 +4821,7 @@
$input.prop( 'readonly', 'custom' !== linkTo );
}
$input.show();
$input.removeClass( 'hidden' );
// If the input is visible, focus and select its contents.
if ( $input.is(':visible') ) {
@ -4932,4 +5182,50 @@
this.$('img').attr( 'src', this.model.get('url') );
}
});
}(jQuery));
media.view.ImageDetails = media.view.Settings.AttachmentDisplay.extend({
className: 'image-details',
template: media.template('image-details'),
initialize: function() {
// used in AttachmentDisplay.prototype.updateLinkTo
this.options.attachment = this.model.attachment;
media.view.Settings.AttachmentDisplay.prototype.initialize.apply( this, arguments );
},
prepare: function() {
var attachment = false;
if ( this.model.attachment ) {
attachment = this.model.attachment.toJSON();
}
return _.defaults({
model: this.model.toJSON(),
attachment: attachment
}, this.options );
},
render: function() {
var self = this,
args = arguments;
if ( this.model.attachment && 'pending' === this.model.dfd.state() ) {
// should instead show a spinner when the attachment is new and then add a listener that updates on change
this.model.dfd.done( function() {
media.view.Settings.AttachmentDisplay.prototype.render.apply( self, args );
self.resetFocus();
} );
} else {
media.view.Settings.AttachmentDisplay.prototype.render.apply( this, arguments );
setTimeout( function() { self.resetFocus(); }, 10 );
}
return this;
},
resetFocus: function() {
this.$( '.caption textarea' ).focus();
this.$( '.embed-image-settings' ).scrollTop( 0 );
}
});
}(jQuery));

View File

@ -101,6 +101,176 @@ tinymce.PluginManager.add( 'wpeditimage', function( editor ) {
});
}
function extractImageData( imageNode ) {
var classes, metadata, captionBlock, caption;
// default attributes
metadata = {
attachment_id: false,
url: false,
height: '',
width: '',
size: 'none',
caption: '',
alt: '',
align: 'none',
link: false,
linkUrl: ''
};
metadata.url = editor.dom.getAttrib( imageNode, 'src' );
metadata.alt = editor.dom.getAttrib( imageNode, 'alt' );
metadata.width = parseInt( editor.dom.getAttrib( imageNode, 'width' ), 10 );
metadata.height = parseInt( editor.dom.getAttrib( imageNode, 'height' ), 10 );
//TODO: probably should capture attributes on both the <img /> and the <a /> so that they can be restored when the image and/or caption are updated
// maybe use getAttribs()
// extract meta data from classes (candidate for turning into a method)
classes = imageNode.className.split( ' ' );
tinymce.each( classes, function( name ) {
if ( /^wp-image/.test( name ) ) {
metadata.attachment_id = parseInt( name.replace( 'wp-image-', '' ), 10 );
}
if ( /^align/.test( name ) ) {
metadata.align = name.replace( 'align', '' );
}
if ( /^size/.test( name ) ) {
metadata.size = name.replace( 'size-', '' );
}
} );
// extract caption
captionBlock = editor.dom.getParents( imageNode, '.wp-caption' );
if ( captionBlock.length ) {
captionBlock = captionBlock[0];
classes = captionBlock.className.split( ' ' );
tinymce.each( classes, function( name ) {
if ( /^align/.test( name ) ) {
metadata.align = name.replace( 'align', '' );
}
} );
caption = editor.dom.select( 'dd.wp-caption-dd', captionBlock );
if ( caption.length ) {
caption = caption[0];
// need to do some more thinking about this
metadata.caption = editor.serializer.serialize( caption )
.replace( /<br[^>]*>/g, '$&\n' ).replace( /^<p>/, '' ).replace( /<\/p>$/, '' );
}
}
// extract linkTo
if ( imageNode.parentNode.nodeName === 'A' ) {
metadata.linkUrl = editor.dom.getAttrib( imageNode.parentNode, 'href' );
}
return metadata;
}
function updateImage( imageNode, imageData ) {
var className, width, node, html, captionNode, nodeToReplace, uid;
if ( imageData.caption ) {
html = createImageAndLink( imageData, 'html' );
width = imageData.width + 10;
className = 'align' + imageData.align;
//TODO: shouldn't add the id attribute if it isn't an attachment
// should create a new function for genrating the caption markup
html = '<dl id="'+ imageData.attachment_id +'" class="wp-caption '+ className +'" style="width: '+ width +'px">' +
'<dt class="wp-caption-dt">'+ html + '</dt><dd class="wp-caption-dd">'+ imageData.caption +'</dd></dl>';
node = editor.dom.create( 'div', { 'class': 'mceTemp', draggable: 'true' }, html );
} else {
node = createImageAndLink( imageData, 'node' );
}
nodeToReplace = imageNode;
captionNode = editor.dom.getParent( imageNode, '.mceTemp' );
if ( captionNode ) {
nodeToReplace = captionNode;
} else {
if ( imageNode.parentNode.nodeName === 'A' ) {
nodeToReplace = imageNode.parentNode;
}
}
// uniqueId isn't super exciting, so maybe we want to use something else
uid = editor.dom.uniqueId( 'wp_' );
editor.dom.setAttrib( node, 'data-wp-replace-id', uid );
editor.dom.replace( node, nodeToReplace );
// find the updated node
node = editor.dom.select( '[data-wp-replace-id="' + uid + '"]' )[0];
editor.dom.setAttrib( node, 'data-wp-replace-id', '' );
if ( node.nodeName === 'IMG' ) {
editor.selection.select( node );
} else {
editor.selection.select( editor.dom.select( 'img', node )[0] );
}
editor.nodeChanged();
}
function createImageAndLink( imageData, mode ) {
var classes = [],
props;
mode = mode ? mode : 'node';
if ( ! imageData.caption ) {
classes.push( 'align' + imageData.align );
}
if ( imageData.attachment_id ) {
classes.push( 'wp-image-' + imageData.attachment_id );
if ( imageData.size ) {
classes.push( 'size-' + imageData.size );
}
}
props = {
src: imageData.url,
width: imageData.width,
height: imageData.height,
alt: imageData.alt
};
if ( classes.length ) {
props['class'] = classes.join( ' ' );
}
if ( imageData.linkUrl ) {
if ( mode === 'node' ) {
return editor.dom.create( 'a', { href: imageData.linkUrl }, editor.dom.createHTML( 'img', props ) );
} else if ( mode === 'html' ) {
return editor.dom.createHTML( 'a', { href: imageData.linkUrl }, editor.dom.createHTML( 'img', props ) );
}
} else {
if ( mode === 'node' ) {
return editor.dom.create( 'img', props );
} else if ( mode === 'html' ) {
return editor.dom.createHTML( 'img', props );
}
}
}
editor.on( 'init', function() {
var dom = editor.dom;
@ -452,6 +622,40 @@ tinymce.PluginManager.add( 'wpeditimage', function( editor ) {
}
});
editor.on( 'mousedown', function( e ) {
var imageNode, frame, callback;
if ( e.target.nodeName === 'IMG' && editor.selection.getNode() === e.target ) {
// Don't trigger on right-click
if ( e.button !== 2 ) {
// Don't attempt to edit placeholders
if ( editor.dom.hasClass( e.target, 'mceItem' ) || '1' === editor.dom.getAttrib( e.target, 'data-mce-placeholder' ) ) {
return;
}
imageNode = e.target;
frame = wp.media({
frame: 'image',
state: 'image-details',
metadata: extractImageData( imageNode )
} );
callback = function( imageData ) {
updateImage( imageNode, imageData );
editor.focus();
};
frame.state('image-details').on( 'update', callback );
frame.state('replace-image').on( 'replace', callback );
frame.open();
}
}
} );
editor.wpSetImgCaption = function( content ) {
return parseShortcode( content );
};

View File

@ -500,6 +500,117 @@ function wp_print_media_templates() {
}
</style>
</script>
<script type="text/html" id="tmpl-image-details">
<?php // reusing .media-embed to pick up the styles for now ?>
<div class="media-embed">
<div class="embed-image-settings">
<div class="thumbnail">
<img src="{{ data.model.url }}" draggable="false" />
</div>
<div class="setting url">
<?php // might want to make the url editable if it isn't an attachment ?>
<input type="text" disabled="disabled" value="{{ data.model.url }}" />
</div>
<?php
/** This filter is documented in wp-admin/includes/media.php */
if ( ! apply_filters( 'disable_captions', '' ) ) : ?>
<label class="setting caption">
<span><?php _e('Caption'); ?></span>
<textarea data-setting="caption">{{ data.model.caption }}</textarea>
</label>
<?php endif; ?>
<label class="setting alt-text">
<span><?php _e('Alt Text'); ?></span>
<input type="text" data-setting="alt" value="{{ data.model.alt }}" />
</label>
<div class="setting align">
<span><?php _e('Align'); ?></span>
<div class="button-group button-large" data-setting="align">
<button class="button" value="left">
<?php esc_attr_e('Left'); ?>
</button>
<button class="button" value="center">
<?php esc_attr_e('Center'); ?>
</button>
<button class="button" value="right">
<?php esc_attr_e('Right'); ?>
</button>
<button class="button active" value="none">
<?php esc_attr_e('None'); ?>
</button>
</div>
</div>
<div class="setting link-to">
<span><?php _e('Link To'); ?></span>
<# if ( data.attachment ) { #>
<div class="button-group button-large" data-setting="link">
<button class="button" value="file">
<?php esc_attr_e('Media File'); ?>
</button>
<button class="button" value="post">
<?php esc_attr_e('Attachment Page'); ?>
</button>
<button class="button" value="custom">
<?php esc_attr_e('Custom URL'); ?>
</button>
<button class="button active" value="none">
<?php esc_attr_e('None'); ?>
</button>
</div>
<input type="text" class="link-to-custom" data-setting="linkUrl" />
<# } else { #>
<div class="button-group button-large" data-setting="link">
<button class="button" value="file">
<?php esc_attr_e('Image URL'); ?>
</button>
<button class="button" value="custom">
<?php esc_attr_e('Custom URL'); ?>
</button>
<button class="button active" value="none">
<?php esc_attr_e('None'); ?>
</button>
</div>
<input type="text" class="link-to-custom" data-setting="linkUrl" />
<# } #>
</div>
<# if ( data.attachment ) { #>
<div class="setting size">
<span><?php _e('Size'); ?></span>
<div class="button-group button-large" data-setting="size">
<?php
/** This filter is documented in wp-admin/includes/media.php */
$sizes = apply_filters( 'image_size_names_choose', array(
'thumbnail' => __('Thumbnail'),
'medium' => __('Medium'),
'large' => __('Large'),
'full' => __('Full Size'),
) );
foreach ( $sizes as $value => $name ) : ?>
<#
var size = data.attachment.sizes['<?php echo esc_js( $value ); ?>'];
if ( size ) { #>
<button class="button" value="<?php echo esc_attr( $value ); ?>">
<?php echo esc_html( $name ); ?>
</button>
<# } #>
<?php endforeach; ?>
</div>
</div>
<# } #>
</div>
</div>
</div>
</script>
<?php
/**

View File

@ -1967,6 +1967,8 @@ function wp_enqueue_media( $args = array() ) {
'search' => __( 'Search' ),
'select' => __( 'Select' ),
'cancel' => __( 'Cancel' ),
'update' => __( 'Update' ),
'replace' => __( 'Replace' ),
/* translators: This is a would-be plural string used in the media manager.
If there is not a word you can use in your language to avoid issues with the
lack of plural support here, turn it into "selected: %d" then translate it.
@ -2005,6 +2007,12 @@ function wp_enqueue_media( $args = array() ) {
'addToGallery' => __( 'Add to gallery' ),
'addToGalleryTitle' => __( 'Add to Gallery' ),
'reverseOrder' => __( 'Reverse order' ),
// Edit Image
'imageDetailsTitle' => __( 'Image Details' ),
'imageReplaceTitle' => __( 'Replace Image' ),
'imageDetailsCancel' => __( 'Cancel Edit' )
);
$settings = apply_filters( 'media_view_settings', $settings, $post );