7bbf46657ba91af8799093e81da485b848f46321
[autoinstalls/wordpress.git] / wp-includes / js / tinymce / plugins / wpview / plugin.js
1 /* global tinymce */
2
3 /**
4  * WordPress View plugin.
5  */
6 tinymce.PluginManager.add( 'wpview', function( editor ) {
7         var selected,
8                 Env = tinymce.Env,
9                 VK = tinymce.util.VK,
10                 TreeWalker = tinymce.dom.TreeWalker,
11                 toRemove = false,
12                 firstFocus = true,
13                 _noop = function() { return false; },
14                 isios = /iPad|iPod|iPhone/.test( navigator.userAgent ),
15                 cursorInterval, lastKeyDownNode, setViewCursorTries, focus, execCommandView;
16
17         function getView( node ) {
18                 return getParent( node, 'wpview-wrap' );
19         }
20
21         /**
22          * Returns the node or a parent of the node that has the passed className.
23          * Doing this directly is about 40% faster
24          */
25         function getParent( node, className ) {
26                 while ( node && node.parentNode ) {
27                         if ( node.className && ( ' ' + node.className + ' ' ).indexOf( ' ' + className + ' ' ) !== -1 ) {
28                                 return node;
29                         }
30
31                         node = node.parentNode;
32                 }
33
34                 return false;
35         }
36
37         /**
38          * Get the text/shortcode string for a view.
39          *
40          * @param view The view wrapper's node
41          * @returns string The text/shoercode string of the view
42          */
43         function getViewText( view ) {
44                 if ( view = getView( view ) ) {
45                         return window.decodeURIComponent( editor.dom.getAttrib( view, 'data-wpview-text' ) || '' );
46                 }
47
48                 return '';
49         }
50
51         /**
52          * Set the view's original text/shortcode string
53          *
54          * @param view The view wrapper's HTML id or node
55          * @param text The text string to be set
56          */
57         function setViewText( view, text ) {
58                 view = getView( view );
59
60                 if ( view ) {
61                         editor.dom.setAttrib( view, 'data-wpview-text', window.encodeURIComponent( text || '' ) );
62                         return true;
63                 }
64
65                 return false;
66         }
67
68         function _stop( event ) {
69                 event.stopPropagation();
70         }
71
72         function setViewCursor( before, view ) {
73                 var location = before ? 'before' : 'after',
74                         offset = before ? 0 : 1;
75                 deselect();
76                 editor.selection.setCursorLocation( editor.dom.select( '.wpview-selection-' + location, view )[0], offset );
77                 editor.nodeChanged();
78         }
79
80         function handleEnter( view, before, key ) {
81                 var dom = editor.dom,
82                         padNode = dom.create( 'p' );
83
84                 if ( ! ( Env.ie && Env.ie < 11 ) ) {
85                         padNode.innerHTML = '<br data-mce-bogus="1">';
86                 }
87
88                 if ( before ) {
89                         view.parentNode.insertBefore( padNode, view );
90                 } else {
91                         dom.insertAfter( padNode, view );
92                 }
93
94                 deselect();
95
96                 if ( before && key === VK.ENTER ) {
97                         setViewCursor( before, view );
98                 } else {
99                         editor.selection.setCursorLocation( padNode, 0 );
100                 }
101
102                 editor.nodeChanged();
103         }
104
105         function removeView( view ) {
106                 // TODO: trigger an event to run a clean up function.
107                 // Maybe `jQuery( view ).trigger( 'remove' );`?
108                 editor.undoManager.transact( function() {
109                         handleEnter( view );
110                         editor.dom.remove( view );
111                 });
112         }
113
114         function select( viewNode ) {
115                 var clipboard,
116                         dom = editor.dom;
117
118                 // Bail if node is already selected.
119                 if ( ! viewNode || viewNode === selected ) {
120                         return;
121                 }
122
123                 // Make sure that the editor is focused.
124                 // It is possible that the editor is not focused when the mouse event fires
125                 // without focus, the selection will not work properly.
126                 editor.getBody().focus();
127
128                 deselect();
129                 selected = viewNode;
130                 dom.setAttrib( viewNode, 'data-mce-selected', 1 );
131
132                 clipboard = dom.create( 'div', {
133                         'class': 'wpview-clipboard',
134                         'contenteditable': 'true'
135                 }, getViewText( viewNode ) );
136
137                 editor.dom.select( '.wpview-body', viewNode )[0].appendChild( clipboard );
138
139                 // Both of the following are necessary to prevent manipulating the selection/focus
140                 dom.bind( clipboard, 'beforedeactivate focusin focusout', _stop );
141                 dom.bind( selected, 'beforedeactivate focusin focusout', _stop );
142
143                 // select the hidden div
144                 if ( isios ) {
145                         editor.selection.select( clipboard );
146                 } else {
147                         editor.selection.select( clipboard, true );
148                 }
149
150                 editor.nodeChanged();
151                 editor.fire( 'wpview-selected', viewNode );
152         }
153
154         /**
155          * Deselect a selected view and remove clipboard
156          */
157         function deselect() {
158                 var clipboard,
159                         dom = editor.dom;
160
161                 if ( selected ) {
162                         clipboard = editor.dom.select( '.wpview-clipboard', selected )[0];
163                         dom.unbind( clipboard );
164                         dom.remove( clipboard );
165
166                         dom.unbind( selected, 'beforedeactivate focusin focusout click mouseup', _stop );
167                         dom.setAttrib( selected, 'data-mce-selected', null );
168                 }
169
170                 selected = null;
171         }
172
173         // Check if the `wp.mce` API exists.
174         if ( typeof wp === 'undefined' || ! wp.mce ) {
175                 return {
176                         getViewText: _noop,
177                         setViewText: _noop,
178                         getView: _noop
179                 };
180         }
181
182         // Remove the content of view wrappers from HTML string
183         function emptyViews( content ) {
184                 return content.replace(/<div[^>]+data-wpview-text=\"([^"]+)"[^>]*>[\s\S]+?wpview-selection-after[^>]+>(?:&nbsp;|\u00a0)*<\/p><\/div>/g, '$1' );
185         }
186
187         // Prevent adding undo levels on changes inside a view wrapper
188         editor.on( 'BeforeAddUndo', function( event ) {
189                 if ( event.lastLevel && emptyViews( event.level.content ) === emptyViews( event.lastLevel.content ) ) {
190                         event.preventDefault();
191                 }
192         });
193
194         // When the editor's content changes, scan the new content for
195         // matching view patterns, and transform the matches into
196         // view wrappers.
197         editor.on( 'BeforeSetContent', function( event ) {
198                 var node;
199
200                 if ( ! event.content ) {
201                         return;
202                 }
203
204                 if ( selected ) {
205                         removeView( selected );
206                 }
207
208                 node = editor.selection.getNode();
209
210                 // When a url is pasted, only try to embed it when pasted in an empty paragrapgh.
211                 if ( event.content.match( /^\s*(https?:\/\/[^\s"]+)\s*$/i ) &&
212                         ( node.nodeName !== 'P' || node.parentNode !== editor.getBody() || ! editor.dom.isEmpty( node ) ) ) {
213                         return;
214                 }
215
216                 event.content = wp.mce.views.toViews( event.content );
217         });
218
219         // When the editor's content has been updated and the DOM has been
220         // processed, render the views in the document.
221         editor.on( 'SetContent', function() {
222                 wp.mce.views.render();
223         });
224
225         // Set the cursor before or after a view when clicking next to it.
226         editor.on( 'click', function( event ) {
227                 var x = event.clientX,
228                         y = event.clientY,
229                         body = editor.getBody(),
230                         bodyRect = body.getBoundingClientRect(),
231                         first = body.firstChild,
232                         firstRect = first.getBoundingClientRect(),
233                         last = body.lastChild,
234                         lastRect = last.getBoundingClientRect(),
235                         view;
236
237                 if ( y < firstRect.top && ( view = getView( first ) ) ) {
238                         setViewCursor( true, view );
239                         event.preventDefault();
240                 } else if ( y > lastRect.bottom && ( view = getView( last ) ) ) {
241                         setViewCursor( false, view );
242                         event.preventDefault();
243                 } else {
244                         tinymce.each( editor.dom.select( '.wpview-wrap' ), function( view ) {
245                                 var rect = view.getBoundingClientRect();
246
247                                 if ( y >= rect.top && y <= rect.bottom ) {
248                                         if ( x < bodyRect.left ) {
249                                                 setViewCursor( true, view );
250                                                 event.preventDefault();
251                                         } else if ( x > bodyRect.right ) {
252                                                 setViewCursor( false, view );
253                                                 event.preventDefault();
254                                         }
255                                         return;
256                                 }
257                         });
258                 }
259         });
260
261         editor.on( 'init', function() {
262                 var scrolled = false,
263                         selection = editor.selection,
264                         MutationObserver = window.MutationObserver || window.WebKitMutationObserver;
265
266                 // When a view is selected, ensure content that is being pasted
267                 // or inserted is added to a text node (instead of the view).
268                 editor.on( 'BeforeSetContent', function() {
269                         var walker, target,
270                                 view = getView( selection.getNode() );
271
272                         // If the selection is not within a view, bail.
273                         if ( ! view ) {
274                                 return;
275                         }
276
277                         if ( ! view.nextSibling || getView( view.nextSibling ) ) {
278                                 // If there are no additional nodes or the next node is a
279                                 // view, create a text node after the current view.
280                                 target = editor.getDoc().createTextNode('');
281                                 editor.dom.insertAfter( target, view );
282                         } else {
283                                 // Otherwise, find the next text node.
284                                 walker = new TreeWalker( view.nextSibling, view.nextSibling );
285                                 target = walker.next();
286                         }
287
288                         // Select the `target` text node.
289                         selection.select( target );
290                         selection.collapse( true );
291                 });
292
293                 editor.dom.bind( editor.getDoc(), 'touchmove', function() {
294                         scrolled = true;
295                 });
296
297                 editor.on( 'mousedown mouseup click touchend', function( event ) {
298                         var view = getView( event.target );
299
300                         firstFocus = false;
301
302                         // Contain clicks inside the view wrapper
303                         if ( view ) {
304                                 event.stopImmediatePropagation();
305                                 event.preventDefault();
306
307                                 if ( ( event.type === 'touchend' || event.type === 'mousedown' ) && ! event.metaKey && ! event.ctrlKey ) {
308                                         if ( editor.dom.hasClass( event.target, 'edit' ) ) {
309                                                 wp.mce.views.edit( view );
310                                                 editor.focus();
311                                                 return false;
312                                         } else if ( editor.dom.hasClass( event.target, 'remove' ) ) {
313                                                 removeView( view );
314                                                 return false;
315                                         }
316                                 }
317
318                                 if ( event.type === 'touchend' && scrolled ) {
319                                         scrolled = false;
320                                 } else {
321                                         select( view );
322                                 }
323
324                                 // Returning false stops the ugly bars from appearing in IE11 and stops the view being selected as a range in FF.
325                                 // Unfortunately, it also inhibits the dragging of views to a new location.
326                                 return false;
327                         } else {
328                                 if ( event.type === 'touchend' || event.type === 'mousedown' ) {
329                                         deselect();
330                                 }
331                         }
332
333                         if ( event.type === 'touchend' && scrolled ) {
334                                 scrolled = false;
335                         }
336                 }, true );
337
338                 if ( MutationObserver ) {
339                         new MutationObserver( function() {
340                                 editor.fire( 'wp-body-class-change' );
341                         } )
342                         .observe( editor.getBody(), {
343                                 attributes: true,
344                                 attributeFilter: ['class']
345                         } );
346                 }
347         });
348
349         editor.on( 'PreProcess', function( event ) {
350                 // Empty the wpview wrap nodes
351                 tinymce.each( editor.dom.select( 'div[data-wpview-text]', event.node ), function( node ) {
352                         node.textContent = node.innerText = '\u00a0';
353                 });
354     });
355
356     editor.on( 'PostProcess', function( event ) {
357                 if ( event.content ) {
358                         event.content = event.content.replace( /<div [^>]*?data-wpview-text="([^"]*)"[^>]*>[\s\S]*?<\/div>/g, function( match, shortcode ) {
359                                 if ( shortcode ) {
360                                         return '<p>' + window.decodeURIComponent( shortcode ) + '</p>';
361                                 }
362                                 return ''; // If error, remove the view wrapper
363                         });
364                 }
365         });
366
367         // Excludes arrow keys, delete, backspace, enter, space bar.
368         // Ref: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent.keyCode
369         function isSpecialKey( key ) {
370                 return ( ( key <= 47 && key !== VK.SPACEBAR && key !== VK.ENTER && key !== VK.DELETE && key !== VK.BACKSPACE && ( key < 37 || key > 40 ) ) ||
371                         key >= 224 || // OEM or non-printable 
372                         ( key >= 144 && key <= 150 ) || // Num Lock, Scroll Lock, OEM
373                         ( key >= 91 && key <= 93 ) || // Windows keys
374                         ( key >= 112 && key <= 135 ) ); // F keys
375         }
376
377         // (De)select views when arrow keys are used to navigate the content of the editor.
378         editor.on( 'keydown', function( event ) {
379                 var key = event.keyCode,
380                         dom = editor.dom,
381                         selection = editor.selection,
382                         node, view, cursorBefore, cursorAfter,
383                         range, clonedRange, tempRange;
384
385                 if ( selected ) {
386                         // Ignore key presses that involve the command or control key, but continue when in combination with backspace or v.
387                         // Also ignore the F# keys.
388                         if ( ( ( event.metaKey || event.ctrlKey ) && key !== VK.BACKSPACE && key !== 86 ) || ( key >= 112 && key <= 123 ) ) {
389                                 // Remove the view when pressing cmd/ctrl+x on keyup, otherwise the browser can't copy the content.
390                                 if ( ( event.metaKey || event.ctrlKey ) && key === 88 ) {
391                                         toRemove = selected;
392                                 }
393                                 return;
394                         }
395
396                         view = getView( selection.getNode() );
397
398                         // If the caret is not within the selected view, deselect the view and bail.
399                         if ( view !== selected ) {
400                                 deselect();
401                                 return;
402                         }
403
404                         if ( key === VK.LEFT ) {
405                                 setViewCursor( true, view );
406                                 event.preventDefault();
407                         } else if ( key === VK.UP ) {
408                                 if ( view.previousSibling ) {
409                                         if ( getView( view.previousSibling ) ) {
410                                                 setViewCursor( true, view.previousSibling );
411                                         } else {
412                                                 deselect();
413                                                 selection.select( view.previousSibling, true );
414                                                 selection.collapse();
415                                         }
416                                 } else {
417                                         setViewCursor( true, view );
418                                 }
419                                 event.preventDefault();
420                         } else if ( key === VK.RIGHT ) {
421                                 setViewCursor( false, view );
422                                 event.preventDefault();
423                         } else if ( key === VK.DOWN ) {
424                                 if ( view.nextSibling ) {
425                                         if ( getView( view.nextSibling ) ) {
426                                                 setViewCursor( false, view.nextSibling );
427                                         } else {
428                                                 deselect();
429                                                 selection.setCursorLocation( view.nextSibling, 0 );
430                                         }
431                                 } else {
432                                         setViewCursor( false, view );
433                                 }
434
435                                 event.preventDefault();
436                         // Ignore keys that don't insert anything.
437                         } else if ( ! isSpecialKey( key ) ) {
438                                 removeView( selected );
439
440                                 if ( key === VK.ENTER || key === VK.DELETE || key === VK.BACKSPACE ) {
441                                         event.preventDefault();
442                                 }
443                         }
444                 } else {
445                         if ( event.metaKey || event.ctrlKey || ( key >= 112 && key <= 123 ) ) {
446                                 return;
447                         }
448
449                         node = selection.getNode();
450                         lastKeyDownNode = node;
451                         view = getView( node );
452
453                         // Make sure we don't delete part of a view.
454                         // If the range ends or starts with the view, we'll need to trim it.
455                         if ( ! selection.isCollapsed() ) {
456                                 range = selection.getRng();
457
458                                 if ( view = getView( range.endContainer ) ) {
459                                         clonedRange = range.cloneRange();
460                                         selection.select( view.previousSibling, true );
461                                         selection.collapse();
462                                         tempRange = selection.getRng();
463                                         clonedRange.setEnd( tempRange.endContainer, tempRange.endOffset );
464                                         selection.setRng( clonedRange );
465                                 } else if ( view = getView( range.startContainer ) ) {
466                                         clonedRange = range.cloneRange();
467                                         clonedRange.setStart( view.nextSibling, 0 );
468                                         selection.setRng( clonedRange );
469                                 }
470                         }
471
472                         if ( ! view ) {
473                                 // Make sure we don't eat any content.
474                                 if ( event.keyCode === VK.BACKSPACE ) {
475                                         if ( editor.dom.isEmpty( node ) ) {
476                                                 if ( view = getView( node.previousSibling ) ) {
477                                                         setViewCursor( false, view );
478                                                         editor.dom.remove( node );
479                                                         event.preventDefault();
480                                                 }
481                                         } else if ( ( range = selection.getRng() ) &&
482                                                         range.startOffset === 0 &&
483                                                         range.endOffset === 0 &&
484                                                         ( view = getView( node.previousSibling ) ) ) {
485                                                 setViewCursor( false, view );
486                                                 event.preventDefault();
487                                         }
488                                 }
489                                 return;
490                         }
491
492                         if ( ! ( ( cursorBefore = dom.hasClass( view, 'wpview-selection-before' ) ) ||
493                                         ( cursorAfter = dom.hasClass( view, 'wpview-selection-after' ) ) ) ) {
494                                 return;
495                         }
496
497                         if ( isSpecialKey( key ) ) {
498                                 // ignore
499                                 return;
500                         }
501
502                         if ( ( cursorAfter && key === VK.UP ) || ( cursorBefore && key === VK.BACKSPACE ) ) {
503                                 if ( view.previousSibling ) {
504                                         if ( getView( view.previousSibling ) ) {
505                                                 setViewCursor( false, view.previousSibling );
506                                         } else {
507                                                 if ( dom.isEmpty( view.previousSibling ) && key === VK.BACKSPACE ) {
508                                                         dom.remove( view.previousSibling );
509                                                 } else {
510                                                         selection.select( view.previousSibling, true );
511                                                         selection.collapse();
512                                                 }
513                                         }
514                                 } else {
515                                         setViewCursor( true, view );
516                                 }
517                                 event.preventDefault();
518                         } else if ( cursorAfter && ( key === VK.DOWN || key === VK.RIGHT ) ) {
519                                 if ( view.nextSibling ) {
520                                         if ( getView( view.nextSibling ) ) {
521                                                 setViewCursor( key === VK.RIGHT, view.nextSibling );
522                                         } else {
523                                                 selection.setCursorLocation( view.nextSibling, 0 );
524                                         }
525                                 }
526                                 event.preventDefault();
527                         } else if ( cursorBefore && ( key === VK.UP || key ===  VK.LEFT ) ) {
528                                 if ( view.previousSibling ) {
529                                         if ( getView( view.previousSibling ) ) {
530                                                 setViewCursor( key === VK.UP, view.previousSibling );
531                                         } else {
532                                                 selection.select( view.previousSibling, true );
533                                                 selection.collapse();
534                                         }
535                                 }
536                                 event.preventDefault();
537                         } else if ( cursorBefore && key === VK.DOWN ) {
538                                 if ( view.nextSibling ) {
539                                         if ( getView( view.nextSibling ) ) {
540                                                 setViewCursor( true, view.nextSibling );
541                                         } else {
542                                                 selection.setCursorLocation( view.nextSibling, 0 );
543                                         }
544                                 } else {
545                                         setViewCursor( false, view );
546                                 }
547                                 event.preventDefault();
548                         } else if ( ( cursorAfter && key === VK.LEFT ) || ( cursorBefore && key === VK.RIGHT ) ) {
549                                 select( view );
550                                 event.preventDefault();
551                         } else if ( cursorAfter && key === VK.BACKSPACE ) {
552                                 removeView( view );
553                                 event.preventDefault();
554                         } else if ( cursorAfter ) {
555                                 handleEnter( view );
556                         } else if ( cursorBefore ) {
557                                 handleEnter( view , true, key );
558                         }
559
560                         if ( key === VK.ENTER ) {
561                                 event.preventDefault();
562                         }
563                 }
564         });
565
566         editor.on( 'keyup', function() {
567                 if ( toRemove ) {
568                         removeView( toRemove );
569                         toRemove = false;
570                 }
571         });
572
573         editor.on( 'focus', function() {
574                 var view;
575
576                 focus = true;
577                 editor.dom.addClass( editor.getBody(), 'has-focus' );
578
579                 // Edge case: show the fake caret when the editor is focused for the first time
580                 // and the first element is a view.
581                 if ( firstFocus && ( view = getView( editor.getBody().firstChild ) ) ) {
582                         setViewCursor( true, view );
583                 }
584
585                 firstFocus = false;
586         } );
587
588         editor.on( 'blur', function() {
589                 focus = false;
590                 editor.dom.removeClass( editor.getBody(), 'has-focus' );
591         } );
592
593         editor.on( 'NodeChange', function( event ) {
594                 var dom = editor.dom,
595                         views = editor.dom.select( '.wpview-wrap' ),
596                         className = event.element.className,
597                         view = getView( event.element ),
598                         lKDN = lastKeyDownNode;
599
600                 lastKeyDownNode = false;
601
602                 clearInterval( cursorInterval );
603
604                 // This runs a lot and is faster than replacing each class separately
605                 tinymce.each( views, function ( view ) {
606                         if ( view.className ) {
607                                 view.className = view.className.replace( / ?\bwpview-(?:selection-before|selection-after|cursor-hide)\b/g, '' );
608                         }
609                 });
610
611                 if ( focus && view ) {
612                         if ( ( className === 'wpview-selection-before' || className === 'wpview-selection-after' ) &&
613                                 editor.selection.isCollapsed() ) {
614
615                                 setViewCursorTries = 0;
616
617                                 deselect();
618
619                                 // Make sure the cursor arrived in the right node.
620                                 // This is necessary for Firefox.
621                                 if ( lKDN === view.previousSibling ) {
622                                         setViewCursor( true, view );
623                                         return;
624                                 } else if ( lKDN === view.nextSibling ) {
625                                         setViewCursor( false, view );
626                                         return;
627                                 }
628
629                                 dom.addClass( view, className );
630
631                                 cursorInterval = setInterval( function() {
632                                         if ( dom.hasClass( view, 'wpview-cursor-hide' ) ) {
633                                                 dom.removeClass( view, 'wpview-cursor-hide' );
634                                         } else {
635                                                 dom.addClass( view, 'wpview-cursor-hide' );
636                                         }
637                                 }, 500 );
638                         // If the cursor lands anywhere else in the view, set the cursor before it.
639                         // Only try this once to prevent a loop. (You never know.)
640                         } else if ( ! getParent( event.element, 'wpview-clipboard' ) && ! setViewCursorTries ) {
641                                 deselect();
642                                 setViewCursorTries++;
643                                 setViewCursor( true, view );
644                         }
645                 }
646         });
647
648         editor.on( 'BeforeExecCommand', function() {
649                 var node = editor.selection.getNode(),
650                         view;
651
652                 if ( node && ( node.className === 'wpview-selection-before' || node.className === 'wpview-selection-after' ) && ( view = getView( node ) ) ) {
653                         handleEnter( view );
654                         execCommandView = view;
655                 }
656         });
657
658         editor.on( 'ExecCommand', function() {
659                 var toSelect, node;
660
661                 if ( selected ) {
662                         toSelect = selected;
663                         deselect();
664                         select( toSelect );
665                 }
666
667                 if ( execCommandView ) {
668                         node = execCommandView.nextSibling;
669
670                         if ( node && node.nodeName === 'P' && editor.dom.isEmpty( node ) ) {
671                                 editor.dom.remove( node );
672                                 setViewCursor( false, execCommandView );
673                         }
674
675                         execCommandView = false;
676                 }
677         });
678
679         editor.on( 'ResolveName', function( event ) {
680                 if ( editor.dom.hasClass( event.target, 'wpview-wrap' ) ) {
681                         event.name = editor.dom.getAttrib( event.target, 'data-wpview-type' ) || 'wpview';
682                         event.stopPropagation();
683                 } else if ( getView( event.target ) ) {
684                         event.preventDefault();
685                         event.stopPropagation();
686                 }
687         });
688
689         return {
690                 getViewText: getViewText,
691                 setViewText: setViewText,
692                 getView: getView
693         };
694 });