Index: src/wp-includes/js/mce-view.js
===================================================================
--- src/wp-includes/js/mce-view.js	(revision 31544)
+++ src/wp-includes/js/mce-view.js	(working copy)
@@ -1,279 +1,38 @@
 /* global tinymce */
-/**
+
+window.wp = window.wp || {};
+
+/*
+ * The TinyMCE view API.
+ *
  * Note: this API is "experimental" meaning that it will probably change
  * in the next few releases based on feedback from 3.9.0.
  * If you decide to use it, please follow the development closely.
+ *
+ * Diagram
+ *
+ * |- registered view constructor (type)
+ * |  |- view instance (unique text)
+ * |  |  |- editor 1
+ * |  |  |  |- view node
+ * |  |  |  |- view node
+ * |  |  |  |- ...
+ * |  |  |- editor 2
+ * |  |  |  |- ...
+ * |  |- view instance
+ * |  |  |- ...
+ * |- registered view
+ * |  |- ...
  */
-
-// Ensure the global `wp` object exists.
-window.wp = window.wp || {};
-
-( function( $ ) {
+( function( window, wp, $ ) {
 	'use strict';
 
 	var views = {},
-		instances = {},
-		media = wp.media,
-		mediaWindows = [],
-		windowIdx = 0,
-		waitInterval = 50,
-		viewOptions = ['encodedText'];
+		instances = {};
 
-	// Create the `wp.mce` object if necessary.
 	wp.mce = wp.mce || {};
 
 	/**
-	 * wp.mce.View
-	 *
-	 * A Backbone-like View constructor intended for use when rendering a TinyMCE View. The main difference is
-	 * that the TinyMCE View is not tied to a particular DOM node.
-	 *
-	 * @param {Object} [options={}]
-	 */
-	wp.mce.View = function( options ) {
-		options = options || {};
-		this.type = options.type;
-		_.extend( this, _.pick( options, viewOptions ) );
-		this.initialize.apply( this, arguments );
-	};
-
-	_.extend( wp.mce.View.prototype, {
-		initialize: function() {},
-		getHtml: function() {
-			return '';
-		},
-		loadingPlaceholder: function() {
-			return '' +
-				'<div class="loading-placeholder">' +
-					'<div class="dashicons dashicons-admin-media"></div>' +
-					'<div class="wpview-loading"><ins></ins></div>' +
-				'</div>';
-		},
-		render: function( force ) {
-			if ( force || ! this.rendered() ) {
-				this.unbind();
-
-				this.setContent(
-					'<p class="wpview-selection-before">\u00a0</p>' +
-					'<div class="wpview-body" contenteditable="false">' +
-						'<div class="toolbar mce-arrow-down">' +
-							( _.isFunction( views[ this.type ].edit ) ? '<div class="dashicons dashicons-edit edit"></div>' : '' ) +
-							'<div class="dashicons dashicons-no remove"></div>' +
-						'</div>' +
-						'<div class="wpview-content wpview-type-' + this.type + '">' +
-							( this.getHtml() || this.loadingPlaceholder() ) +
-						'</div>' +
-						( this.overlay ? '<div class="wpview-overlay"></div>' : '' ) +
-					'</div>' +
-					'<p class="wpview-selection-after">\u00a0</p>',
-					'wrap'
-				);
-
-				$( this ).trigger( 'ready' );
-
-				this.rendered( true );
-			}
-		},
-		unbind: function() {},
-		getEditors: function( callback ) {
-			var editors = [];
-
-			_.each( tinymce.editors, function( editor ) {
-				if ( editor.plugins.wpview ) {
-					if ( callback ) {
-						callback( editor );
-					}
-
-					editors.push( editor );
-				}
-			}, this );
-
-			return editors;
-		},
-		getNodes: function( callback ) {
-			var nodes = [],
-				self = this;
-
-			this.getEditors( function( editor ) {
-				$( editor.getBody() )
-				.find( '[data-wpview-text="' + self.encodedText + '"]' )
-				.each( function ( i, node ) {
-					if ( callback ) {
-						callback( editor, node, $( node ).find( '.wpview-content' ).get( 0 ) );
-					}
-
-					nodes.push( node );
-				} );
-			} );
-
-			return nodes;
-		},
-		setContent: function( html, option ) {
-			this.getNodes( function ( editor, node, content ) {
-				var el = ( option === 'wrap' || option === 'replace' ) ? node : content,
-					insert = html;
-
-				if ( _.isString( insert ) ) {
-					insert = editor.dom.createFragment( insert );
-				}
-
-				if ( option === 'replace' ) {
-					editor.dom.replace( insert, el );
-				} else {
-					el.innerHTML = '';
-					el.appendChild( insert );
-				}
-			} );
-		},
-		/* jshint scripturl: true */
-		setIframes: function ( head, body ) {
-			var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
-				importStyles = this.type === 'video' || this.type === 'audio' || this.type === 'playlist';
-
-			if ( head || body.indexOf( '<script' ) !== -1 ) {
-				this.getNodes( function ( editor, node, content ) {
-					var dom = editor.dom,
-						styles = '',
-						bodyClasses = editor.getBody().className || '',
-						iframe, iframeDoc, i, resize;
-
-					content.innerHTML = '';
-					head = head || '';
-
-					if ( importStyles ) {
-						if ( ! wp.mce.views.sandboxStyles ) {
-							tinymce.each( dom.$( 'link[rel="stylesheet"]', editor.getDoc().head ), function( link ) {
-								if ( link.href && link.href.indexOf( 'skins/lightgray/content.min.css' ) === -1 &&
-									link.href.indexOf( 'skins/wordpress/wp-content.css' ) === -1 ) {
-
-									styles += dom.getOuterHTML( link ) + '\n';
-								}
-							});
-
-							wp.mce.views.sandboxStyles = styles;
-						} else {
-							styles = wp.mce.views.sandboxStyles;
-						}
-					}
-
-					// Seems Firefox needs a bit of time to insert/set the view nodes, or the iframe will fail
-					// especially when switching Text => Visual.
-					setTimeout( function() {
-						iframe = dom.add( content, 'iframe', {
-							src: tinymce.Env.ie ? 'javascript:""' : '',
-							frameBorder: '0',
-							allowTransparency: 'true',
-							scrolling: 'no',
-							'class': 'wpview-sandbox',
-							style: {
-								width: '100%',
-								display: 'block'
-							}
-						} );
-
-						iframeDoc = iframe.contentWindow.document;
-
-						iframeDoc.open();
-						iframeDoc.write(
-							'<!DOCTYPE html>' +
-							'<html>' +
-								'<head>' +
-									'<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />' +
-									head +
-									styles +
-									'<style>' +
-										'html {' +
-											'background: transparent;' +
-											'padding: 0;' +
-											'margin: 0;' +
-										'}' +
-										'body#wpview-iframe-sandbox {' +
-											'background: transparent;' +
-											'padding: 1px 0 !important;' +
-											'margin: -1px 0 0 !important;' +
-										'}' +
-										'body#wpview-iframe-sandbox:before,' +
-										'body#wpview-iframe-sandbox:after {' +
-											'display: none;' +
-											'content: "";' +
-										'}' +
-									'</style>' +
-								'</head>' +
-								'<body id="wpview-iframe-sandbox" class="' + bodyClasses + '">' +
-									body +
-								'</body>' +
-							'</html>'
-						);
-						iframeDoc.close();
-
-						resize = function() {
-							var $iframe, iframeDocHeight;
-
-							// Make sure the iframe still exists.
-							if ( iframe.contentWindow ) {
-								$iframe = $( iframe );
-								iframeDocHeight = $( iframeDoc.body ).height();
-
-								if ( $iframe.height() !== iframeDocHeight ) {
-									$iframe.height( iframeDocHeight );
-									editor.nodeChanged();
-								}
-							}
-						};
-
-						if ( MutationObserver ) {
-							new MutationObserver( _.debounce( function() {
-								resize();
-							}, 100 ) )
-							.observe( iframeDoc.body, {
-								attributes: true,
-								childList: true,
-								subtree: true
-							} );
-						} else {
-							for ( i = 1; i < 6; i++ ) {
-								setTimeout( resize, i * 700 );
-							}
-						}
-
-						if ( importStyles ) {
-							editor.on( 'wp-body-class-change', function() {
-								iframeDoc.body.className = editor.getBody().className;
-							});
-						}
-					}, waitInterval );
-				});
-			} else {
-				this.setContent( body );
-			}
-		},
-		setError: function( message, dashicon ) {
-			this.setContent(
-				'<div class="wpview-error">' +
-					'<div class="dashicons dashicons-' + ( dashicon ? dashicon : 'no' ) + '"></div>' +
-					'<p>' + message + '</p>' +
-				'</div>'
-			);
-		},
-		rendered: function( value ) {
-			var notRendered;
-
-			this.getNodes( function( editor, node ) {
-				if ( value != null ) {
-					$( node ).data( 'rendered', value === true );
-				} else {
-					notRendered = notRendered || ! $( node ).data( 'rendered' );
-				}
-			} );
-
-			return ! notRendered;
-		}
-	} );
-
-	// take advantage of the Backbone extend method
-	wp.mce.View.extend = Backbone.View.extend;
-
-	/**
 	 * wp.mce.views
 	 *
 	 * A set of utilities that simplifies adding custom UI within a TinyMCE editor.
@@ -283,67 +42,36 @@
 	wp.mce.views = {
 
 		/**
-		 * wp.mce.views.register( type, view )
-		 *
-		 * Registers a new TinyMCE view.
-		 *
-		 * @param type
-		 * @param constructor
+		 * Registers a new view type.
 		 *
+		 * @param {String} type   The view type.
+		 * @param {Object} extend An object to extend wp.mce.View.prototype with.
 		 */
-		register: function( type, constructor ) {
-			var defaultConstructor = {
-					type: type,
-					View: {},
-					toView: function( content ) {
-						var match = wp.shortcode.next( this.type, content );
-
-						if ( ! match ) {
-							return;
-						}
-
-						return {
-							index: match.index,
-							content: match.content,
-							options: {
-								shortcode: match.shortcode
-							}
-						};
-					}
-				};
-
-			constructor = _.defaults( constructor, defaultConstructor );
-			constructor.View = wp.mce.View.extend( constructor.View );
-
-			views[ type ] = constructor;
+		register: function( type, extend ) {
+			views[ type ] = wp.mce.View.extend( _.extend( extend, { type: type } ) );
 		},
 
 		/**
-		 * wp.mce.views.get( id )
+		 * Unregisters a view type.
 		 *
-		 * Returns a TinyMCE view constructor.
-		 *
-		 * @param type
+		 * @param {String} type The view type.
 		 */
-		get: function( type ) {
-			return views[ type ];
+		unregister: function( type ) {
+			delete views[ type ];
 		},
 
 		/**
-		 * wp.mce.views.unregister( type )
+		 * Returns the settings of a view type.
 		 *
-		 * Unregisters a TinyMCE view.
-		 *
-		 * @param type
+		 * @param {String} type The view type.
 		 */
-		unregister: function( type ) {
-			delete views[ type ];
+		get: function( type ) {
+			return views[ type ];
 		},
 
 		/**
-		 * wp.mce.views.unbind( editor )
-		 *
-		 * The editor DOM is being rebuilt, run cleanup.
+		 * Unbinds all view nodes.
+		 * Runs before removing all view nodes from the DOM.
 		 */
 		unbind: function() {
 			_.each( instances, function( instance ) {
@@ -352,18 +80,18 @@
 		},
 
 		/**
-		 * toViews( content )
-		 * Scans a `content` string for each view's pattern, replacing any
-		 * matches with wrapper elements, and creates a new instance for
-		 * every match, which triggers the related data to be fetched.
+		 * Scans a given string for each view's pattern,
+		 * replacing any matches with markers,
+		 * and creates a new instance for every match.
 		 *
-		 * @param content
+		 * @param {String} content The string to scan.
 		 */
-		toViews: function( content ) {
+		setMarkers: function( content ) {
 			var pieces = [ { content: content } ],
+				self = this,
 				current;
 
-			_.each( views, function( view, viewType ) {
+			_.each( views, function( view, type ) {
 				current = pieces.slice();
 				pieces  = [];
 
@@ -379,110 +107,72 @@
 
 					// Iterate through the string progressively matching views
 					// and slicing the string as we go.
-					while ( remaining && (result = view.toView( remaining )) ) {
+					while ( remaining && ( result = view.prototype.match( remaining ) ) ) {
 						// Any text before the match becomes an unprocessed piece.
 						if ( result.index ) {
-							pieces.push({ content: remaining.substring( 0, result.index ) });
+							pieces.push( { content: remaining.substring( 0, result.index ) } );
 						}
 
+						self.createInstance( type, result.content, result.options );
+
 						// Add the processed piece for the match.
-						pieces.push({
-							content: wp.mce.views.toView( viewType, result.content, result.options ),
+						pieces.push( {
+							content: '<p data-wpview-marker="' + encodeURIComponent( result.content ) + '">' + result.content + '</p>',
 							processed: true
-						});
+						} );
 
 						// Update the remaining content.
 						remaining = remaining.slice( result.index + result.content.length );
 					}
 
-					// There are no additional matches. If any content remains,
-					// add it as an unprocessed piece.
+					// There are no additional matches.
+					// If any content remains, add it as an unprocessed piece.
 					if ( remaining ) {
-						pieces.push({ content: remaining });
+						pieces.push( { content: remaining } );
 					}
-				});
-			});
+				} );
+			} );
 
-			return _.pluck( pieces, 'content' ).join('');
+			return _.pluck( pieces, 'content' ).join( '' );
 		},
 
 		/**
-		 * Create a placeholder for a particular view type
-		 *
-		 * @param viewType
-		 * @param text
-		 * @param options
+		 * Create a view instance.
 		 *
+		 * @param {String} type    The view type.
+		 * @param {String} text    The textual representation of the view.
+		 * @param {Object} options Options.
 		 */
-		toView: function( viewType, text, options ) {
-			var view = wp.mce.views.get( viewType ),
-				encodedText = window.encodeURIComponent( text ),
-				instance, viewOptions;
-
-
-			if ( ! view ) {
-				return text;
+		createInstance: function( type, text, options ) {
+			var View = this.get( type ),
+				encodedText = encodeURIComponent( text ),
+				instance = this.getInstance( encodedText );
+
+			if ( instance ) {
+				return instance;
 			}
 
-			if ( ! wp.mce.views.getInstance( encodedText ) ) {
-				viewOptions = options;
-				viewOptions.type = viewType;
-				viewOptions.encodedText = encodedText;
-				instance = new view.View( viewOptions );
-				instances[ encodedText ] = instance;
-			}
-
-			return wp.html.string({
-				tag: 'div',
-
-				attrs: {
-					'class': 'wpview-wrap',
-					'data-wpview-text': encodedText,
-					'data-wpview-type': viewType
-				},
+			options = _.extend( options || {}, {
+				text: text,
+				encodedText: encodedText
+			} );
 
-				content: '\u00a0'
-			});
+			return instances[ encodedText ] = new View( options );
 		},
 
 		/**
-		 * Refresh views after an update is made
+		 * Get a view instance.
 		 *
-		 * @param view {object} being refreshed
-		 * @param text {string} textual representation of the view
-		 * @param force {Boolean} whether to force rendering
+		 * @param {String} text The textual representation of the view.
 		 */
-		refreshView: function( view, text, force ) {
-			var encodedText = window.encodeURIComponent( text ),
-				viewOptions,
-				result, instance;
-
-			instance = wp.mce.views.getInstance( encodedText );
-
-			if ( ! instance ) {
-				result = view.toView( text );
-				viewOptions = result.options;
-				viewOptions.type = view.type;
-				viewOptions.encodedText = encodedText;
-				instance = new view.View( viewOptions );
-				instances[ encodedText ] = instance;
-			}
-
-			instance.render( force );
-		},
-
-		getInstance: function( encodedText ) {
-			return instances[ encodedText ];
+		getInstance: function( text ) {
+			return instances[ encodeURIComponent( text ) ];
 		},
 
 		/**
-		 * render( scope )
+		 * Renders all view nodes that are not yet rendered.
 		 *
-		 * Renders any view instances inside a DOM node `scope`.
-		 *
-		 * View instances are detected by the presence of wrapper elements.
-		 * To generate wrapper elements, pass your content through
-		 * `wp.mce.view.toViews( content )`.
+		 * @param {Boolean} force Rerender all view nodes.
 		 */
 		render: function( force ) {
 			_.each( instances, function( instance ) {
@@ -490,408 +180,645 @@
 			} );
 		},
 
-		edit: function( node ) {
-			var viewType = $( node ).data('wpview-type'),
-				view = wp.mce.views.get( viewType );
+		/**
+		 * Update the text of a given view node.
+		 *
+		 * @param {String}         text   The new text.
+		 * @param {tinymce.Editor} editor The TinyMCE editor instance the view node is in.
+		 * @param {HTMLElement}    node   The view node to update.
+		 */
+		update: function( text, editor, node ) {
+			var oldText = decodeURIComponent( $( node ).data( 'wpview-text' ) ),
+				instance = this.getInstance( oldText );
 
-			if ( view ) {
-				view.edit( node );
+			if ( instance ) {
+				instance.update( text, editor, node );
+			}
+		},
+
+		/**
+		 * Renders any editing interface based on the view type.
+		 *
+		 * @param {tinymce.Editor} editor The TinyMCE editor instance the view node is in.
+		 * @param {HTMLElement}    node   The view node to edit.
+		 */
+		edit: function( editor, node ) {
+			var text = decodeURIComponent( $( node ).data( 'wpview-text' ) ),
+				instance = this.getInstance( text );
+
+			if ( instance && instance.edit ) {
+				instance.edit( text, function( text ) {
+					instance.update( text, editor, node );
+				} );
 			}
 		}
 	};
 
-	wp.mce.views.register( 'gallery', {
-		View: {
-			template: media.template( 'editor-gallery' ),
-
-			// The fallback post ID to use as a parent for galleries that don't
-			// specify the `ids` or `include` parameters.
-			//
-			// Uses the hidden input on the edit posts page by default.
-			postID: $('#post_ID').val(),
-
-			initialize: function( options ) {
-				this.shortcode = options.shortcode;
-				this.fetch();
-			},
+	/**
+	 * A Backbone-like View constructor intended for use when rendering a TinyMCE View.
+	 * The main difference is that the TinyMCE View is not tied to a particular DOM node.
+	 *
+	 * @param {Object} Options.
+	 */
+	wp.mce.View = function( options ) {
+		_.extend( this, options );
+		this.initialize();
+	};
 
-			fetch: function() {
-				var self = this;
+	wp.mce.View.extend = Backbone.View.extend;
 
-				this.attachments = wp.media.gallery.attachments( this.shortcode, this.postID );
-				this.dfd = this.attachments.more().done( function() {
-					self.render( true );
-				} );
-			},
+	_.extend( wp.mce.View.prototype, {
 
-			getHtml: function() {
-				var attrs = this.shortcode.attrs.named,
-					attachments = false,
-					options;
-
-				// Don't render errors while still fetching attachments
-				if ( this.dfd && 'pending' === this.dfd.state() && ! this.attachments.length ) {
-					return '';
-				}
+		/**
+		 * The content.
+		 *
+		 * @type {*}
+		 */
+		content: null,
 
-				if ( this.attachments.length ) {
-					attachments = this.attachments.toJSON();
+		/**
+		 * Whether or not to display a loader.
+		 *
+		 * @type {Boolean}
+		 */
+		loader: true,
 
-					_.each( attachments, function( attachment ) {
-						if ( attachment.sizes ) {
-							if ( attrs.size && attachment.sizes[ attrs.size ] ) {
-								attachment.thumbnail = attachment.sizes[ attrs.size ];
-							} else if ( attachment.sizes.thumbnail ) {
-								attachment.thumbnail = attachment.sizes.thumbnail;
-							} else if ( attachment.sizes.full ) {
-								attachment.thumbnail = attachment.sizes.full;
-							}
-						}
-					} );
-				}
+		/**
+		 * Runs after the view instance is created.
+		 */
+		initialize: function() {},
 
-				options = {
-					attachments: attachments,
-					columns: attrs.columns ? parseInt( attrs.columns, 10 ) : wp.media.galleryDefaults.columns
-				};
+		/**
+		 * Retuns the content to render in the view node.
+		 *
+		 * @return {*}
+		 */
+		getContent: function() {
+			return this.content;
+		},
 
-				return this.template( options );
+		/**
+		 * Renders all view nodes tied to this view instance that are not yet rendered.
+		 *
+		 * @param {Boolean} force Rerender all view nodes tied to this view instance.
+		 */
+		render: function( force ) {
+			// If there's nothing to render an no loader needs to be shown, stop.
+			if ( ! this.loader && ! this.getContent() ) {
+				return;
 			}
-		},
 
-		edit: function( node ) {
-			var gallery = wp.media.gallery,
-				self = this,
-				frame, data;
+			// We're about to rerender all views of this instance, so unbind rendered views.
+			force && this.unbind();
 
-			data = window.decodeURIComponent( $( node ).attr('data-wpview-text') );
-			frame = gallery.edit( data );
+			// Replace any left over markers.
+			this.replaceMarkers();
 
-			frame.state('gallery-edit').on( 'update', function( selection ) {
-				var shortcode = gallery.shortcode( selection ).string();
-				$( node ).attr( 'data-wpview-text', window.encodeURIComponent( shortcode ) );
-				wp.mce.views.refreshView( self, shortcode, true );
-			});
+			if ( this.getContent() ) {
+				this.setContent( this.getContent(), function( editor, node ) {
+					$( node ).data( 'rendered', true );
+					this.bindNodes.apply( this, arguments );
+				}, force ? null : false );
+			} else {
+				this.setLoader();
+			}
+		},
 
-			frame.on( 'close', function() {
-				frame.detach();
-			});
-		}
-	} );
+		/**
+		 * Binds a given rendered view node.
+		 * Runs after a view node's content is added to the DOM.
+		 *
+		 * @param {tinymce.Editor} editor      The TinyMCE editor instance the view node is in.
+		 * @param {HTMLElement}    node        The view node.
+		 * @param {HTMLElement}    contentNode The view's content node.
+		 */
+		bindNodes: function( /* editor, node, contentNode */ ) {},
 
-	/**
-	 * These are base methods that are shared by the audio and video shortcode's MCE controller.
-	 *
-	 * @mixin
-	 */
-	wp.mce.av = {
-		View: {
-			overlay: true,
+		/**
+		 * Unbinds all view nodes tied to this view instance.
+		 * Runs before their content is removed from the DOM.
+		 */
+		unbind: function() {
+			this.getNodes( function() {
+				this.unbindNodes.apply( this, arguments );
+			}, true );
+		},
 
-			action: 'parse-media-shortcode',
+		/**
+		 * Unbinds a given view node.
+		 * Runs before the view node's content is removed from the DOM.
+		 *
+		 * @param {tinymce.Editor} editor      The TinyMCE editor instance the view node is in.
+		 * @param {HTMLElement}    node        The view node.
+		 * @param {HTMLElement}    contentNode The view's content node.
+		 */
+		unbindNodes: function( /* editor, node, contentNode */ ) {},
 
-			initialize: function( options ) {
+		/**
+		 * Gets all the TinyMCE editor instances that support views.
+		 *
+		 * @param {Function} callback A callback.
+		 */
+		getEditors: function( callback ) {
+			_.each( tinymce.editors, function( editor ) {
+				if ( editor.plugins.wpview ) {
+					callback.call( this, editor );
+				}
+			}, this );
+		},
+
+		/**
+		 * Gets all view nodes tied to this view instance.
+		 *
+		 * @param {Function} callback A callback.
+		 * @param {Boolean}  rendered Get (un)rendered view nodes. Optional.
+		 */
+		getNodes: function( callback, rendered ) {
+			this.getEditors( function( editor ) {
 				var self = this;
 
-				this.shortcode = options.shortcode;
+				$( editor.getBody() )
+					.find( '[data-wpview-text="' + self.encodedText + '"]' )
+					.filter( function() {
+						var data;
+
+						if ( rendered == null ) {
+							return true;
+						}
 
-				_.bindAll( this, 'setIframes', 'setNodes', 'fetch', 'stopPlayers' );
-				$( this ).on( 'ready', this.setNodes );
+						data = $( this ).data( 'rendered' ) === true;
 
-				$( document ).on( 'media:edit', this.stopPlayers );
+						return rendered ? data : ! data;
+					} )
+					.each( function() {
+						callback.call( self, editor, this, $( this ).find( '.wpview-content' ).get( 0 ) );
+					} );
+			} );
+		},
 
-				this.fetch();
+		/**
+		 * Gets all marker nodes tied to this view instance.
+		 *
+		 * @param {Function} callback A callback.
+		 */
+		getMarkers: function( callback ) {
+			this.getEditors( function( editor ) {
+				var self = this;
 
-				this.getEditors( function( editor ) {
-					editor.on( 'hide', function () {
-						mediaWindows = [];
-						windowIdx = 0;
-						self.stopPlayers();
+				$( editor.getBody() )
+					.find( '[data-wpview-marker="' + this.encodedText + '"]' )
+					.each( function() {
+						callback.call( self, editor, this );
 					} );
-				});
-			},
+			} );
+		},
 
-			pauseOtherWindows: function ( win ) {
-				_.each( mediaWindows, function ( mediaWindow ) {
-					if ( mediaWindow.sandboxId !== win.sandboxId ) {
-						_.each( mediaWindow.mejs.players, function ( player ) {
-							player.pause();
-						} );
-					}
-				} );
-			},
+		/**
+		 * Replaces all marker nodes tied to this view instance.
+		 */
+		replaceMarkers: function() {
+			this.getMarkers( function( editor, node ) {
+				if ( $( node ).text() !== this.text ) {
+					editor.dom.setAttrib( node, 'data-wpview-marker', null );
+					return;
+				}
 
-			iframeLoaded: function (win) {
-				return _.bind( function () {
-					var callback;
-					if ( ! win.mejs || _.isEmpty( win.mejs.players ) ) {
-						return;
-					}
+				editor.dom.replace(
+					editor.dom.createFragment(
+						'<div class="wpview-wrap" data-wpview-text="' + this.encodedText + '" data-wpview-type="' + this.type + '">' +
+							'<p class="wpview-selection-before">\u00a0</p>' +
+							'<div class="wpview-body" contenteditable="false">' +
+								'<div class="toolbar mce-arrow-down">' +
+									( this.edit ? '<div class="dashicons dashicons-edit edit"></div>' : '' ) +
+									'<div class="dashicons dashicons-no remove"></div>' +
+								'</div>' +
+								'<div class="wpview-content wpview-type-' + this.type + '"></div>' +
+							'</div>' +
+							'<p class="wpview-selection-after">\u00a0</p>' +
+						'</div>'
+					),
+					node
+				);
+			} );
+		},
 
-					win.sandboxId = windowIdx;
-					windowIdx++;
-					mediaWindows.push( win );
-
-					callback = _.bind( function () {
-						this.pauseOtherWindows( win );
-					}, this );
-
-					if ( ! _.isEmpty( win.mejs.MediaPluginBridge.pluginMediaElements ) ) {
-						_.each( win.mejs.MediaPluginBridge.pluginMediaElements, function ( mediaElement ) {
-							mediaElement.addEventListener( 'play', callback );
-						} );
-					}
+		/**
+		 * Removes all marker nodes tied to this view instance.
+		 */
+		removeMarkers: function() {
+			this.getMarkers( function( editor, node ) {
+				editor.dom.setAttrib( node, 'data-wpview-marker', null );
+			} );
+		},
+
+		/**
+		 * Sets the content for all view nodes tied to this view instance.
+		 *
+		 * @param {*}        content  The content to set.
+		 * @param {Function} callback A callback. Optional.
+		 * @param {Boolean}  rendered Only set for (un)rendered nodes. Optional.
+		 */
+		setContent: function( content, callback, rendered ) {
+			if ( _.isObject( content ) && content.body.indexOf( '<script' ) !== -1 ) {
+				this.setIframes( content.head, content.body, callback, rendered );
+			} else if ( _.isString( content ) && content.indexOf( '<script' ) !== -1 ) {
+				this.setIframes( null, content, callback, rendered );
+			} else {
+				this.getNodes( function( editor, node, contentNode ) {
+					content = content.body || content;
 
-					_.each( win.mejs.players, function ( player ) {
-						$( player.node ).on( 'play', callback );
-					}, this );
-				}, this );
-			},
-
-			listenToSandboxes: function () {
-				_.each( this.getNodes(), function ( node ) {
-					var win, iframe = $( '.wpview-sandbox', node ).get( 0 );
-					if ( iframe && ( win = iframe.contentWindow ) ) {
-						$( win ).load( _.bind( this.iframeLoaded( win ), this ) );
+					if ( content.indexOf( '<iframe' ) !== -1 ) {
+						content += '<div class="wpview-overlay"></div>';
 					}
-				}, this );
-			},
 
-			deferredListen: function () {
-				window.setTimeout( _.bind( this.listenToSandboxes, this ), this.getNodes().length * waitInterval );
-			},
-
-			setNodes: function () {
-				if ( this.parsed ) {
-					this.setIframes( this.parsed.head, this.parsed.body );
-					this.deferredListen();
-				} else {
-					this.fail();
-				}
-			},
+					contentNode.innerHTML = '';
+					contentNode.appendChild( _.isString( content ) ? editor.dom.createFragment( content ) : content );
 
-			fetch: function () {
-				var self = this;
+					callback && callback.apply( this, arguments );
+				}, rendered );
+			}
+		},
 
-				wp.ajax.send( this.action, {
-					data: {
-						post_ID: $( '#post_ID' ).val() || 0,
-						type: this.shortcode.tag,
-						shortcode: this.shortcode.string()
-					}
-				} )
-				.done( function( response ) {
-					if ( response ) {
-						self.parsed = response;
-						self.setIframes( response.head, response.body );
-						self.deferredListen();
-					} else {
-						self.fail( true );
-					}
-				} )
-				.fail( function( response ) {
-					self.fail( response || true );
-				} );
-			},
+		/**
+		 * Sets the content in an iframe for all view nodes tied to this view instance.
+		 *
+		 * @param {String}   head     HTML string to be added to the head of the document.
+		 * @param {String}   body     HTML string to be added to the body of the document.
+		 * @param {Function} callback A callback. Optional.
+		 * @param {Boolean}  rendered Only set for (un)rendered nodes. Optional.
+		 */
+		setIframes: function( head, body, callback, rendered ) {
+			var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
+				importStyles = this.type === 'video' || this.type === 'audio' || this.type === 'playlist';
 
-			fail: function( error ) {
-				if ( ! this.error ) {
-					if ( error ) {
-						this.error = error;
-					} else {
-						return;
-					}
-				}
+			this.getNodes( function( editor, node, content ) {
+				var dom = editor.dom,
+					styles = '',
+					bodyClasses = editor.getBody().className || '',
+					iframe, iframeDoc, i, resize;
+
+				content.innerHTML = '';
+				head = head || '';
+
+				if ( importStyles ) {
+					if ( ! wp.mce.views.sandboxStyles ) {
+						tinymce.each( dom.$( 'link[rel="stylesheet"]', editor.getDoc().head ), function( link ) {
+							if ( link.href && link.href.indexOf( 'skins/lightgray/content.min.css' ) === -1 &&
+								link.href.indexOf( 'skins/wordpress/wp-content.css' ) === -1 ) {
 
-				if ( this.error.message ) {
-					if ( ( this.error.type === 'not-embeddable' && this.type === 'embed' ) || this.error.type === 'not-ssl' ||
-						this.error.type === 'no-items' ) {
+								styles += dom.getOuterHTML( link ) + '\n';
+							}
+						});
 
-						this.setError( this.error.message, 'admin-media' );
+						wp.mce.views.sandboxStyles = styles;
 					} else {
-						this.setContent( '<p>' + this.original + '</p>', 'replace' );
+						styles = wp.mce.views.sandboxStyles;
 					}
-				} else if ( this.error.statusText ) {
-					this.setError( this.error.statusText, 'admin-media' );
-				} else if ( this.original ) {
-					this.setContent( '<p>' + this.original + '</p>', 'replace' );
 				}
-			},
 
-			stopPlayers: function( remove ) {
-				var rem = remove === 'remove';
+				// Seems Firefox needs a bit of time to insert/set the view nodes,
+				// or the iframe will fail especially when switching Text => Visual.
+				setTimeout( function() {
+					iframe = dom.add( content, 'iframe', {
+						/* jshint scripturl: true */
+						src: tinymce.Env.ie ? 'javascript:""' : '',
+						frameBorder: '0',
+						allowTransparency: 'true',
+						scrolling: 'no',
+						'class': 'wpview-sandbox',
+						style: {
+							width: '100%',
+							display: 'block'
+						}
+					} );
+
+					dom.add( content, 'div', { 'class': 'wpview-overlay' } );
 
-				this.getNodes( function( editor, node, content ) {
-					var p, win,
-						iframe = $( 'iframe.wpview-sandbox', content ).get(0);
+					iframeDoc = iframe.contentWindow.document;
 
-					if ( iframe && ( win = iframe.contentWindow ) && win.mejs ) {
-						// Sometimes ME.js may show a "Download File" placeholder and player.remove() doesn't exist there.
-						try {
-							for ( p in win.mejs.players ) {
-								win.mejs.players[p].pause();
+					iframeDoc.open();
 
-								if ( rem ) {
-									win.mejs.players[p].remove();
-								}
+					iframeDoc.write(
+						'<!DOCTYPE html>' +
+						'<html>' +
+							'<head>' +
+								'<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />' +
+								head +
+								styles +
+								'<style>' +
+									'html {' +
+										'background: transparent;' +
+										'padding: 0;' +
+										'margin: 0;' +
+									'}' +
+									'body#wpview-iframe-sandbox {' +
+										'background: transparent;' +
+										'padding: 1px 0 !important;' +
+										'margin: -1px 0 0 !important;' +
+									'}' +
+									'body#wpview-iframe-sandbox:before,' +
+									'body#wpview-iframe-sandbox:after {' +
+										'display: none;' +
+										'content: "";' +
+									'}' +
+								'</style>' +
+							'</head>' +
+							'<body id="wpview-iframe-sandbox" class="' + bodyClasses + '">' +
+								body +
+							'</body>' +
+						'</html>'
+					);
+
+					iframeDoc.close();
+
+					resize = function() {
+						var $iframe, iframeDocHeight;
+
+						// Make sure the iframe still exists.
+						if ( iframe.contentWindow ) {
+							$iframe = $( iframe );
+							iframeDocHeight = $( iframeDoc.body ).height();
+
+							if ( $iframe.height() !== iframeDocHeight ) {
+								$iframe.height( iframeDocHeight );
+								editor.nodeChanged();
 							}
-						} catch( er ) {}
+						}
+					};
+
+					if ( MutationObserver ) {
+						new MutationObserver( _.debounce( function() {
+							resize();
+						}, 100 ) )
+						.observe( iframeDoc.body, {
+							attributes: true,
+							childList: true,
+							subtree: true
+						} );
+					} else {
+						for ( i = 1; i < 6; i++ ) {
+							setTimeout( resize, i * 700 );
+						}
 					}
-				});
-			},
 
-			unbind: function() {
-				this.stopPlayers( 'remove' );
+					if ( importStyles ) {
+						editor.on( 'wp-body-class-change', function() {
+							iframeDoc.body.className = editor.getBody().className;
+						} );
+					}
+				}, 50 );
+
+				callback && callback.apply( this, arguments );
+			}, rendered );
+		},
+
+		/**
+		 * Sets a loader for all view nodes tied to this view instance.
+		 */
+		setLoader: function() {
+			this.setContent(
+				'<div class="loading-placeholder">' +
+					'<div class="dashicons dashicons-admin-media"></div>' +
+					'<div class="wpview-loading"><ins></ins></div>' +
+				'</div>'
+			);
+		},
+
+		/**
+		 * Sets an error for all view nodes tied to this view instance.
+		 *
+		 * @param {String} message  The error message to set.
+		 * @param {String} dashicon A dashicon ID (optional). {@link https://developer.wordpress.org/resource/dashicons/}
+		 */
+		setError: function( message, dashicon ) {
+			this.setContent(
+				'<div class="wpview-error">' +
+					'<div class="dashicons dashicons-' + ( dashicon || 'no' ) + '"></div>' +
+					'<p>' + message + '</p>' +
+				'</div>'
+			);
+		},
+
+		/**
+		 * Tries to find a text match in a given string.
+		 *
+		 * @param {String} content The string to scan.
+		 *
+		 * @return {Object}
+		 */
+		match: function( content ) {
+			var match = wp.shortcode.next( this.type, content );
+
+			if ( match ) {
+				return {
+					index: match.index,
+					content: match.content,
+					options: {
+						shortcode: match.shortcode
+					}
+				};
 			}
 		},
 
 		/**
-		 * Called when a TinyMCE view is clicked for editing.
-		 * - Parses the shortcode out of the element's data attribute
-		 * - Calls the `edit` method on the shortcode model
-		 * - Launches the model window
-		 * - Bind's an `update` callback which updates the element's data attribute
-		 *   re-renders the view
+		 * Update the text of a given view node.
 		 *
-		 * @param {HTMLElement} node
+		 * @param {String}         text   The new text.
+		 * @param {tinymce.Editor} editor The TinyMCE editor instance the view node is in.
+		 * @param {HTMLElement}    node   The view node to update.
 		 */
-		edit: function( node ) {
+		update: function( text, editor, node ) {
+			$( node ).data( 'rendered', false );
+			editor.dom.setAttrib( node, 'data-wpview-text', encodeURIComponent( text ) );
+			wp.mce.views.createInstance( this.type, text, this.match( text ).options ).render();
+		}
+	} );
+} )( window, window.wp, window.jQuery );
+
+/*
+ * The WordPress core TinyMCE views.
+ * Views for the gallery, audio, video, playlist and embed shortcodes,
+ * and a view for embeddable URLs.
+ */
+( function( window, views, $ ) {
+	var postID = $( '#post_ID' ).val() || 0,
+		media, gallery, av, embed;
+
+	media = {
+		state: [],
+
+		edit: function( text, update ) {
 			var media = wp.media[ this.type ],
-				self = this,
-				frame, data, callback;
+				frame = media.edit( text );
+
+			this.stopPlayers && this.stopPlayers();
 
-			$( document ).trigger( 'media:edit' );
+			_.each( this.state, function( state ) {
+				frame.state( state ).on( 'update', function( selection ) {
+					update( media.shortcode( selection ).string() );
+				} );
+			} );
 
-			data = window.decodeURIComponent( $( node ).attr('data-wpview-text') );
-			frame = media.edit( data );
 			frame.on( 'close', function() {
 				frame.detach();
 			} );
+		}
+	};
 
-			callback = function( selection ) {
-				var shortcode = wp.media[ self.type ].shortcode( selection ).string();
-				$( node ).attr( 'data-wpview-text', window.encodeURIComponent( shortcode ) );
-				wp.mce.views.refreshView( self, shortcode );
-				frame.detach();
-			};
-			if ( _.isArray( self.state ) ) {
-				_.each( self.state, function (state) {
-					frame.state( state ).on( 'update', callback );
+	gallery = _.extend( {}, media, {
+		state: [ 'gallery-edit' ],
+		template: wp.media.template( 'editor-gallery' ),
+
+		initialize: function() {
+			var attachments = wp.media.gallery.attachments( this.shortcode, postID ),
+				attrs = this.shortcode.attrs.named,
+				self = this;
+
+			attachments.more()
+			.done( function() {
+				attachments = attachments.toJSON();
+
+				_.each( attachments, function( attachment ) {
+					if ( attachment.sizes ) {
+						if ( attrs.size && attachment.sizes[ attrs.size ] ) {
+							attachment.thumbnail = attachment.sizes[ attrs.size ];
+						} else if ( attachment.sizes.thumbnail ) {
+							attachment.thumbnail = attachment.sizes.thumbnail;
+						} else if ( attachment.sizes.full ) {
+							attachment.thumbnail = attachment.sizes.full;
+						}
+					}
 				} );
-			} else {
-				frame.state( self.state ).on( 'update', callback );
-			}
-			frame.open();
+
+				self.content = self.template( {
+					attachments: attachments,
+					columns: attrs.columns ? parseInt( attrs.columns, 10 ) : wp.media.galleryDefaults.columns
+				} );
+
+				self.render();
+			} )
+			.fail( function( jqXHR, textStatus ) {
+				self.setError( textStatus );
+			} );
 		}
-	};
+	} );
 
-	/**
-	 * TinyMCE handler for the video shortcode
-	 *
-	 * @mixes wp.mce.av
-	 */
-	wp.mce.views.register( 'video', _.extend( {}, wp.mce.av, {
-		state: 'video-details'
-	} ) );
+	av = _.extend( {}, media, {
+		action: 'parse-media-shortcode',
 
-	/**
-	 * TinyMCE handler for the audio shortcode
-	 *
-	 * @mixes wp.mce.av
-	 */
-	wp.mce.views.register( 'audio', _.extend( {}, wp.mce.av, {
-		state: 'audio-details'
-	} ) );
+		initialize: function() {
+			var self = this;
 
-	/**
-	 * TinyMCE handler for the playlist shortcode
-	 *
-	 * @mixes wp.mce.av
-	 */
-	wp.mce.views.register( 'playlist', _.extend( {}, wp.mce.av, {
-		state: [ 'playlist-edit', 'video-playlist-edit' ]
-	} ) );
+			if ( this.url ) {
+				this.loader = false;
+				this.shortcode = wp.media.embed.shortcode( {
+					url: this.url
+				} );
+			}
 
-	/**
-	 * TinyMCE handler for the embed shortcode
-	 */
-	wp.mce.embedMixin = {
-		View: _.extend( {}, wp.mce.av.View, {
-			overlay: true,
-			action: 'parse-embed',
-			initialize: function( options ) {
-				this.content = options.content;
-				this.original = options.url || options.shortcode.string();
-
-				if ( options.url ) {
-					this.shortcode = media.embed.shortcode( {
-						url: options.url
-					} );
+			wp.ajax.send( this.action, {
+				data: {
+					post_ID: postID,
+					type: this.shortcode.tag,
+					shortcode: this.shortcode.string()
+				}
+			} )
+			.done( function( response ) {
+				self.content = response;
+				self.render();
+			} )
+			.fail( function( response ) {
+				if ( self.type === 'embedURL' ) {
+					self.removeMarkers();
 				} else {
-					this.shortcode = options.shortcode;
+					self.setError( response.message || response.statusText, 'admin-media' );
 				}
+			} );
 
-				_.bindAll( this, 'setIframes', 'setNodes', 'fetch' );
-				$( this ).on( 'ready', this.setNodes );
-
-				this.fetch();
-			}
-		} ),
-		edit: function( node ) {
-			var embed = media.embed,
-				self = this,
-				frame,
-				data,
-				isURL = 'embedURL' === this.type;
+			this.getEditors( function( editor ) {
+				editor.on( 'wpview-selected', function() {
+					self.stopPlayers();
+				} );
+			} );
+		},
 
-			$( document ).trigger( 'media:edit' );
+		stopPlayers: function( remove ) {
+			this.getNodes( function( editor, node, content ) {
+				var win = $( 'iframe.wpview-sandbox', content ).get( 0 );
 
-			data = window.decodeURIComponent( $( node ).attr('data-wpview-text') );
-			frame = embed.edit( data, isURL );
-			frame.on( 'close', function() {
-				frame.detach();
+				if ( win && ( win = win.contentWindow ) && win.mejs ) {
+					_.each( win.mejs.players, function( player ) {
+						try {
+							player[ remove ? 'remove' : 'pause' ]();
+						} catch ( e ) {}
+					} );
+				}
 			} );
-			frame.state( 'embed' ).props.on( 'change:url', function (model, url) {
-				if ( ! url ) {
-					return;
+		}
+	} );
+
+	embed = _.extend( {}, av, {
+		action: 'parse-embed',
+
+		edit: function( text, update ) {
+			var media = wp.media.embed,
+				isURL = 'embedURL' === this.type,
+				frame = media.edit( text, isURL );
+
+			this.stopPlayers();
+
+			frame.state( 'embed' ).props.on( 'change:url', function( model, url ) {
+				if ( url ) {
+					frame.state( 'embed' ).metadata = model.toJSON();
 				}
-				frame.state( 'embed' ).metadata = model.toJSON();
 			} );
-			frame.state( 'embed' ).on( 'select', function() {
-				var shortcode;
 
+			frame.state( 'embed' ).on( 'select', function() {
 				if ( isURL ) {
-					shortcode = frame.state( 'embed' ).metadata.url;
+					update( frame.state( 'embed' ).metadata.url );
 				} else {
-					shortcode = embed.shortcode( frame.state( 'embed' ).metadata ).string();
+					update( media.shortcode( frame.state( 'embed' ).metadata ).string() );
 				}
-				$( node ).attr( 'data-wpview-text', window.encodeURIComponent( shortcode ) );
-				wp.mce.views.refreshView( self, shortcode );
+			} );
+
+			frame.on( 'close', function() {
 				frame.detach();
 			} );
+
 			frame.open();
 		}
-	};
+	} );
+
+	views.register( 'gallery', _.extend( {}, gallery ) );
 
-	wp.mce.views.register( 'embed', _.extend( {}, wp.mce.embedMixin ) );
+	views.register( 'audio', _.extend( {}, av, {
+		state: [ 'audio-details' ]
+	} ) );
 
-	wp.mce.views.register( 'embedURL', _.extend( {}, wp.mce.embedMixin, {
-		toView: function( content ) {
+	views.register( 'video', _.extend( {}, av, {
+		state: [ 'video-details' ]
+	} ) );
+
+	views.register( 'playlist', _.extend( {}, av, {
+		state: [ 'playlist-edit', 'video-playlist-edit' ]
+	} ) );
+
+	views.register( 'embed', _.extend( {}, embed ) );
+
+	views.register( 'embedURL', _.extend( {}, embed, {
+		match: function( content ) {
 			var re = /(^|<p>)(https?:\/\/[^\s"]+?)(<\/p>\s*|$)/gi,
 				match = re.exec( tinymce.trim( content ) );
 
-			if ( ! match ) {
-				return;
+			if ( match ) {
+				return {
+					index: match.index + match[1].length,
+					content: match[2],
+					options: {
+						url: match[2]
+					}
+				};
 			}
-
-			return {
-				index: match.index + match[1].length,
-				content: match[2],
-				options: {
-					url: match[2]
-				}
-			};
 		}
 	} ) );
-
-}(jQuery));
+} )( window, window.wp.mce.views, window.jQuery );
Index: src/wp-includes/js/tinymce/plugins/wpview/plugin.js
===================================================================
--- src/wp-includes/js/tinymce/plugins/wpview/plugin.js	(revision 31544)
+++ src/wp-includes/js/tinymce/plugins/wpview/plugin.js	(working copy)
@@ -237,7 +237,7 @@
 			return;
 		}
 
-		event.content = wp.mce.views.toViews( event.content );
+		event.content = wp.mce.views.setMarkers( event.content );
 	});
 
 	// When the editor's content has been updated and the DOM has been
@@ -341,7 +341,7 @@
 							editor.focus();
 						}
 
-						wp.mce.views.edit( view );
+						wp.mce.views.edit( editor, view );
 						return false;
 					} else if ( editor.dom.hasClass( event.target, 'remove' ) ) {
 						removeView( view );
Index: src/wp-includes/media-template.php
===================================================================
--- src/wp-includes/media-template.php	(revision 31544)
+++ src/wp-includes/media-template.php	(working copy)
@@ -1205,7 +1205,7 @@
 	</script>
 
 	<script type="text/html" id="tmpl-editor-gallery">
-		<# if ( data.attachments ) { #>
+		<# if ( data.attachments.length ) { #>
 			<div class="gallery gallery-columns-{{ data.columns }}">
 				<# _.each( data.attachments, function( attachment, index ) { #>
 					<dl class="gallery-item">
