From 20f89d7c4beb2e8c22214a9d924810f4597f82d0 Mon Sep 17 00:00:00 2001 From: Andrew Ozz Date: Tue, 28 Jan 2014 21:16:42 +0000 Subject: [PATCH] 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 --- src/wp-includes/class-wp-editor.php | 1 - src/wp-includes/css/media-views.css | 16 +- src/wp-includes/js/media-models.js | 121 ++++++- src/wp-includes/js/media-views.js | 302 +++++++++++++++++- .../js/tinymce/plugins/wpeditimage/plugin.js | 204 ++++++++++++ src/wp-includes/media-template.php | 111 +++++++ src/wp-includes/media.php | 8 + 7 files changed, 755 insertions(+), 8 deletions(-) diff --git a/src/wp-includes/class-wp-editor.php b/src/wp-includes/class-wp-editor.php index dd59239751..fb36424093 100644 --- a/src/wp-includes/class-wp-editor.php +++ b/src/wp-includes/class-wp-editor.php @@ -228,7 +228,6 @@ final class _WP_Editors { 'paste', 'tabfocus', 'textcolor', - 'image', 'fullscreen', 'wordpress', 'wpeditimage', diff --git a/src/wp-includes/css/media-views.css b/src/wp-includes/css/media-views.css index 7fb7ffaf06..78a48ea274 100644 --- a/src/wp-includes/css/media-views.css +++ b/src/wp-includes/css/media-views.css @@ -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'); } -} \ No newline at end of file +} diff --git a/src/wp-includes/js/media-models.js b/src/wp-includes/js/media-models.js index 2144f33d29..6ea947fa41 100644 --- a/src/wp-includes/js/media-models.js +++ b/src/wp-includes/js/media-models.js @@ -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)); \ No newline at end of file +}(jQuery)); diff --git a/src/wp-includes/js/media-views.js b/src/wp-includes/js/media-views.js index 07b49c916c..3fa9ecd09b 100644 --- a/src/wp-includes/js/media-views.js +++ b/src/wp-includes/js/media-views.js @@ -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 + 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 + 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)); \ No newline at end of file + + 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)); diff --git a/src/wp-includes/js/tinymce/plugins/wpeditimage/plugin.js b/src/wp-includes/js/tinymce/plugins/wpeditimage/plugin.js index 3f4ca1036e..91392396ff 100644 --- a/src/wp-includes/js/tinymce/plugins/wpeditimage/plugin.js +++ b/src/wp-includes/js/tinymce/plugins/wpeditimage/plugin.js @@ -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 and the 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( /]*>/g, '$&\n' ).replace( /^

/, '' ).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 = '

' + + '
'+ html + '
'+ imageData.caption +'
'; + + 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 ); }; diff --git a/src/wp-includes/media-template.php b/src/wp-includes/media-template.php index 57d1c0087d..71e9c8282d 100644 --- a/src/wp-includes/media-template.php +++ b/src/wp-includes/media-template.php @@ -500,6 +500,117 @@ function wp_print_media_templates() { } + + __( '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 );