/** * @output wp-includes/js/mce-view.js */ /* global tinymce */ /* * 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 * | |- ... */ ( function( window, wp, shortcode, $ ) { 'use strict'; var views = {}, instances = {}; wp.mce = wp.mce || {}; /** * wp.mce.views * * A set of utilities that simplifies adding custom UI within a TinyMCE editor. * At its core, it serves as a series of converters, transforming text to a * custom UI, and back again. */ wp.mce.views = { /** * 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, extend ) { views[ type ] = wp.mce.View.extend( _.extend( extend, { type: type } ) ); }, /** * Unregisters a view type. * * @param {String} type The view type. */ unregister: function( type ) { delete views[ type ]; }, /** * Returns the settings of a view type. * * @param {String} type The view type. * * @return {Function} The view constructor. */ get: function( type ) { return views[ type ]; }, /** * Unbinds all view nodes. * Runs before removing all view nodes from the DOM. */ unbind: function() { _.each( instances, function( instance ) { instance.unbind(); } ); }, /** * Scans a given string for each view's pattern, * replacing any matches with markers, * and creates a new instance for every match. * * @param {String} content The string to scan. * @param {tinymce.Editor} editor The editor. * * @return {String} The string with markers. */ setMarkers: function( content, editor ) { var pieces = [ { content: content } ], self = this, instance, current; _.each( views, function( view, type ) { current = pieces.slice(); pieces = []; _.each( current, function( piece ) { var remaining = piece.content, result, text; // Ignore processed pieces, but retain their location. if ( piece.processed ) { pieces.push( piece ); return; } // Iterate through the string progressively matching views // and slicing the string as we go. 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 ) } ); } result.options.editor = editor; instance = self.createInstance( type, result.content, result.options ); text = instance.loader ? '.' : instance.text; // Add the processed piece for the match. pieces.push( { content: instance.ignore ? text : '
' + text + '
', 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. if ( remaining ) { pieces.push( { content: remaining } ); } } ); } ); content = _.pluck( pieces, 'content' ).join( '' ); return content.replace( /\s*
' ); }, /** * Create a view instance. * * @param {String} type The view type. * @param {String} text The textual representation of the view. * @param {Object} options Options. * @param {Boolean} force Recreate the instance. Optional. * * @return {wp.mce.View} The view instance. */ createInstance: function( type, text, options, force ) { var View = this.get( type ), encodedText, instance; if ( text.indexOf( '[' ) !== -1 && text.indexOf( ']' ) !== -1 ) { // Looks like a shortcode? Remove any line breaks from inside of shortcodes // or autop will replace them with
 and 
 later and the string won't match.
				text = text.replace( /\[[^\]]+\]/g, function( match ) {
					return match.replace( /[\r\n]/g, '' );
				});
			}
			if ( ! force ) {
				instance = this.getInstance( text );
				if ( instance ) {
					return instance;
				}
			}
			encodedText = encodeURIComponent( text );
			options = _.extend( options || {}, {
				text: text,
				encodedText: encodedText
			} );
			return instances[ encodedText ] = new View( options );
		},
		/**
		 * Get a view instance.
		 *
		 * @param {(String|HTMLElement)} object The textual representation of the view or the view node.
		 *
		 * @return {wp.mce.View} The view instance or undefined.
		 */
		getInstance: function( object ) {
			if ( typeof object === 'string' ) {
				return instances[ encodeURIComponent( object ) ];
			}
			return instances[ $( object ).attr( 'data-wpview-text' ) ];
		},
		/**
		 * Given a view node, get the view's text.
		 *
		 * @param {HTMLElement} node The view node.
		 *
		 * @return {String} The textual representation of the view.
		 */
		getText: function( node ) {
			return decodeURIComponent( $( node ).attr( 'data-wpview-text' ) || '' );
		},
		/**
		 * Renders all view nodes that are not yet rendered.
		 *
		 * @param {Boolean} force Rerender all view nodes.
		 */
		render: function( force ) {
			_.each( instances, function( instance ) {
				instance.render( null, force );
			} );
		},
		/**
		 * 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.
		 * @param {Boolean}        force  Recreate the instance. Optional.
		 */
		update: function( text, editor, node, force ) {
			var instance = this.getInstance( node );
			if ( instance ) {
				instance.update( text, editor, node, force );
			}
		},
		/**
		 * 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 instance = this.getInstance( node );
			if ( instance && instance.edit ) {
				instance.edit( instance.text, function( text, force ) {
					instance.update( text, editor, node, force );
				} );
			}
		},
		/**
		 * Remove a given view node from the DOM.
		 *
		 * @param {tinymce.Editor} editor The TinyMCE editor instance the view node is in.
		 * @param {HTMLElement}    node   The view node to remove.
		 */
		remove: function( editor, node ) {
			var instance = this.getInstance( node );
			if ( instance ) {
				instance.remove( editor, node );
			}
		}
	};
	/**
	 * 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 Options.
	 */
	wp.mce.View = function( options ) {
		_.extend( this, options );
		this.initialize();
	};
	wp.mce.View.extend = Backbone.View.extend;
	_.extend( wp.mce.View.prototype, /** @lends wp.mce.View.prototype */{
		/**
		 * The content.
		 *
		 * @type {*}
		 */
		content: null,
		/**
		 * Whether or not to display a loader.
		 *
		 * @type {Boolean}
		 */
		loader: true,
		/**
		 * Runs after the view instance is created.
		 */
		initialize: function() {},
		/**
		 * Returns the content to render in the view node.
		 *
		 * @return {*}
		 */
		getContent: function() {
			return this.content;
		},
		/**
		 * Renders all view nodes tied to this view instance that are not yet rendered.
		 *
		 * @param {String}  content The content to render. Optional.
		 * @param {Boolean} force   Rerender all view nodes tied to this view instance. Optional.
		 */
		render: function( content, force ) {
			if ( content != null ) {
				this.content = content;
			}
			content = this.getContent();
			// If there's nothing to render an no loader needs to be shown, stop.
			if ( ! this.loader && ! content ) {
				return;
			}
			// We're about to rerender all views of this instance, so unbind rendered views.
			force && this.unbind();
			// Replace any left over markers.
			this.replaceMarkers();
			if ( content ) {
				this.setContent( content, function( editor, node ) {
					$( node ).data( 'rendered', true );
					this.bindNode.call( this, editor, node );
				}, force ? null : false );
			} else {
				this.setLoader();
			}
		},
		/**
		 * Binds a given node after its content is added to the DOM.
		 */
		bindNode: function() {},
		/**
		 * Unbinds a given node before its content is removed from the DOM.
		 */
		unbindNode: function() {},
		/**
		 * Unbinds all view nodes tied to this view instance.
		 * Runs before their content is removed from the DOM.
		 */
		unbind: function() {
			this.getNodes( function( editor, node ) {
				this.unbindNode.call( this, editor, node );
			}, true );
		},
		/**
		 * 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;
				$( editor.getBody() )
					.find( '[data-wpview-text="' + self.encodedText + '"]' )
					.filter( function() {
						var data;
						if ( rendered == null ) {
							return true;
						}
						data = $( this ).data( 'rendered' ) === true;
						return rendered ? data : ! data;
					} )
					.each( function() {
						callback.call( self, editor, this, this /* back compat */ );
					} );
			} );
		},
		/**
		 * Gets all marker nodes tied to this view instance.
		 *
		 * @param {Function} callback A callback.
		 */
		getMarkers: function( callback ) {
			this.getEditors( function( editor ) {
				var self = this;
				$( editor.getBody() )
					.find( '[data-wpview-marker="' + this.encodedText + '"]' )
					.each( function() {
						callback.call( self, editor, this );
					} );
			} );
		},
		/**
		 * Replaces all marker nodes tied to this view instance.
		 */
		replaceMarkers: function() {
			this.getMarkers( function( editor, node ) {
				var selected = node === editor.selection.getNode();
				var $viewNode;
				if ( ! this.loader && $( node ).text() !== tinymce.DOM.decode( this.text ) ) {
					editor.dom.setAttrib( node, 'data-wpview-marker', null );
					return;
				}
				$viewNode = editor.$(
					'