3 * Note: this API is "experimental" meaning that it will probably change
4 * in the next few releases based on feedback from 3.9.0.
5 * If you decide to use it, please follow the development closely.
8 // Ensure the global `wp` object exists.
9 window.wp = window.wp || {};
20 viewOptions = ['encodedText'];
22 // Create the `wp.mce` object if necessary.
23 wp.mce = wp.mce || {};
28 * A Backbone-like View constructor intended for use when rendering a TinyMCE View. The main difference is
29 * that the TinyMCE View is not tied to a particular DOM node.
31 * @param {Object} [options={}]
33 wp.mce.View = function( options ) {
34 options = options || {};
35 this.type = options.type;
36 _.extend( this, _.pick( options, viewOptions ) );
37 this.initialize.apply( this, arguments );
40 _.extend( wp.mce.View.prototype, {
41 initialize: function() {},
45 loadingPlaceholder: function() {
47 '<div class="loading-placeholder">' +
48 '<div class="dashicons dashicons-admin-media"></div>' +
49 '<div class="wpview-loading"><ins></ins></div>' +
52 render: function( force ) {
53 if ( force || ! this.rendered() ) {
57 '<p class="wpview-selection-before">\u00a0</p>' +
58 '<div class="wpview-body" contenteditable="false">' +
59 '<div class="toolbar mce-arrow-down">' +
60 ( _.isFunction( views[ this.type ].edit ) ? '<div class="dashicons dashicons-edit edit"></div>' : '' ) +
61 '<div class="dashicons dashicons-no remove"></div>' +
63 '<div class="wpview-content wpview-type-' + this.type + '">' +
64 ( this.getHtml() || this.loadingPlaceholder() ) +
66 ( this.overlay ? '<div class="wpview-overlay"></div>' : '' ) +
68 '<p class="wpview-selection-after">\u00a0</p>',
72 $( this ).trigger( 'ready' );
74 this.rendered( true );
77 unbind: function() {},
78 getEditors: function( callback ) {
81 _.each( tinymce.editors, function( editor ) {
82 if ( editor.plugins.wpview ) {
87 editors.push( editor );
93 getNodes: function( callback ) {
97 this.getEditors( function( editor ) {
99 .find( '[data-wpview-text="' + self.encodedText + '"]' )
100 .each( function ( i, node ) {
102 callback( editor, node, $( node ).find( '.wpview-content' ).get( 0 ) );
111 setContent: function( html, option ) {
112 this.getNodes( function ( editor, node, content ) {
113 var el = ( option === 'wrap' || option === 'replace' ) ? node : content,
116 if ( _.isString( insert ) ) {
117 insert = editor.dom.createFragment( insert );
120 if ( option === 'replace' ) {
121 editor.dom.replace( insert, el );
124 el.appendChild( insert );
128 /* jshint scripturl: true */
129 setIframes: function ( head, body ) {
130 var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
131 importStyles = this.type === 'video' || this.type === 'audio' || this.type === 'playlist';
133 if ( head || body.indexOf( '<script' ) !== -1 ) {
134 this.getNodes( function ( editor, node, content ) {
135 var dom = editor.dom,
137 bodyClasses = editor.getBody().className || '',
138 iframe, iframeDoc, i, resize;
140 content.innerHTML = '';
143 if ( importStyles ) {
144 if ( ! wp.mce.views.sandboxStyles ) {
145 tinymce.each( dom.$( 'link[rel="stylesheet"]', editor.getDoc().head ), function( link ) {
146 if ( link.href && link.href.indexOf( 'skins/lightgray/content.min.css' ) === -1 &&
147 link.href.indexOf( 'skins/wordpress/wp-content.css' ) === -1 ) {
149 styles += dom.getOuterHTML( link ) + '\n';
153 wp.mce.views.sandboxStyles = styles;
155 styles = wp.mce.views.sandboxStyles;
159 // Seems Firefox needs a bit of time to insert/set the view nodes, or the iframe will fail
160 // especially when switching Text => Visual.
161 setTimeout( function() {
162 iframe = dom.add( content, 'iframe', {
163 src: tinymce.Env.ie ? 'javascript:""' : '',
165 allowTransparency: 'true',
167 'class': 'wpview-sandbox',
174 iframeDoc = iframe.contentWindow.document;
181 '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />' +
186 'background: transparent;' +
190 'body#wpview-iframe-sandbox {' +
191 'background: transparent;' +
192 'padding: 1px 0 !important;' +
193 'margin: -1px 0 0 !important;' +
195 'body#wpview-iframe-sandbox:before,' +
196 'body#wpview-iframe-sandbox:after {' +
202 '<body id="wpview-iframe-sandbox" class="' + bodyClasses + '">' +
209 resize = function() {
210 // Make sure the iframe still exists.
211 iframe.contentWindow && $( iframe ).height( $( iframeDoc.body ).height() );
214 if ( MutationObserver ) {
215 new MutationObserver( _.debounce( function() {
218 .observe( iframeDoc.body, {
224 for ( i = 1; i < 6; i++ ) {
225 setTimeout( resize, i * 700 );
229 if ( importStyles ) {
230 editor.on( 'wp-body-class-change', function() {
231 iframeDoc.body.className = editor.getBody().className;
237 this.setContent( body );
240 setError: function( message, dashicon ) {
242 '<div class="wpview-error">' +
243 '<div class="dashicons dashicons-' + ( dashicon ? dashicon : 'no' ) + '"></div>' +
244 '<p>' + message + '</p>' +
248 rendered: function( value ) {
251 this.getNodes( function( editor, node ) {
252 if ( value != null ) {
253 $( node ).data( 'rendered', value === true );
255 notRendered = notRendered || ! $( node ).data( 'rendered' );
259 return ! notRendered;
263 // take advantage of the Backbone extend method
264 wp.mce.View.extend = Backbone.View.extend;
269 * A set of utilities that simplifies adding custom UI within a TinyMCE editor.
270 * At its core, it serves as a series of converters, transforming text to a
271 * custom UI, and back again.
276 * wp.mce.views.register( type, view )
278 * Registers a new TinyMCE view.
284 register: function( type, constructor ) {
285 var defaultConstructor = {
288 toView: function( content ) {
289 var match = wp.shortcode.next( this.type, content );
297 content: match.content,
299 shortcode: match.shortcode
305 constructor = _.defaults( constructor, defaultConstructor );
306 constructor.View = wp.mce.View.extend( constructor.View );
308 views[ type ] = constructor;
312 * wp.mce.views.get( id )
314 * Returns a TinyMCE view constructor.
318 get: function( type ) {
319 return views[ type ];
323 * wp.mce.views.unregister( type )
325 * Unregisters a TinyMCE view.
329 unregister: function( type ) {
330 delete views[ type ];
334 * wp.mce.views.unbind( editor )
336 * The editor DOM is being rebuilt, run cleanup.
339 _.each( instances, function( instance ) {
346 * Scans a `content` string for each view's pattern, replacing any
347 * matches with wrapper elements, and creates a new instance for
348 * every match, which triggers the related data to be fetched.
352 toViews: function( content ) {
353 var pieces = [ { content: content } ],
356 _.each( views, function( view, viewType ) {
357 current = pieces.slice();
360 _.each( current, function( piece ) {
361 var remaining = piece.content,
364 // Ignore processed pieces, but retain their location.
365 if ( piece.processed ) {
366 pieces.push( piece );
370 // Iterate through the string progressively matching views
371 // and slicing the string as we go.
372 while ( remaining && (result = view.toView( remaining )) ) {
373 // Any text before the match becomes an unprocessed piece.
374 if ( result.index ) {
375 pieces.push({ content: remaining.substring( 0, result.index ) });
378 // Add the processed piece for the match.
380 content: wp.mce.views.toView( viewType, result.content, result.options ),
384 // Update the remaining content.
385 remaining = remaining.slice( result.index + result.content.length );
388 // There are no additional matches. If any content remains,
389 // add it as an unprocessed piece.
391 pieces.push({ content: remaining });
396 return _.pluck( pieces, 'content' ).join('');
400 * Create a placeholder for a particular view type
407 toView: function( viewType, text, options ) {
408 var view = wp.mce.views.get( viewType ),
409 encodedText = window.encodeURIComponent( text ),
410 instance, viewOptions;
417 if ( ! wp.mce.views.getInstance( encodedText ) ) {
418 viewOptions = options;
419 viewOptions.type = viewType;
420 viewOptions.encodedText = encodedText;
421 instance = new view.View( viewOptions );
422 instances[ encodedText ] = instance;
425 return wp.html.string({
429 'class': 'wpview-wrap',
430 'data-wpview-text': encodedText,
431 'data-wpview-type': viewType
439 * Refresh views after an update is made
441 * @param view {object} being refreshed
442 * @param text {string} textual representation of the view
443 * @param force {Boolean} whether to force rendering
445 refreshView: function( view, text, force ) {
446 var encodedText = window.encodeURIComponent( text ),
450 instance = wp.mce.views.getInstance( encodedText );
453 result = view.toView( text );
454 viewOptions = result.options;
455 viewOptions.type = view.type;
456 viewOptions.encodedText = encodedText;
457 instance = new view.View( viewOptions );
458 instances[ encodedText ] = instance;
461 instance.render( force );
464 getInstance: function( encodedText ) {
465 return instances[ encodedText ];
471 * Renders any view instances inside a DOM node `scope`.
473 * View instances are detected by the presence of wrapper elements.
474 * To generate wrapper elements, pass your content through
475 * `wp.mce.view.toViews( content )`.
477 render: function( force ) {
478 _.each( instances, function( instance ) {
479 instance.render( force );
483 edit: function( node ) {
484 var viewType = $( node ).data('wpview-type'),
485 view = wp.mce.views.get( viewType );
493 wp.mce.views.register( 'gallery', {
495 template: media.template( 'editor-gallery' ),
497 // The fallback post ID to use as a parent for galleries that don't
498 // specify the `ids` or `include` parameters.
500 // Uses the hidden input on the edit posts page by default.
501 postID: $('#post_ID').val(),
503 initialize: function( options ) {
504 this.shortcode = options.shortcode;
511 this.attachments = wp.media.gallery.attachments( this.shortcode, this.postID );
512 this.dfd = this.attachments.more().done( function() {
517 getHtml: function() {
518 var attrs = this.shortcode.attrs.named,
522 // Don't render errors while still fetching attachments
523 if ( this.dfd && 'pending' === this.dfd.state() && ! this.attachments.length ) {
527 if ( this.attachments.length ) {
528 attachments = this.attachments.toJSON();
530 _.each( attachments, function( attachment ) {
531 if ( attachment.sizes ) {
532 if ( attrs.size && attachment.sizes[ attrs.size ] ) {
533 attachment.thumbnail = attachment.sizes[ attrs.size ];
534 } else if ( attachment.sizes.thumbnail ) {
535 attachment.thumbnail = attachment.sizes.thumbnail;
536 } else if ( attachment.sizes.full ) {
537 attachment.thumbnail = attachment.sizes.full;
544 attachments: attachments,
545 columns: attrs.columns ? parseInt( attrs.columns, 10 ) : wp.media.galleryDefaults.columns
548 return this.template( options );
552 edit: function( node ) {
553 var gallery = wp.media.gallery,
557 data = window.decodeURIComponent( $( node ).attr('data-wpview-text') );
558 frame = gallery.edit( data );
560 frame.state('gallery-edit').on( 'update', function( selection ) {
561 var shortcode = gallery.shortcode( selection ).string(), force;
562 $( node ).attr( 'data-wpview-text', window.encodeURIComponent( shortcode ) );
563 force = ( data !== shortcode );
564 wp.mce.views.refreshView( self, shortcode, force );
567 frame.on( 'close', function() {
574 * These are base methods that are shared by the audio and video shortcode's MCE controller.
582 action: 'parse-media-shortcode',
584 initialize: function( options ) {
587 this.shortcode = options.shortcode;
589 _.bindAll( this, 'setIframes', 'setNodes', 'fetch', 'stopPlayers' );
590 $( this ).on( 'ready', this.setNodes );
592 $( document ).on( 'media:edit', this.stopPlayers );
596 this.getEditors( function( editor ) {
597 editor.on( 'hide', function () {
605 pauseOtherWindows: function ( win ) {
606 _.each( mediaWindows, function ( mediaWindow ) {
607 if ( mediaWindow.sandboxId !== win.sandboxId ) {
608 _.each( mediaWindow.mejs.players, function ( player ) {
615 iframeLoaded: function (win) {
616 return _.bind( function () {
618 if ( ! win.mejs || _.isEmpty( win.mejs.players ) ) {
622 win.sandboxId = windowIdx;
624 mediaWindows.push( win );
626 callback = _.bind( function () {
627 this.pauseOtherWindows( win );
630 if ( ! _.isEmpty( win.mejs.MediaPluginBridge.pluginMediaElements ) ) {
631 _.each( win.mejs.MediaPluginBridge.pluginMediaElements, function ( mediaElement ) {
632 mediaElement.addEventListener( 'play', callback );
636 _.each( win.mejs.players, function ( player ) {
637 $( player.node ).on( 'play', callback );
642 listenToSandboxes: function () {
643 _.each( this.getNodes(), function ( node ) {
644 var win, iframe = $( '.wpview-sandbox', node ).get( 0 );
645 if ( iframe && ( win = iframe.contentWindow ) ) {
646 $( win ).load( _.bind( this.iframeLoaded( win ), this ) );
651 deferredListen: function () {
652 window.setTimeout( _.bind( this.listenToSandboxes, this ), this.getNodes().length * waitInterval );
655 setNodes: function () {
657 this.setIframes( this.parsed.head, this.parsed.body );
658 this.deferredListen();
667 wp.ajax.send( this.action, {
669 post_ID: $( '#post_ID' ).val() || 0,
670 type: this.shortcode.tag,
671 shortcode: this.shortcode.string()
674 .done( function( response ) {
676 self.parsed = response;
677 self.setIframes( response.head, response.body );
678 self.deferredListen();
683 .fail( function( response ) {
684 self.fail( response || true );
688 fail: function( error ) {
689 if ( ! this.error ) {
697 if ( this.error.message ) {
698 if ( ( this.error.type === 'not-embeddable' && this.type === 'embed' ) || this.error.type === 'not-ssl' ||
699 this.error.type === 'no-items' ) {
701 this.setError( this.error.message, 'admin-media' );
703 this.setContent( '<p>' + this.original + '</p>', 'replace' );
705 } else if ( this.error.statusText ) {
706 this.setError( this.error.statusText, 'admin-media' );
707 } else if ( this.original ) {
708 this.setContent( '<p>' + this.original + '</p>', 'replace' );
712 stopPlayers: function( remove ) {
713 var rem = remove === 'remove';
715 this.getNodes( function( editor, node, content ) {
717 iframe = $( 'iframe.wpview-sandbox', content ).get(0);
719 if ( iframe && ( win = iframe.contentWindow ) && win.mejs ) {
720 // Sometimes ME.js may show a "Download File" placeholder and player.remove() doesn't exist there.
722 for ( p in win.mejs.players ) {
723 win.mejs.players[p].pause();
726 win.mejs.players[p].remove();
735 this.stopPlayers( 'remove' );
740 * Called when a TinyMCE view is clicked for editing.
741 * - Parses the shortcode out of the element's data attribute
742 * - Calls the `edit` method on the shortcode model
743 * - Launches the model window
744 * - Bind's an `update` callback which updates the element's data attribute
745 * re-renders the view
747 * @param {HTMLElement} node
749 edit: function( node ) {
750 var media = wp.media[ this.type ],
752 frame, data, callback;
754 $( document ).trigger( 'media:edit' );
756 data = window.decodeURIComponent( $( node ).attr('data-wpview-text') );
757 frame = media.edit( data );
758 frame.on( 'close', function() {
762 callback = function( selection ) {
763 var shortcode = wp.media[ self.type ].shortcode( selection ).string();
764 $( node ).attr( 'data-wpview-text', window.encodeURIComponent( shortcode ) );
765 wp.mce.views.refreshView( self, shortcode );
768 if ( _.isArray( self.state ) ) {
769 _.each( self.state, function (state) {
770 frame.state( state ).on( 'update', callback );
773 frame.state( self.state ).on( 'update', callback );
780 * TinyMCE handler for the video shortcode
784 wp.mce.views.register( 'video', _.extend( {}, wp.mce.av, {
785 state: 'video-details'
789 * TinyMCE handler for the audio shortcode
793 wp.mce.views.register( 'audio', _.extend( {}, wp.mce.av, {
794 state: 'audio-details'
798 * TinyMCE handler for the playlist shortcode
802 wp.mce.views.register( 'playlist', _.extend( {}, wp.mce.av, {
803 state: [ 'playlist-edit', 'video-playlist-edit' ]
807 * TinyMCE handler for the embed shortcode
809 wp.mce.embedMixin = {
810 View: _.extend( {}, wp.mce.av.View, {
812 action: 'parse-embed',
813 initialize: function( options ) {
814 this.content = options.content;
815 this.original = options.url || options.shortcode.string();
818 this.shortcode = media.embed.shortcode( {
822 this.shortcode = options.shortcode;
825 _.bindAll( this, 'setIframes', 'setNodes', 'fetch' );
826 $( this ).on( 'ready', this.setNodes );
831 edit: function( node ) {
832 var embed = media.embed,
836 isURL = 'embedURL' === this.type;
838 $( document ).trigger( 'media:edit' );
840 data = window.decodeURIComponent( $( node ).attr('data-wpview-text') );
841 frame = embed.edit( data, isURL );
842 frame.on( 'close', function() {
845 frame.state( 'embed' ).props.on( 'change:url', function (model, url) {
849 frame.state( 'embed' ).metadata = model.toJSON();
851 frame.state( 'embed' ).on( 'select', function() {
855 shortcode = frame.state( 'embed' ).metadata.url;
857 shortcode = embed.shortcode( frame.state( 'embed' ).metadata ).string();
859 $( node ).attr( 'data-wpview-text', window.encodeURIComponent( shortcode ) );
860 wp.mce.views.refreshView( self, shortcode );
867 wp.mce.views.register( 'embed', _.extend( {}, wp.mce.embedMixin ) );
869 wp.mce.views.register( 'embedURL', _.extend( {}, wp.mce.embedMixin, {
870 toView: function( content ) {
871 var re = /(?:^|<p>)(https?:\/\/[^\s"]+?)(?:<\/p>\s*|$)/gi,
872 match = re.exec( tinymce.trim( content ) );