1 // Ensure the global `wp` object exists. |
1 /* global tinymce */ |
|
2 |
2 window.wp = window.wp || {}; |
3 window.wp = window.wp || {}; |
3 |
4 |
4 (function($){ |
5 /* |
|
6 * The TinyMCE view API. |
|
7 * |
|
8 * Note: this API is "experimental" meaning that it will probably change |
|
9 * in the next few releases based on feedback from 3.9.0. |
|
10 * If you decide to use it, please follow the development closely. |
|
11 * |
|
12 * Diagram |
|
13 * |
|
14 * |- registered view constructor (type) |
|
15 * | |- view instance (unique text) |
|
16 * | | |- editor 1 |
|
17 * | | | |- view node |
|
18 * | | | |- view node |
|
19 * | | | |- ... |
|
20 * | | |- editor 2 |
|
21 * | | | |- ... |
|
22 * | |- view instance |
|
23 * | | |- ... |
|
24 * |- registered view |
|
25 * | |- ... |
|
26 */ |
|
27 ( function( window, wp, $ ) { |
|
28 'use strict'; |
|
29 |
5 var views = {}, |
30 var views = {}, |
6 instances = {}; |
31 instances = {}; |
7 |
32 |
8 // Create the `wp.mce` object if necessary. |
|
9 wp.mce = wp.mce || {}; |
33 wp.mce = wp.mce || {}; |
10 |
34 |
11 // wp.mce.view |
35 /** |
12 // ----------- |
36 * wp.mce.views |
13 // A set of utilities that simplifies adding custom UI within a TinyMCE editor. |
37 * |
14 // At its core, it serves as a series of converters, transforming text to a |
38 * A set of utilities that simplifies adding custom UI within a TinyMCE editor. |
15 // custom UI, and back again. |
39 * At its core, it serves as a series of converters, transforming text to a |
16 wp.mce.view = { |
40 * custom UI, and back again. |
17 // ### defaults |
41 */ |
18 defaults: { |
42 wp.mce.views = { |
19 // The default properties used for objects with the `pattern` key in |
43 |
20 // `wp.mce.view.add()`. |
44 /** |
21 pattern: { |
45 * Registers a new view type. |
22 view: Backbone.View, |
46 * |
23 text: function( instance ) { |
47 * @param {String} type The view type. |
24 return instance.options.original; |
48 * @param {Object} extend An object to extend wp.mce.View.prototype with. |
25 }, |
49 */ |
26 |
50 register: function( type, extend ) { |
27 toView: function( content ) { |
51 views[ type ] = wp.mce.View.extend( _.extend( extend, { type: type } ) ); |
28 if ( ! this.pattern ) |
52 }, |
29 return; |
53 |
30 |
54 /** |
31 this.pattern.lastIndex = 0; |
55 * Unregisters a view type. |
32 var match = this.pattern.exec( content ); |
56 * |
33 |
57 * @param {String} type The view type. |
34 if ( ! match ) |
58 */ |
35 return; |
59 unregister: function( type ) { |
36 |
60 delete views[ type ]; |
37 return { |
61 }, |
38 index: match.index, |
62 |
39 content: match[0], |
63 /** |
40 options: { |
64 * Returns the settings of a view type. |
41 original: match[0], |
65 * |
42 results: match |
66 * @param {String} type The view type. |
43 } |
67 * |
44 }; |
68 * @return {Function} The view constructor. |
45 } |
69 */ |
46 }, |
70 get: function( type ) { |
47 |
71 return views[ type ]; |
48 // The default properties used for objects with the `shortcode` key in |
72 }, |
49 // `wp.mce.view.add()`. |
73 |
50 shortcode: { |
74 /** |
51 view: Backbone.View, |
75 * Unbinds all view nodes. |
52 text: function( instance ) { |
76 * Runs before removing all view nodes from the DOM. |
53 return instance.options.shortcode.string(); |
77 */ |
54 }, |
78 unbind: function() { |
55 |
79 _.each( instances, function( instance ) { |
56 toView: function( content ) { |
80 instance.unbind(); |
57 var match = wp.shortcode.next( this.shortcode, content ); |
81 } ); |
58 |
82 }, |
59 if ( ! match ) |
83 |
60 return; |
84 /** |
61 |
85 * Scans a given string for each view's pattern, |
62 return { |
86 * replacing any matches with markers, |
63 index: match.index, |
87 * and creates a new instance for every match. |
64 content: match.content, |
88 * |
65 options: { |
89 * @param {String} content The string to scan. |
66 shortcode: match.shortcode |
90 * |
67 } |
91 * @return {String} The string with markers. |
68 }; |
92 */ |
69 } |
93 setMarkers: function( content ) { |
70 } |
|
71 }, |
|
72 |
|
73 // ### add( id, options ) |
|
74 // Registers a new TinyMCE view. |
|
75 // |
|
76 // Accepts a unique `id` and an `options` object. |
|
77 // |
|
78 // `options` accepts the following properties: |
|
79 // |
|
80 // * `pattern` is the regular expression used to scan the content and |
|
81 // detect matching views. |
|
82 // |
|
83 // * `view` is a `Backbone.View` constructor. If a plain object is |
|
84 // provided, it will automatically extend the parent constructor |
|
85 // (usually `Backbone.View`). Views are instantiated when the `pattern` |
|
86 // is successfully matched. The instance's `options` object is provided |
|
87 // with the `original` matched value, the match `results` including |
|
88 // capture groups, and the `viewType`, which is the constructor's `id`. |
|
89 // |
|
90 // * `extend` an existing view by passing in its `id`. The current |
|
91 // view will inherit all properties from the parent view, and if |
|
92 // `view` is set to a plain object, it will extend the parent `view` |
|
93 // constructor. |
|
94 // |
|
95 // * `text` is a method that accepts an instance of the `view` |
|
96 // constructor and transforms it into a text representation. |
|
97 add: function( id, options ) { |
|
98 var parent, remove, base, properties; |
|
99 |
|
100 // Fetch the parent view or the default options. |
|
101 if ( options.extend ) |
|
102 parent = wp.mce.view.get( options.extend ); |
|
103 else if ( options.shortcode ) |
|
104 parent = wp.mce.view.defaults.shortcode; |
|
105 else |
|
106 parent = wp.mce.view.defaults.pattern; |
|
107 |
|
108 // Extend the `options` object with the parent's properties. |
|
109 _.defaults( options, parent ); |
|
110 options.id = id; |
|
111 |
|
112 // Create properties used to enhance the view for use in TinyMCE. |
|
113 properties = { |
|
114 // Ensure the wrapper element and references to the view are |
|
115 // removed. Otherwise, removed views could randomly restore. |
|
116 remove: function() { |
|
117 delete instances[ this.el.id ]; |
|
118 this.$el.parent().remove(); |
|
119 |
|
120 // Trigger the inherited `remove` method. |
|
121 if ( remove ) |
|
122 remove.apply( this, arguments ); |
|
123 |
|
124 return this; |
|
125 } |
|
126 }; |
|
127 |
|
128 // If the `view` provided was an object, use the parent's |
|
129 // `view` constructor as a base. If a `view` constructor |
|
130 // was provided, treat that as the base. |
|
131 if ( _.isFunction( options.view ) ) { |
|
132 base = options.view; |
|
133 } else { |
|
134 base = parent.view; |
|
135 remove = options.view.remove; |
|
136 _.defaults( properties, options.view ); |
|
137 } |
|
138 |
|
139 // If there's a `remove` method on the `base` view that wasn't |
|
140 // created by this method, inherit it. |
|
141 if ( ! remove && ! base._mceview ) |
|
142 remove = base.prototype.remove; |
|
143 |
|
144 // Automatically create the new `Backbone.View` constructor. |
|
145 options.view = base.extend( properties, { |
|
146 // Flag that the new view has been created by `wp.mce.view`. |
|
147 _mceview: true |
|
148 }); |
|
149 |
|
150 views[ id ] = options; |
|
151 }, |
|
152 |
|
153 // ### get( id ) |
|
154 // Returns a TinyMCE view options object. |
|
155 get: function( id ) { |
|
156 return views[ id ]; |
|
157 }, |
|
158 |
|
159 // ### remove( id ) |
|
160 // Unregisters a TinyMCE view. |
|
161 remove: function( id ) { |
|
162 delete views[ id ]; |
|
163 }, |
|
164 |
|
165 // ### toViews( content ) |
|
166 // Scans a `content` string for each view's pattern, replacing any |
|
167 // matches with wrapper elements, and creates a new view instance for |
|
168 // every match. |
|
169 // |
|
170 // To render the views, call `wp.mce.view.render( scope )`. |
|
171 toViews: function( content ) { |
|
172 var pieces = [ { content: content } ], |
94 var pieces = [ { content: content } ], |
|
95 self = this, |
|
96 instance, |
173 current; |
97 current; |
174 |
98 |
175 _.each( views, function( view, viewType ) { |
99 _.each( views, function( view, type ) { |
176 current = pieces.slice(); |
100 current = pieces.slice(); |
177 pieces = []; |
101 pieces = []; |
178 |
102 |
179 _.each( current, function( piece ) { |
103 _.each( current, function( piece ) { |
180 var remaining = piece.content, |
104 var remaining = piece.content, |
186 return; |
110 return; |
187 } |
111 } |
188 |
112 |
189 // Iterate through the string progressively matching views |
113 // Iterate through the string progressively matching views |
190 // and slicing the string as we go. |
114 // and slicing the string as we go. |
191 while ( remaining && (result = view.toView( remaining )) ) { |
115 while ( remaining && ( result = view.prototype.match( remaining ) ) ) { |
192 // Any text before the match becomes an unprocessed piece. |
116 // Any text before the match becomes an unprocessed piece. |
193 if ( result.index ) |
117 if ( result.index ) { |
194 pieces.push({ content: remaining.substring( 0, result.index ) }); |
118 pieces.push( { content: remaining.substring( 0, result.index ) } ); |
|
119 } |
|
120 |
|
121 instance = self.createInstance( type, result.content, result.options ); |
195 |
122 |
196 // Add the processed piece for the match. |
123 // Add the processed piece for the match. |
197 pieces.push({ |
124 pieces.push( { |
198 content: wp.mce.view.toView( viewType, result.options ), |
125 content: '<p data-wpview-marker="' + instance.encodedText + '">' + instance.text + '</p>', |
199 processed: true |
126 processed: true |
200 }); |
127 } ); |
201 |
128 |
202 // Update the remaining content. |
129 // Update the remaining content. |
203 remaining = remaining.slice( result.index + result.content.length ); |
130 remaining = remaining.slice( result.index + result.content.length ); |
204 } |
131 } |
205 |
132 |
206 // There are no additional matches. If any content remains, |
133 // There are no additional matches. |
207 // add it as an unprocessed piece. |
134 // If any content remains, add it as an unprocessed piece. |
208 if ( remaining ) |
135 if ( remaining ) { |
209 pieces.push({ content: remaining }); |
136 pieces.push( { content: remaining } ); |
210 }); |
137 } |
211 }); |
138 } ); |
212 |
139 } ); |
213 return _.pluck( pieces, 'content' ).join(''); |
140 |
214 }, |
141 return _.pluck( pieces, 'content' ).join( '' ); |
215 |
142 }, |
216 toView: function( viewType, options ) { |
143 |
217 var view = wp.mce.view.get( viewType ), |
144 /** |
218 instance, id; |
145 * Create a view instance. |
219 |
146 * |
220 if ( ! view ) |
147 * @param {String} type The view type. |
221 return ''; |
148 * @param {String} text The textual representation of the view. |
222 |
149 * @param {Object} options Options. |
223 // Create a new view instance. |
150 * |
224 instance = new view.view( _.extend( options || {}, { |
151 * @return {wp.mce.View} The view instance. |
225 viewType: viewType |
152 */ |
226 }) ); |
153 createInstance: function( type, text, options ) { |
227 |
154 var View = this.get( type ), |
228 // Use the view's `id` if it already exists. Otherwise, |
155 encodedText, |
229 // create a new `id`. |
156 instance; |
230 id = instance.el.id = instance.el.id || _.uniqueId('__wpmce-'); |
157 |
231 instances[ id ] = instance; |
158 text = tinymce.DOM.decode( text ), |
232 |
159 encodedText = encodeURIComponent( text ), |
233 // Create a dummy `$wrapper` property to allow `$wrapper` to be |
160 instance = this.getInstance( encodedText ); |
234 // called in the view's `render` method without a conditional. |
161 |
235 instance.$wrapper = $(); |
162 if ( instance ) { |
236 |
163 return instance; |
237 return wp.html.string({ |
164 } |
238 // If the view is a span, wrap it in a span. |
165 |
239 tag: 'span' === instance.tagName ? 'span' : 'div', |
166 options = _.extend( options || {}, { |
240 |
167 text: text, |
241 attrs: { |
168 encodedText: encodedText |
242 'class': 'wp-view-wrap wp-view-type-' + viewType, |
169 } ); |
243 'data-wp-view': id, |
170 |
244 'contenteditable': false |
171 return instances[ encodedText ] = new View( options ); |
245 } |
172 }, |
246 }); |
173 |
247 }, |
174 /** |
248 |
175 * Get a view instance. |
249 // ### render( scope ) |
176 * |
250 // Renders any view instances inside a DOM node `scope`. |
177 * @param {(String|HTMLElement)} object The textual representation of the view or the view node. |
251 // |
178 * |
252 // View instances are detected by the presence of wrapper elements. |
179 * @return {wp.mce.View} The view instance or undefined. |
253 // To generate wrapper elements, pass your content through |
180 */ |
254 // `wp.mce.view.toViews( content )`. |
181 getInstance: function( object ) { |
255 render: function( scope ) { |
182 if ( typeof object === 'string' ) { |
256 $( '.wp-view-wrap', scope ).each( function() { |
183 return instances[ encodeURIComponent( object ) ]; |
257 var wrapper = $(this), |
184 } |
258 view = wp.mce.view.instance( this ); |
185 |
259 |
186 return instances[ $( object ).attr( 'data-wpview-text' ) ]; |
260 if ( ! view ) |
187 }, |
261 return; |
188 |
262 |
189 /** |
263 // Link the real wrapper to the view. |
190 * Given a view node, get the view's text. |
264 view.$wrapper = wrapper; |
191 * |
265 // Render the view. |
192 * @param {HTMLElement} node The view node. |
266 view.render(); |
193 * |
267 // Detach the view element to ensure events are not unbound. |
194 * @return {String} The textual representation of the view. |
268 view.$el.detach(); |
195 */ |
269 |
196 getText: function( node ) { |
270 // Empty the wrapper, attach the view element to the wrapper, |
197 return decodeURIComponent( $( node ).attr( 'data-wpview-text' ) || '' ); |
271 // and add an ending marker to the wrapper to help regexes |
198 }, |
272 // scan the HTML string. |
199 |
273 wrapper.empty().append( view.el ).append('<span data-wp-view-end class="wp-view-end"></span>'); |
200 /** |
274 }); |
201 * Renders all view nodes that are not yet rendered. |
275 }, |
202 * |
276 |
203 * @param {Boolean} force Rerender all view nodes. |
277 // ### toText( content ) |
204 */ |
278 // Scans an HTML `content` string and replaces any view instances with |
205 render: function( force ) { |
279 // their respective text representations. |
206 _.each( instances, function( instance ) { |
280 toText: function( content ) { |
207 instance.render( force ); |
281 return content.replace( /<(?:div|span)[^>]+data-wp-view="([^"]+)"[^>]*>.*?<span[^>]+data-wp-view-end[^>]*><\/span><\/(?:div|span)>/g, function( match, id ) { |
208 } ); |
282 var instance = instances[ id ], |
209 }, |
283 view; |
210 |
284 |
211 /** |
285 if ( instance ) |
212 * Update the text of a given view node. |
286 view = wp.mce.view.get( instance.options.viewType ); |
213 * |
287 |
214 * @param {String} text The new text. |
288 return instance && view ? view.text( instance ) : ''; |
215 * @param {tinymce.Editor} editor The TinyMCE editor instance the view node is in. |
289 }); |
216 * @param {HTMLElement} node The view node to update. |
290 }, |
217 */ |
291 |
218 update: function( text, editor, node ) { |
292 // ### Remove internal TinyMCE attributes. |
219 var instance = this.getInstance( node ); |
293 removeInternalAttrs: function( attrs ) { |
220 |
294 var result = {}; |
221 if ( instance ) { |
295 _.each( attrs, function( value, attr ) { |
222 instance.update( text, editor, node ); |
296 if ( -1 === attr.indexOf('data-mce') ) |
223 } |
297 result[ attr ] = value; |
224 }, |
298 }); |
225 |
299 return result; |
226 /** |
300 }, |
227 * Renders any editing interface based on the view type. |
301 |
228 * |
302 // ### Parse an attribute string and removes internal TinyMCE attributes. |
229 * @param {tinymce.Editor} editor The TinyMCE editor instance the view node is in. |
303 attrs: function( content ) { |
230 * @param {HTMLElement} node The view node to edit. |
304 return wp.mce.view.removeInternalAttrs( wp.html.attrs( content ) ); |
231 */ |
305 }, |
232 edit: function( editor, node ) { |
306 |
233 var instance = this.getInstance( node ); |
307 // ### instance( scope ) |
234 |
308 // |
235 if ( instance && instance.edit ) { |
309 // Accepts a MCE view wrapper `node` (i.e. a node with the |
236 instance.edit( instance.text, function( text ) { |
310 // `wp-view-wrap` class). |
237 instance.update( text, editor, node ); |
311 instance: function( node ) { |
238 } ); |
312 var id = $( node ).data('wp-view'); |
239 } |
313 |
240 }, |
314 if ( id ) |
241 |
315 return instances[ id ]; |
242 /** |
316 }, |
243 * Remove a given view node from the DOM. |
317 |
244 * |
318 // ### Select a view. |
245 * @param {tinymce.Editor} editor The TinyMCE editor instance the view node is in. |
319 // |
246 * @param {HTMLElement} node The view node to remove. |
320 // Accepts a MCE view wrapper `node` (i.e. a node with the |
247 */ |
321 // `wp-view-wrap` class). |
248 remove: function( editor, node ) { |
322 select: function( node ) { |
249 var instance = this.getInstance( node ); |
323 var $node = $(node); |
250 |
324 |
251 if ( instance ) { |
325 // Bail if node is already selected. |
252 instance.remove( editor, node ); |
326 if ( $node.hasClass('selected') ) |
253 } |
327 return; |
|
328 |
|
329 $node.addClass('selected'); |
|
330 $( node.firstChild ).trigger('select'); |
|
331 }, |
|
332 |
|
333 // ### Deselect a view. |
|
334 // |
|
335 // Accepts a MCE view wrapper `node` (i.e. a node with the |
|
336 // `wp-view-wrap` class). |
|
337 deselect: function( node ) { |
|
338 var $node = $(node); |
|
339 |
|
340 // Bail if node is already selected. |
|
341 if ( ! $node.hasClass('selected') ) |
|
342 return; |
|
343 |
|
344 $node.removeClass('selected'); |
|
345 $( node.firstChild ).trigger('deselect'); |
|
346 } |
254 } |
347 }; |
255 }; |
348 |
256 |
349 }(jQuery)); |
257 /** |
|
258 * A Backbone-like View constructor intended for use when rendering a TinyMCE View. |
|
259 * The main difference is that the TinyMCE View is not tied to a particular DOM node. |
|
260 * |
|
261 * @param {Object} options Options. |
|
262 */ |
|
263 wp.mce.View = function( options ) { |
|
264 _.extend( this, options ); |
|
265 this.initialize(); |
|
266 }; |
|
267 |
|
268 wp.mce.View.extend = Backbone.View.extend; |
|
269 |
|
270 _.extend( wp.mce.View.prototype, { |
|
271 |
|
272 /** |
|
273 * The content. |
|
274 * |
|
275 * @type {*} |
|
276 */ |
|
277 content: null, |
|
278 |
|
279 /** |
|
280 * Whether or not to display a loader. |
|
281 * |
|
282 * @type {Boolean} |
|
283 */ |
|
284 loader: true, |
|
285 |
|
286 /** |
|
287 * Runs after the view instance is created. |
|
288 */ |
|
289 initialize: function() {}, |
|
290 |
|
291 /** |
|
292 * Retuns the content to render in the view node. |
|
293 * |
|
294 * @return {*} |
|
295 */ |
|
296 getContent: function() { |
|
297 return this.content; |
|
298 }, |
|
299 |
|
300 /** |
|
301 * Renders all view nodes tied to this view instance that are not yet rendered. |
|
302 * |
|
303 * @param {String} content The content to render. Optional. |
|
304 * @param {Boolean} force Rerender all view nodes tied to this view instance. |
|
305 */ |
|
306 render: function( content, force ) { |
|
307 if ( content != null ) { |
|
308 this.content = content; |
|
309 } |
|
310 |
|
311 content = this.getContent(); |
|
312 |
|
313 // If there's nothing to render an no loader needs to be shown, stop. |
|
314 if ( ! this.loader && ! content ) { |
|
315 return; |
|
316 } |
|
317 |
|
318 // We're about to rerender all views of this instance, so unbind rendered views. |
|
319 force && this.unbind(); |
|
320 |
|
321 // Replace any left over markers. |
|
322 this.replaceMarkers(); |
|
323 |
|
324 if ( content ) { |
|
325 this.setContent( content, function( editor, node, contentNode ) { |
|
326 $( node ).data( 'rendered', true ); |
|
327 this.bindNode.call( this, editor, node, contentNode ); |
|
328 }, force ? null : false ); |
|
329 } else { |
|
330 this.setLoader(); |
|
331 } |
|
332 }, |
|
333 |
|
334 /** |
|
335 * Binds a given node after its content is added to the DOM. |
|
336 */ |
|
337 bindNode: function() {}, |
|
338 |
|
339 /** |
|
340 * Unbinds a given node before its content is removed from the DOM. |
|
341 */ |
|
342 unbindNode: function() {}, |
|
343 |
|
344 /** |
|
345 * Unbinds all view nodes tied to this view instance. |
|
346 * Runs before their content is removed from the DOM. |
|
347 */ |
|
348 unbind: function() { |
|
349 this.getNodes( function( editor, node, contentNode ) { |
|
350 this.unbindNode.call( this, editor, node, contentNode ); |
|
351 $( node ).trigger( 'wp-mce-view-unbind' ); |
|
352 }, true ); |
|
353 }, |
|
354 |
|
355 /** |
|
356 * Gets all the TinyMCE editor instances that support views. |
|
357 * |
|
358 * @param {Function} callback A callback. |
|
359 */ |
|
360 getEditors: function( callback ) { |
|
361 _.each( tinymce.editors, function( editor ) { |
|
362 if ( editor.plugins.wpview ) { |
|
363 callback.call( this, editor ); |
|
364 } |
|
365 }, this ); |
|
366 }, |
|
367 |
|
368 /** |
|
369 * Gets all view nodes tied to this view instance. |
|
370 * |
|
371 * @param {Function} callback A callback. |
|
372 * @param {Boolean} rendered Get (un)rendered view nodes. Optional. |
|
373 */ |
|
374 getNodes: function( callback, rendered ) { |
|
375 this.getEditors( function( editor ) { |
|
376 var self = this; |
|
377 |
|
378 $( editor.getBody() ) |
|
379 .find( '[data-wpview-text="' + self.encodedText + '"]' ) |
|
380 .filter( function() { |
|
381 var data; |
|
382 |
|
383 if ( rendered == null ) { |
|
384 return true; |
|
385 } |
|
386 |
|
387 data = $( this ).data( 'rendered' ) === true; |
|
388 |
|
389 return rendered ? data : ! data; |
|
390 } ) |
|
391 .each( function() { |
|
392 callback.call( self, editor, this, $( this ).find( '.wpview-content' ).get( 0 ) ); |
|
393 } ); |
|
394 } ); |
|
395 }, |
|
396 |
|
397 /** |
|
398 * Gets all marker nodes tied to this view instance. |
|
399 * |
|
400 * @param {Function} callback A callback. |
|
401 */ |
|
402 getMarkers: function( callback ) { |
|
403 this.getEditors( function( editor ) { |
|
404 var self = this; |
|
405 |
|
406 $( editor.getBody() ) |
|
407 .find( '[data-wpview-marker="' + this.encodedText + '"]' ) |
|
408 .each( function() { |
|
409 callback.call( self, editor, this ); |
|
410 } ); |
|
411 } ); |
|
412 }, |
|
413 |
|
414 /** |
|
415 * Replaces all marker nodes tied to this view instance. |
|
416 */ |
|
417 replaceMarkers: function() { |
|
418 this.getMarkers( function( editor, node ) { |
|
419 if ( $( node ).text() !== this.text ) { |
|
420 editor.dom.setAttrib( node, 'data-wpview-marker', null ); |
|
421 return; |
|
422 } |
|
423 |
|
424 editor.dom.replace( |
|
425 editor.dom.createFragment( |
|
426 '<div class="wpview-wrap" data-wpview-text="' + this.encodedText + '" data-wpview-type="' + this.type + '">' + |
|
427 '<p class="wpview-selection-before">\u00a0</p>' + |
|
428 '<div class="wpview-body" contenteditable="false">' + |
|
429 '<div class="wpview-content wpview-type-' + this.type + '"></div>' + |
|
430 '</div>' + |
|
431 '<p class="wpview-selection-after">\u00a0</p>' + |
|
432 '</div>' |
|
433 ), |
|
434 node |
|
435 ); |
|
436 } ); |
|
437 }, |
|
438 |
|
439 /** |
|
440 * Removes all marker nodes tied to this view instance. |
|
441 */ |
|
442 removeMarkers: function() { |
|
443 this.getMarkers( function( editor, node ) { |
|
444 editor.dom.setAttrib( node, 'data-wpview-marker', null ); |
|
445 } ); |
|
446 }, |
|
447 |
|
448 /** |
|
449 * Sets the content for all view nodes tied to this view instance. |
|
450 * |
|
451 * @param {*} content The content to set. |
|
452 * @param {Function} callback A callback. Optional. |
|
453 * @param {Boolean} rendered Only set for (un)rendered nodes. Optional. |
|
454 */ |
|
455 setContent: function( content, callback, rendered ) { |
|
456 if ( _.isObject( content ) && content.body.indexOf( '<script' ) !== -1 ) { |
|
457 this.setIframes( content.head || '', content.body, callback, rendered ); |
|
458 } else if ( _.isString( content ) && content.indexOf( '<script' ) !== -1 ) { |
|
459 this.setIframes( '', content, callback, rendered ); |
|
460 } else { |
|
461 this.getNodes( function( editor, node, contentNode ) { |
|
462 content = content.body || content; |
|
463 |
|
464 if ( content.indexOf( '<iframe' ) !== -1 ) { |
|
465 content += '<div class="wpview-overlay"></div>'; |
|
466 } |
|
467 |
|
468 contentNode.innerHTML = ''; |
|
469 contentNode.appendChild( _.isString( content ) ? editor.dom.createFragment( content ) : content ); |
|
470 |
|
471 callback && callback.call( this, editor, node, contentNode ); |
|
472 }, rendered ); |
|
473 } |
|
474 }, |
|
475 |
|
476 /** |
|
477 * Sets the content in an iframe for all view nodes tied to this view instance. |
|
478 * |
|
479 * @param {String} head HTML string to be added to the head of the document. |
|
480 * @param {String} body HTML string to be added to the body of the document. |
|
481 * @param {Function} callback A callback. Optional. |
|
482 * @param {Boolean} rendered Only set for (un)rendered nodes. Optional. |
|
483 */ |
|
484 setIframes: function( head, body, callback, rendered ) { |
|
485 var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver, |
|
486 self = this; |
|
487 |
|
488 this.getNodes( function( editor, node, contentNode ) { |
|
489 var dom = editor.dom, |
|
490 styles = '', |
|
491 bodyClasses = editor.getBody().className || '', |
|
492 editorHead = editor.getDoc().getElementsByTagName( 'head' )[0]; |
|
493 |
|
494 tinymce.each( dom.$( 'link[rel="stylesheet"]', editorHead ), function( link ) { |
|
495 if ( link.href && link.href.indexOf( 'skins/lightgray/content.min.css' ) === -1 && |
|
496 link.href.indexOf( 'skins/wordpress/wp-content.css' ) === -1 ) { |
|
497 |
|
498 styles += dom.getOuterHTML( link ); |
|
499 } |
|
500 } ); |
|
501 |
|
502 // Seems the browsers need a bit of time to insert/set the view nodes, |
|
503 // or the iframe will fail especially when switching Text => Visual. |
|
504 setTimeout( function() { |
|
505 var iframe, iframeDoc, observer, i; |
|
506 |
|
507 contentNode.innerHTML = ''; |
|
508 |
|
509 iframe = dom.add( contentNode, 'iframe', { |
|
510 /* jshint scripturl: true */ |
|
511 src: tinymce.Env.ie ? 'javascript:""' : '', |
|
512 frameBorder: '0', |
|
513 allowTransparency: 'true', |
|
514 scrolling: 'no', |
|
515 'class': 'wpview-sandbox', |
|
516 style: { |
|
517 width: '100%', |
|
518 display: 'block' |
|
519 } |
|
520 } ); |
|
521 |
|
522 dom.add( contentNode, 'div', { 'class': 'wpview-overlay' } ); |
|
523 |
|
524 iframeDoc = iframe.contentWindow.document; |
|
525 |
|
526 iframeDoc.open(); |
|
527 |
|
528 iframeDoc.write( |
|
529 '<!DOCTYPE html>' + |
|
530 '<html>' + |
|
531 '<head>' + |
|
532 '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />' + |
|
533 head + |
|
534 styles + |
|
535 '<style>' + |
|
536 'html {' + |
|
537 'background: transparent;' + |
|
538 'padding: 0;' + |
|
539 'margin: 0;' + |
|
540 '}' + |
|
541 'body#wpview-iframe-sandbox {' + |
|
542 'background: transparent;' + |
|
543 'padding: 1px 0 !important;' + |
|
544 'margin: -1px 0 0 !important;' + |
|
545 '}' + |
|
546 'body#wpview-iframe-sandbox:before,' + |
|
547 'body#wpview-iframe-sandbox:after {' + |
|
548 'display: none;' + |
|
549 'content: "";' + |
|
550 '}' + |
|
551 '</style>' + |
|
552 '</head>' + |
|
553 '<body id="wpview-iframe-sandbox" class="' + bodyClasses + '">' + |
|
554 body + |
|
555 '</body>' + |
|
556 '</html>' |
|
557 ); |
|
558 |
|
559 iframeDoc.close(); |
|
560 |
|
561 function resize() { |
|
562 var $iframe, iframeDocHeight; |
|
563 |
|
564 // Make sure the iframe still exists. |
|
565 if ( iframe.contentWindow ) { |
|
566 $iframe = $( iframe ); |
|
567 iframeDocHeight = $( iframeDoc.body ).height(); |
|
568 |
|
569 if ( $iframe.height() !== iframeDocHeight ) { |
|
570 $iframe.height( iframeDocHeight ); |
|
571 editor.nodeChanged(); |
|
572 } |
|
573 } |
|
574 } |
|
575 |
|
576 $( iframe.contentWindow ).on( 'load', resize ); |
|
577 |
|
578 if ( MutationObserver ) { |
|
579 observer = new MutationObserver( _.debounce( resize, 100 ) ); |
|
580 |
|
581 observer.observe( iframeDoc.body, { |
|
582 attributes: true, |
|
583 childList: true, |
|
584 subtree: true |
|
585 } ); |
|
586 |
|
587 $( node ).one( 'wp-mce-view-unbind', function() { |
|
588 observer.disconnect(); |
|
589 } ); |
|
590 } else { |
|
591 for ( i = 1; i < 6; i++ ) { |
|
592 setTimeout( resize, i * 700 ); |
|
593 } |
|
594 } |
|
595 |
|
596 function classChange() { |
|
597 iframeDoc.body.className = editor.getBody().className; |
|
598 } |
|
599 |
|
600 editor.on( 'wp-body-class-change', classChange ); |
|
601 |
|
602 $( node ).one( 'wp-mce-view-unbind', function() { |
|
603 editor.off( 'wp-body-class-change', classChange ); |
|
604 } ); |
|
605 |
|
606 callback && callback.call( self, editor, node, contentNode ); |
|
607 }, 50 ); |
|
608 }, rendered ); |
|
609 }, |
|
610 |
|
611 /** |
|
612 * Sets a loader for all view nodes tied to this view instance. |
|
613 */ |
|
614 setLoader: function() { |
|
615 this.setContent( |
|
616 '<div class="loading-placeholder">' + |
|
617 '<div class="dashicons dashicons-admin-media"></div>' + |
|
618 '<div class="wpview-loading"><ins></ins></div>' + |
|
619 '</div>' |
|
620 ); |
|
621 }, |
|
622 |
|
623 /** |
|
624 * Sets an error for all view nodes tied to this view instance. |
|
625 * |
|
626 * @param {String} message The error message to set. |
|
627 * @param {String} dashicon A dashicon ID (optional). {@link https://developer.wordpress.org/resource/dashicons/} |
|
628 */ |
|
629 setError: function( message, dashicon ) { |
|
630 this.setContent( |
|
631 '<div class="wpview-error">' + |
|
632 '<div class="dashicons dashicons-' + ( dashicon || 'no' ) + '"></div>' + |
|
633 '<p>' + message + '</p>' + |
|
634 '</div>' |
|
635 ); |
|
636 }, |
|
637 |
|
638 /** |
|
639 * Tries to find a text match in a given string. |
|
640 * |
|
641 * @param {String} content The string to scan. |
|
642 * |
|
643 * @return {Object} |
|
644 */ |
|
645 match: function( content ) { |
|
646 var match = wp.shortcode.next( this.type, content ); |
|
647 |
|
648 if ( match ) { |
|
649 return { |
|
650 index: match.index, |
|
651 content: match.content, |
|
652 options: { |
|
653 shortcode: match.shortcode |
|
654 } |
|
655 }; |
|
656 } |
|
657 }, |
|
658 |
|
659 /** |
|
660 * Update the text of a given view node. |
|
661 * |
|
662 * @param {String} text The new text. |
|
663 * @param {tinymce.Editor} editor The TinyMCE editor instance the view node is in. |
|
664 * @param {HTMLElement} node The view node to update. |
|
665 */ |
|
666 update: function( text, editor, node ) { |
|
667 _.find( views, function( view, type ) { |
|
668 var match = view.prototype.match( text ); |
|
669 |
|
670 if ( match ) { |
|
671 $( node ).data( 'rendered', false ); |
|
672 editor.dom.setAttrib( node, 'data-wpview-text', encodeURIComponent( text ) ); |
|
673 wp.mce.views.createInstance( type, text, match.options ).render(); |
|
674 editor.focus(); |
|
675 |
|
676 return true; |
|
677 } |
|
678 } ); |
|
679 }, |
|
680 |
|
681 /** |
|
682 * Remove a given view node from the DOM. |
|
683 * |
|
684 * @param {tinymce.Editor} editor The TinyMCE editor instance the view node is in. |
|
685 * @param {HTMLElement} node The view node to remove. |
|
686 */ |
|
687 remove: function( editor, node ) { |
|
688 this.unbindNode.call( this, editor, node, $( node ).find( '.wpview-content' ).get( 0 ) ); |
|
689 $( node ).trigger( 'wp-mce-view-unbind' ); |
|
690 editor.dom.remove( node ); |
|
691 editor.focus(); |
|
692 } |
|
693 } ); |
|
694 } )( window, window.wp, window.jQuery ); |
|
695 |
|
696 /* |
|
697 * The WordPress core TinyMCE views. |
|
698 * Views for the gallery, audio, video, playlist and embed shortcodes, |
|
699 * and a view for embeddable URLs. |
|
700 */ |
|
701 ( function( window, views, $ ) { |
|
702 var postID = $( '#post_ID' ).val() || 0, |
|
703 media, gallery, av, embed; |
|
704 |
|
705 media = { |
|
706 state: [], |
|
707 |
|
708 edit: function( text, update ) { |
|
709 var media = wp.media[ this.type ], |
|
710 frame = media.edit( text ); |
|
711 |
|
712 this.pausePlayers && this.pausePlayers(); |
|
713 |
|
714 _.each( this.state, function( state ) { |
|
715 frame.state( state ).on( 'update', function( selection ) { |
|
716 update( media.shortcode( selection ).string() ); |
|
717 } ); |
|
718 } ); |
|
719 |
|
720 frame.on( 'close', function() { |
|
721 frame.detach(); |
|
722 } ); |
|
723 |
|
724 frame.open(); |
|
725 } |
|
726 }; |
|
727 |
|
728 gallery = _.extend( {}, media, { |
|
729 state: [ 'gallery-edit' ], |
|
730 template: wp.media.template( 'editor-gallery' ), |
|
731 |
|
732 initialize: function() { |
|
733 var attachments = wp.media.gallery.attachments( this.shortcode, postID ), |
|
734 attrs = this.shortcode.attrs.named, |
|
735 self = this; |
|
736 |
|
737 attachments.more() |
|
738 .done( function() { |
|
739 attachments = attachments.toJSON(); |
|
740 |
|
741 _.each( attachments, function( attachment ) { |
|
742 if ( attachment.sizes ) { |
|
743 if ( attrs.size && attachment.sizes[ attrs.size ] ) { |
|
744 attachment.thumbnail = attachment.sizes[ attrs.size ]; |
|
745 } else if ( attachment.sizes.thumbnail ) { |
|
746 attachment.thumbnail = attachment.sizes.thumbnail; |
|
747 } else if ( attachment.sizes.full ) { |
|
748 attachment.thumbnail = attachment.sizes.full; |
|
749 } |
|
750 } |
|
751 } ); |
|
752 |
|
753 self.render( self.template( { |
|
754 attachments: attachments, |
|
755 columns: attrs.columns ? parseInt( attrs.columns, 10 ) : wp.media.galleryDefaults.columns |
|
756 } ) ); |
|
757 } ) |
|
758 .fail( function( jqXHR, textStatus ) { |
|
759 self.setError( textStatus ); |
|
760 } ); |
|
761 } |
|
762 } ); |
|
763 |
|
764 av = _.extend( {}, media, { |
|
765 action: 'parse-media-shortcode', |
|
766 |
|
767 initialize: function() { |
|
768 var self = this; |
|
769 |
|
770 if ( this.url ) { |
|
771 this.loader = false; |
|
772 this.shortcode = wp.media.embed.shortcode( { |
|
773 url: this.text |
|
774 } ); |
|
775 } |
|
776 |
|
777 wp.ajax.post( this.action, { |
|
778 post_ID: postID, |
|
779 type: this.shortcode.tag, |
|
780 shortcode: this.shortcode.string() |
|
781 } ) |
|
782 .done( function( response ) { |
|
783 self.render( response ); |
|
784 } ) |
|
785 .fail( function( response ) { |
|
786 if ( self.url ) { |
|
787 self.removeMarkers(); |
|
788 } else { |
|
789 self.setError( response.message || response.statusText, 'admin-media' ); |
|
790 } |
|
791 } ); |
|
792 |
|
793 this.getEditors( function( editor ) { |
|
794 editor.on( 'wpview-selected', function() { |
|
795 self.pausePlayers(); |
|
796 } ); |
|
797 } ); |
|
798 }, |
|
799 |
|
800 pausePlayers: function() { |
|
801 this.getNodes( function( editor, node, content ) { |
|
802 var win = $( 'iframe.wpview-sandbox', content ).get( 0 ); |
|
803 |
|
804 if ( win && ( win = win.contentWindow ) && win.mejs ) { |
|
805 _.each( win.mejs.players, function( player ) { |
|
806 try { |
|
807 player.pause(); |
|
808 } catch ( e ) {} |
|
809 } ); |
|
810 } |
|
811 } ); |
|
812 } |
|
813 } ); |
|
814 |
|
815 embed = _.extend( {}, av, { |
|
816 action: 'parse-embed', |
|
817 |
|
818 edit: function( text, update ) { |
|
819 var media = wp.media.embed, |
|
820 frame = media.edit( text, this.url ), |
|
821 self = this; |
|
822 |
|
823 this.pausePlayers(); |
|
824 |
|
825 frame.state( 'embed' ).props.on( 'change:url', function( model, url ) { |
|
826 if ( url && model.get( 'url' ) ) { |
|
827 frame.state( 'embed' ).metadata = model.toJSON(); |
|
828 } |
|
829 } ); |
|
830 |
|
831 frame.state( 'embed' ).on( 'select', function() { |
|
832 var data = frame.state( 'embed' ).metadata; |
|
833 |
|
834 if ( self.url ) { |
|
835 update( data.url ); |
|
836 } else { |
|
837 update( media.shortcode( data ).string() ); |
|
838 } |
|
839 } ); |
|
840 |
|
841 frame.on( 'close', function() { |
|
842 frame.detach(); |
|
843 } ); |
|
844 |
|
845 frame.open(); |
|
846 } |
|
847 } ); |
|
848 |
|
849 views.register( 'gallery', _.extend( {}, gallery ) ); |
|
850 |
|
851 views.register( 'audio', _.extend( {}, av, { |
|
852 state: [ 'audio-details' ] |
|
853 } ) ); |
|
854 |
|
855 views.register( 'video', _.extend( {}, av, { |
|
856 state: [ 'video-details' ] |
|
857 } ) ); |
|
858 |
|
859 views.register( 'playlist', _.extend( {}, av, { |
|
860 state: [ 'playlist-edit', 'video-playlist-edit' ] |
|
861 } ) ); |
|
862 |
|
863 views.register( 'embed', _.extend( {}, embed ) ); |
|
864 |
|
865 views.register( 'embedURL', _.extend( {}, embed, { |
|
866 match: function( content ) { |
|
867 var re = /(^|<p>)(https?:\/\/[^\s"]+?)(<\/p>\s*|$)/gi, |
|
868 match = re.exec( content ); |
|
869 |
|
870 if ( match ) { |
|
871 return { |
|
872 index: match.index + match[1].length, |
|
873 content: match[2], |
|
874 options: { |
|
875 url: true |
|
876 } |
|
877 }; |
|
878 } |
|
879 } |
|
880 } ) ); |
|
881 } )( window, window.wp.mce.views, window.jQuery ); |