187
* @summary Checks if a cursor is inside an HTML tag.
189
* In order to prevent breaking HTML tags when selecting text, the cursor
190
* must be moved to either the start or end of the tag.
192
* This will prevent the selection marker to be inserted in the middle of an HTML tag.
194
* This function gives information whether the cursor is inside a tag or not, as well as
195
* the tag type, if it is a closing tag and check if the HTML tag is inside a shortcode tag,
196
* e.g. `[caption]<img.../>..`.
198
* @param {string} content The test content where the cursor is.
199
* @param {number} cursorPosition The cursor position inside the content.
201
* @returns {(null|Object)} Null if cursor is not in a tag, Object if the cursor is inside a tag.
203
function getContainingTagInfo( content, cursorPosition ) {
204
var lastLtPos = content.lastIndexOf( '<', cursorPosition - 1 ),
205
lastGtPos = content.lastIndexOf( '>', cursorPosition );
207
if ( lastLtPos > lastGtPos || content.substr( cursorPosition, 1 ) === '>' ) {
208
// find what the tag is
209
var tagContent = content.substr( lastLtPos ),
210
tagMatch = tagContent.match( /<\s*(\/)?(\w+)/ );
216
var tagType = tagMatch[2],
217
closingGt = tagContent.indexOf( '>' );
221
gtPos: lastLtPos + closingGt + 1, // offset by one to get the position _after_ the character,
223
isClosingTag: !! tagMatch[1]
230
* @summary Check if the cursor is inside a shortcode
232
* If the cursor is inside a shortcode wrapping tag, e.g. `[caption]` it's better to
233
* move the selection marker to before or after the shortcode.
235
* For example `[caption]` rewrites/removes anything that's between the `[caption]` tag and the
236
* `<img/>` tag inside.
238
* `[caption]<span>ThisIsGone</span><img .../>[caption]`
240
* Moving the selection to before or after the short code is better, since it allows to select
241
* something, instead of just losing focus and going to the start of the content.
243
* @param {string} content The text content to check against.
244
* @param {number} cursorPosition The cursor position to check.
246
* @return {(undefined|Object)} Undefined if the cursor is not wrapped in a shortcode tag.
247
* Information about the wrapping shortcode tag if it's wrapped in one.
249
function getShortcodeWrapperInfo( content, cursorPosition ) {
250
var contentShortcodes = getShortCodePositionsInText( content );
252
for ( var i = 0; i < contentShortcodes.length; i++ ) {
253
var element = contentShortcodes[ i ];
255
if ( cursorPosition >= element.startIndex && cursorPosition <= element.endIndex ) {
262
* Gets a list of unique shortcodes or shortcode-look-alikes in the content.
264
* @param {string} content The content we want to scan for shortcodes.
266
function getShortcodesInText( content ) {
267
var shortcodes = content.match( /\[+([\w_-])+/g ),
271
for ( var i = 0; i < shortcodes.length; i++ ) {
272
var shortcode = shortcodes[ i ].replace( /^\[+/g, '' );
274
if ( result.indexOf( shortcode ) === -1 ) {
275
result.push( shortcode );
284
* @summary Check if a shortcode has Live Preview enabled for it.
286
* Previewable shortcodes here refers to shortcodes that have Live Preview enabled.
288
* These shortcodes get rewritten when the editor is in Visual mode, which means that
289
* we don't want to change anything inside them, i.e. inserting a selection marker
290
* inside the shortcode will break it :(
292
* @link wp-includes/js/mce-view.js
294
* @param {string} shortcode The shortcode to check.
295
* @return {boolean} If a shortcode has Live Preview or not
297
function isShortcodePreviewable( shortcode ) {
298
var defaultPreviewableShortcodes = [ 'caption' ];
301
defaultPreviewableShortcodes.indexOf( shortcode ) !== -1 ||
302
wp.mce.views.get( shortcode ) !== undefined
308
* @summary Get all shortcodes and their positions in the content
310
* This function returns all the shortcodes that could be found in the textarea content
311
* along with their character positions and boundaries.
313
* This is used to check if the selection cursor is inside the boundaries of a shortcode
314
* and move it accordingly, to avoid breakage.
316
* @link adjustTextAreaSelectionCursors
318
* The information can also be used in other cases when we need to lookup shortcode data,
319
* as it's already structured!
321
* @param {string} content The content we want to scan for shortcodes
323
function getShortCodePositionsInText( content ) {
324
var allShortcodes = getShortcodesInText( content ), shortcodeInfo;
326
if ( allShortcodes.length === 0 ) {
330
var shortcodeDetailsRegexp = wp.shortcode.regexp( allShortcodes.join( '|' ) ),
331
shortcodeMatch, // Define local scope for the variable to be used in the loop below.
332
shortcodesDetails = [];
334
while ( shortcodeMatch = shortcodeDetailsRegexp.exec( content ) ) {
336
* Check if the shortcode should be shown as plain text.
338
* This corresponds to the [[shortcode]] syntax, which doesn't parse the shortcode
339
* and just shows it as text.
341
var showAsPlainText = shortcodeMatch[1] === '[';
344
* For more context check the docs for:
346
* @link isShortcodePreviewable
348
* In addition, if the shortcode will get rendered as plain text ( see above ),
349
* we can treat it as text and use the selection markers in it.
351
var isPreviewable = ! showAsPlainText && isShortcodePreviewable( shortcodeMatch[2] );
354
shortcodeName: shortcodeMatch[2],
355
showAsPlainText: showAsPlainText,
356
startIndex: shortcodeMatch.index,
357
endIndex: shortcodeMatch.index + shortcodeMatch[0].length,
358
length: shortcodeMatch[0].length,
359
isPreviewable: isPreviewable
362
shortcodesDetails.push( shortcodeInfo );
366
* Get all URL matches, and treat them as embeds.
368
* Since there isn't a good way to detect if a URL by itself on a line is a previewable
369
* object, it's best to treat all of them as such.
371
* This means that the selection will capture the whole URL, in a similar way shrotcodes
374
var urlRegexp = new RegExp(
375
'(^|[\\n\\r][\\n\\r]|<p>)(https?:\\/\\/[^\s"]+?)(<\\/p>\s*|[\\n\\r][\\n\\r]|$)', 'gi'
378
while ( shortcodeMatch = urlRegexp.exec( content ) ) {
380
shortcodeName: 'url',
381
showAsPlainText: false,
382
startIndex: shortcodeMatch.index,
383
endIndex: shortcodeMatch.index + shortcodeMatch[ 0 ].length,
384
length: shortcodeMatch[ 0 ].length,
386
urlAtStartOfContent: shortcodeMatch[ 1 ] === '',
387
urlAtEndOfContent: shortcodeMatch[ 3 ] === ''
390
shortcodesDetails.push( shortcodeInfo );
393
return shortcodesDetails;
397
* Generate a cursor marker element to be inserted in the content.
399
* `span` seems to be the least destructive element that can be used.
401
* Using DomQuery syntax to create it, since it's used as both text and as a DOM element.
403
* @param {Object} domLib DOM library instance.
404
* @param {string} content The content to insert into the cusror marker element.
406
function getCursorMarkerSpan( domLib, content ) {
407
return domLib( '<span>' ).css( {
408
display: 'inline-block',
413
.html( content ? content : '' );
417
* @summary Get adjusted selection cursor positions according to HTML tags/shortcodes
419
* Shortcodes and HTML codes are a bit of a special case when selecting, since they may render
420
* content in Visual mode. If we insert selection markers somewhere inside them, it's really possible
421
* to break the syntax and render the HTML tag or shortcode broken.
423
* @link getShortcodeWrapperInfo
425
* @param {string} content Textarea content that the cursors are in
426
* @param {{cursorStart: number, cursorEnd: number}} cursorPositions Cursor start and end positions
428
* @return {{cursorStart: number, cursorEnd: number}}
430
function adjustTextAreaSelectionCursors( content, cursorPositions ) {
432
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
433
'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr'
436
var cursorStart = cursorPositions.cursorStart,
437
cursorEnd = cursorPositions.cursorEnd,
438
// check if the cursor is in a tag and if so, adjust it
439
isCursorStartInTag = getContainingTagInfo( content, cursorStart );
441
if ( isCursorStartInTag ) {
443
* Only move to the start of the HTML tag (to select the whole element) if the tag
444
* is part of the voidElements list above.
446
* This list includes tags that are self-contained and don't need a closing tag, according to the
447
* HTML5 specification.
449
* This is done in order to make selection of text a bit more consistent when selecting text in
450
* `<p>` tags or such.
452
* In cases where the tag is not a void element, the cursor is put to the end of the tag,
453
* so it's either between the opening and closing tag elements or after the closing tag.
455
if ( voidElements.indexOf( isCursorStartInTag.tagType ) !== -1 ) {
456
cursorStart = isCursorStartInTag.ltPos;
458
cursorStart = isCursorStartInTag.gtPos;
462
var isCursorEndInTag = getContainingTagInfo( content, cursorEnd );
463
if ( isCursorEndInTag ) {
464
cursorEnd = isCursorEndInTag.gtPos;
467
var isCursorStartInShortcode = getShortcodeWrapperInfo( content, cursorStart );
468
if ( isCursorStartInShortcode && isCursorStartInShortcode.isPreviewable ) {
470
* If a URL is at the start or the end of the content,
471
* the selection doesn't work, because it inserts a marker in the text,
472
* which breaks the embedURL detection.
474
* The best way to avoid that and not modify the user content is to
475
* adjust the cursor to either after or before URL.
477
if ( isCursorStartInShortcode.urlAtStartOfContent ) {
478
cursorStart = isCursorStartInShortcode.endIndex;
480
cursorStart = isCursorStartInShortcode.startIndex;
484
var isCursorEndInShortcode = getShortcodeWrapperInfo( content, cursorEnd );
485
if ( isCursorEndInShortcode && isCursorEndInShortcode.isPreviewable ) {
486
if ( isCursorEndInShortcode.urlAtEndOfContent ) {
487
cursorEnd = isCursorEndInShortcode.startIndex;
489
cursorEnd = isCursorEndInShortcode.endIndex;
494
cursorStart: cursorStart,
500
* @summary Adds text selection markers in the editor textarea.
502
* Adds selection markers in the content of the editor `textarea`.
503
* The method directly manipulates the `textarea` content, to allow TinyMCE plugins
504
* to run after the markers are added.
506
* @param {object} $textarea TinyMCE's textarea wrapped as a DomQuery object
508
function addHTMLBookmarkInTextAreaContent( $textarea ) {
509
if ( ! $textarea || ! $textarea.length ) {
510
// If no valid $textarea object is provided, there's nothing we can do.
514
var textArea = $textarea[0],
515
textAreaContent = textArea.value,
517
adjustedCursorPositions = adjustTextAreaSelectionCursors( textAreaContent, {
518
cursorStart: textArea.selectionStart,
519
cursorEnd: textArea.selectionEnd
522
htmlModeCursorStartPosition = adjustedCursorPositions.cursorStart,
523
htmlModeCursorEndPosition = adjustedCursorPositions.cursorEnd,
525
mode = htmlModeCursorStartPosition !== htmlModeCursorEndPosition ? 'range' : 'single',
528
cursorMarkerSkeleton = getCursorMarkerSpan( $$, '' ).attr( 'data-mce-type','bookmark' );
530
if ( mode === 'range' ) {
531
var markedText = textArea.value.slice( htmlModeCursorStartPosition, htmlModeCursorEndPosition ),
532
bookMarkEnd = cursorMarkerSkeleton.clone().addClass( 'mce_SELRES_end' );
536
bookMarkEnd[0].outerHTML
541
textArea.value.slice( 0, htmlModeCursorStartPosition ), // text until the cursor/selection position
542
cursorMarkerSkeleton.clone() // cursor/selection start marker
543
.addClass( 'mce_SELRES_start' )[0].outerHTML,
544
selectedText, // selected text with end cursor/position marker
545
textArea.value.slice( htmlModeCursorEndPosition ) // text from last cursor/selection position to end
550
* @summary Focus the selection markers in Visual mode.
552
* The method checks for existing selection markers inside the editor DOM (Visual mode)
553
* and create a selection between the two nodes using the DOM `createRange` selection API
555
* If there is only a single node, select only the single node through TinyMCE's selection API
557
* @param {Object} editor TinyMCE editor instance.
559
function focusHTMLBookmarkInVisualEditor( editor ) {
560
var startNode = editor.$( '.mce_SELRES_start' ).attr( 'data-mce-bogus', 1 ),
561
endNode = editor.$( '.mce_SELRES_end' ).attr( 'data-mce-bogus', 1 );
563
if ( startNode.length ) {
566
if ( ! endNode.length ) {
567
editor.selection.select( startNode[0] );
569
var selection = editor.getDoc().createRange();
571
selection.setStartAfter( startNode[0] );
572
selection.setEndBefore( endNode[0] );
574
editor.selection.setRng( selection );
578
if ( editor.getParam( 'wp_keep_scroll_position' ) ) {
579
scrollVisualModeToStartElement( editor, startNode );
582
removeSelectionMarker( startNode );
583
removeSelectionMarker( endNode );
589
* @summary Remove selection marker and the parent node if it is an empty paragraph.
591
* By default TinyMCE wraps loose inline tags in a `<p>`.
592
* When removing selection markers an empty `<p>` may be left behind, remove it.
594
* @param {object} $marker The marker to be removed from the editor DOM, wrapped in an instnce of `editor.$`
596
function removeSelectionMarker( $marker ) {
597
var $markerParent = $marker.parent();
601
//Remove empty paragraph left over after removing the marker.
602
if ( $markerParent.is( 'p' ) && ! $markerParent.children().length && ! $markerParent.text() ) {
603
$markerParent.remove();
608
* @summary Scrolls the content to place the selected element in the center of the screen.
610
* Takes an element, that is usually the selection start element, selected in
611
* `focusHTMLBookmarkInVisualEditor()` and scrolls the screen so the element appears roughly
612
* in the middle of the screen.
614
* I order to achieve the proper positioning, the editor media bar and toolbar are subtracted
615
* from the window height, to get the proper viewport window, that the user sees.
617
* @param {Object} editor TinyMCE editor instance.
618
* @param {Object} element HTMLElement that should be scrolled into view.
620
function scrollVisualModeToStartElement( editor, element ) {
621
var elementTop = editor.$( element ).offset().top,
622
TinyMCEContentAreaTop = editor.$( editor.getContentAreaContainer() ).offset().top,
624
toolbarHeight = getToolbarHeight( editor ),
626
edTools = $( '#wp-content-editor-tools' ),
628
edToolsOffsetTop = 0,
632
if ( edTools.length ) {
633
edToolsHeight = edTools.height();
634
edToolsOffsetTop = edTools.offset().top;
637
var windowHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight,
639
selectionPosition = TinyMCEContentAreaTop + elementTop,
640
visibleAreaHeight = windowHeight - ( edToolsHeight + toolbarHeight );
642
// There's no need to scroll if the selection is inside the visible area.
643
if ( selectionPosition < visibleAreaHeight ) {
648
* The minimum scroll height should be to the top of the editor, to offer a consistent
651
* In order to find the top of the editor, we calculate the offset of `#wp-content-editor-tools` and
652
* subtracting the height. This gives the scroll position where the top of the editor tools aligns with
653
* the top of the viewport (under the Master Bar)
656
if ( editor.settings.wp_autoresize_on ) {
657
$scrollArea = $( 'html,body' );
658
adjustedScroll = Math.max( selectionPosition - visibleAreaHeight / 2, edToolsOffsetTop - edToolsHeight );
660
$scrollArea = $( editor.contentDocument ).find( 'html,body' );
661
adjustedScroll = elementTop;
664
$scrollArea.animate( {
665
scrollTop: parseInt( adjustedScroll, 10 )
670
* This method was extracted from the `SaveContent` hook in
671
* `wp-includes/js/tinymce/plugins/wordpress/plugin.js`.
673
* It's needed here, since the method changes the content a bit, which confuses the cursor position.
675
* @param {Object} event TinyMCE event object.
677
function fixTextAreaContent( event ) {
678
// Keep empty paragraphs :(
679
event.content = event.content.replace( /<p>(?:<br ?\/?>|\u00a0|\uFEFF| )*<\/p>/g, '<p> </p>' );
683
* @summary Finds the current selection position in the Visual editor.
685
* Find the current selection in the Visual editor by inserting marker elements at the start
686
* and end of the selection.
688
* Uses the standard DOM selection API to achieve that goal.
690
* Check the notes in the comments in the code below for more information on some gotchas
691
* and why this solution was chosen.
693
* @param {Object} editor The editor where we must find the selection
694
* @returns {(null|Object)} The selection range position in the editor
696
function findBookmarkedPosition( editor ) {
697
// Get the TinyMCE `window` reference, since we need to access the raw selection.
698
var TinyMCEWIndow = editor.getWin(),
699
selection = TinyMCEWIndow.getSelection();
701
if ( selection.rangeCount <= 0 ) {
702
// no selection, no need to continue.
707
* The ID is used to avoid replacing user generated content, that may coincide with the
708
* format specified below.
711
var selectionID = 'SELRES_' + Math.random();
714
* Create two marker elements that will be used to mark the start and the end of the range.
716
* The elements have hardcoded style that makes them invisible. This is done to avoid seeing
717
* random content flickering in the editor when switching between modes.
719
var spanSkeleton = getCursorMarkerSpan( editor.$, selectionID ),
720
startElement = spanSkeleton.clone().addClass( 'mce_SELRES_start' ),
721
endElement = spanSkeleton.clone().addClass( 'mce_SELRES_end' );
725
* @link https://stackoverflow.com/a/17497803/153310
727
* Why do it this way and not with TinyMCE's bookmarks?
729
* TinyMCE's bookmarks are very nice when working with selections and positions, BUT
730
* there is no way to determine the precise position of the bookmark when switching modes, since
731
* TinyMCE does some serialization of the content, to fix things like shortcodes, run plugins, prettify
732
* HTML code and so on. In this process, the bookmark markup gets lost.
734
* If we decide to hook right after the bookmark is added, we can see where the bookmark is in the raw HTML
735
* in TinyMCE. Unfortunately this state is before the serialization, so any visual markup in the content will
736
* throw off the positioning.
738
* To avoid this, we insert two custom `span`s that will serve as the markers at the beginning and end of the
741
* Why not use TinyMCE's selection API or the DOM API to wrap the contents? Because if we do that, this creates
742
* a new node, which is inserted in the dom. Now this will be fine, if we worked with fixed selections to
743
* full nodes. Unfortunately in our case, the user can select whatever they like, which means that the
744
* selection may start in the middle of one node and end in the middle of a completely different one. If we
745
* wrap the selection in another node, this will create artifacts in the content.
747
* Using the method below, we insert the custom `span` nodes at the start and at the end of the selection.
748
* This helps us not break the content and also gives us the option to work with multi-node selections without
749
* breaking the markup.
751
var range = selection.getRangeAt( 0 ),
752
startNode = range.startContainer,
753
startOffset = range.startOffset,
754
boundaryRange = range.cloneRange();
757
* If the selection is on a shortcode with Live View, TinyMCE creates a bogus markup,
758
* which we have to account for.
760
if ( editor.$( startNode ).parents( '.mce-offscreen-selection' ).length > 0 ) {
761
startNode = editor.$( '[data-mce-selected]' )[0];
764
* Marking the start and end element with `data-mce-object-selection` helps
765
* discern when the selected object is a Live Preview selection.
767
* This way we can adjust the selection to properly select only the content, ignoring
768
* whitespace inserted around the selected object by the Editor.
770
startElement.attr( 'data-mce-object-selection', 'true' );
771
endElement.attr( 'data-mce-object-selection', 'true' );
773
editor.$( startNode ).before( startElement[0] );
774
editor.$( startNode ).after( endElement[0] );
776
boundaryRange.collapse( false );
777
boundaryRange.insertNode( endElement[0] );
779
boundaryRange.setStart( startNode, startOffset );
780
boundaryRange.collapse( true );
781
boundaryRange.insertNode( startElement[0] );
783
range.setStartAfter( startElement[0] );
784
range.setEndBefore( endElement[0] );
785
selection.removeAllRanges();
786
selection.addRange( range );
790
* Now the editor's content has the start/end nodes.
792
* Unfortunately the content goes through some more changes after this step, before it gets inserted
793
* in the `textarea`. This means that we have to do some minor cleanup on our own here.
795
editor.on( 'GetContent', fixTextAreaContent );
797
var content = removep( editor.getContent() );
799
editor.off( 'GetContent', fixTextAreaContent );
801
startElement.remove();
804
var startRegex = new RegExp(
805
'<span[^>]*\\s*class="mce_SELRES_start"[^>]+>\\s*' + selectionID + '[^<]*<\\/span>(\\s*)'
808
var endRegex = new RegExp(
809
'(\\s*)<span[^>]*\\s*class="mce_SELRES_end"[^>]+>\\s*' + selectionID + '[^<]*<\\/span>'
812
var startMatch = content.match( startRegex ),
813
endMatch = content.match( endRegex );
815
if ( ! startMatch ) {
819
var startIndex = startMatch.index,
820
startMatchLength = startMatch[0].length,
825
* Adjust the selection index, if the selection contains a Live Preview object or not.
827
* Check where the `data-mce-object-selection` attribute is set above for more context.
829
if ( startMatch[0].indexOf( 'data-mce-object-selection' ) !== -1 ) {
830
startMatchLength -= startMatch[1].length;
833
var endMatchIndex = endMatch.index;
835
if ( endMatch[0].indexOf( 'data-mce-object-selection' ) !== -1 ) {
836
endMatchIndex -= endMatch[1].length;
839
// We need to adjust the end position to discard the length of the range start marker
840
endIndex = endMatchIndex - startMatchLength;
850
* @summary Selects text in the TinyMCE `textarea`.
852
* Selects the text in TinyMCE's textarea that's between `selection.start` and `selection.end`.
854
* For `selection` parameter:
855
* @link findBookmarkedPosition
857
* @param {Object} editor TinyMCE's editor instance.
858
* @param {Object} selection Selection data.
860
function selectTextInTextArea( editor, selection ) {
861
// only valid in the text area mode and if we have selection
866
var textArea = editor.getElement(),
867
start = selection.start,
868
end = selection.end || selection.start;
870
if ( textArea.focus ) {
871
// Wait for the Visual editor to be hidden, then focus and scroll to the position
872
setTimeout( function() {
873
textArea.setSelectionRange( start, end );
874
if ( textArea.blur ) {
875
// defocus before focusing
883
// Restore the selection when the editor is initialized. Needed when the Text editor is the default.
884
$( document ).on( 'tinymce-editor-init.keep-scroll-position', function( event, editor ) {
885
if ( editor.$( '.mce_SELRES_start' ).length ) {
886
focusHTMLBookmarkInVisualEditor( editor );
159
891
* @summary Replaces <p> tags with two line breaks. "Opposite" of wpautop().
161
893
* Replaces <p> tags with two line breaks except where the <p> has attributes.