` tags or such. + * + * In cases where the tag is not a void element, the cursor is put to the end of the tag, + * so it's either between the opening and closing tag elements or after the closing tag. + */ + if ( voidElements.indexOf( isCursorStartInTag.tagType ) !== - 1 ) { + htmlModeCursorStartPosition = isCursorStartInTag.ltPos; + } + else { + htmlModeCursorStartPosition = isCursorStartInTag.gtPos; + } + } + + var isCursorEndInTag = getContainingTagInfo( textArea.value, htmlModeCursorEndPosition ); + if ( isCursorEndInTag ) { + htmlModeCursorEndPosition = isCursorEndInTag.gtPos; + } + + var mode = htmlModeCursorStartPosition !== htmlModeCursorEndPosition ? 'range' : 'single'; + + var selectedText = null; + var cursorMarkerSkeleton = getCursorMarkerSpan( { $: jQuery }, '' ); + + if ( mode === 'range' ) { + var markedText = textArea.value.slice( htmlModeCursorStartPosition, htmlModeCursorEndPosition ); + + /** + * Since the shortcodes convert the tags in them a bit, we need to mark the tag itself, + * and not rely on the cursor marker. + * + * @see getShortcodeWrapperInfo + */ + if ( isCursorStartInTag && isCursorStartInTag.shortcodeTagInfo ) { + // Get the tag on the cursor start + var tagEndPosition = isCursorStartInTag.gtPos - isCursorStartInTag.ltPos; + var tagContent = markedText.slice( 0, tagEndPosition ); + + // Check if the tag already has a `class` attribute. + var classMatch = /class=(['"])([^$1]*?)\1/; + + /** + * Add a marker class to the selected tag, to be used later. + * + * @see focusHTMLBookmarkInVisualEditor + */ + if ( tagContent.match( classMatch ) ) { + tagContent = tagContent.replace( classMatch, 'class=$1$2 mce_SELRES_start_target$1' ); + } + else { + tagContent = tagContent.replace( /(<\w+)/, '$1 class="mce_SELRES_start_target" ' ); + } + + // Update the selected text content with the marked tag above + markedText = [ + tagContent, + markedText.substr( tagEndPosition ) + ].join( '' ); + } + + var bookMarkEnd = cursorMarkerSkeleton.clone() + .addClass( 'mce_SELRES_end' )[ 0 ].outerHTML; + + /** + * A small workaround when selecting just a single HTML tag inside a shortcode. + * + * This removes the end selection marker, to make sure the HTML tag is the only selected + * thing. This prevents the selection to appear like it contains multiple items in it (i.e. + * all highlighted blue) + */ + if ( isCursorStartInTag && isCursorStartInTag.shortcodeTagInfo && isCursorEndInTag && + isCursorStartInTag.ltPos === isCursorEndInTag.ltPos ) { + bookMarkEnd = ''; + } + + selectedText = [ + markedText, + bookMarkEnd + ].join( '' ); + } + + textArea.value = [ + textArea.value.slice( 0, htmlModeCursorStartPosition ), // text until the cursor/selection position + cursorMarkerSkeleton.clone() // cursor/selection start marker + .addClass( 'mce_SELRES_start')[0].outerHTML, + selectedText, // selected text with end cursor/position marker + textArea.value.slice( htmlModeCursorEndPosition ) // text from last cursor/selection position to end + ].join( '' ); + } + + /** + * @summary Focus the selection markers in Visual mode. + * + * The method checks for existing selection markers inside the editor DOM (Visual mode) + * and create a selection between the two nodes using the DOM `createRange` selection API + * + * If there is only a single node, select only the single node through TinyMCE's selection API + * + * @param {Object} editor TinyMCE editor instance. + */ + function focusHTMLBookmarkInVisualEditor( editor ) { + var startNode = editor.$( '.mce_SELRES_start' ), + endNode = editor.$( '.mce_SELRES_end' ); + + if ( ! startNode.length ) { + startNode = editor.$( '.mce_SELRES_start_target' ); + } + + if ( startNode.length ) { + editor.focus(); + + if ( ! endNode.length ) { + editor.selection.select( startNode[ 0 ] ); + } else { + var selection = editor.getDoc().createRange(); + + selection.setStartAfter( startNode[ 0 ] ); + selection.setEndBefore( endNode[ 0 ] ); + + editor.selection.setRng( selection ); + } + + scrollVisualModeToStartElement( editor, startNode ); + } + + if ( startNode.hasClass( 'mce_SELRES_start_target' ) ) { + startNode.removeClass( 'mce_SELRES_start_target' ); + } + else { + startNode.remove(); + } + endNode.remove(); + } + + /** + * @summary Scrolls the content to place the selected element in the center of the screen. + * + * Takes an element, that is usually the selection start element, selected in + * `focusHTMLBookmarkInVisualEditor()` and scrolls the screen so the element appears roughly + * in the middle of the screen. + * + * I order to achieve the proper positioning, the editor media bar and toolbar are subtracted + * from the window height, to get the proper viewport window, that the user sees. + * + * @param {Object} editor TinyMCE editor instance. + * @param {Object} element HTMLElement that should be scrolled into view. + */ + function scrollVisualModeToStartElement( editor, element ) { + /** + * TODO: + * * Decide if we should animate the transition or not ( motion sickness/accessibility ) + */ + var elementTop = editor.$( element ).offset().top; + var TinyMCEContentAreaTop = editor.$( editor.getContentAreaContainer() ).offset().top; + + var edTools = $('#wp-content-editor-tools'); + var edToolsHeight = edTools.height(); + var edToolsOffsetTop = edTools.offset().top; + + var toolbarHeight = getToolbarHeight( editor ); + + var windowHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight; + + var selectionPosition = TinyMCEContentAreaTop + elementTop; + var visibleAreaHeight = windowHeight - ( edToolsHeight + toolbarHeight ); + + /** + * The minimum scroll height should be to the top of the editor, to offer a consistent + * experience. + * + * In order to find the top of the editor, we calculate the offset of `#wp-content-editor-tools` and + * subtracting the height. This gives the scroll position where the top of the editor tools aligns with + * the top of the viewport (under the Master Bar) + */ + var adjustedScroll = Math.max(selectionPosition - visibleAreaHeight / 2, edToolsOffsetTop - edToolsHeight); + + + $( 'body' ).animate( { + scrollTop: parseInt( adjustedScroll, 10 ) + }, 100 ); + } + + /** + * This method was extracted from the `SaveContent` hook in + * `wp-includes/js/tinymce/plugins/wordpress/plugin.js`. + * + * It's needed here, since the method changes the content a bit, which confuses the cursor position. + * + * @param {Object} event TinyMCE event object. + */ + function fixTextAreaContent( event ) { + // Keep empty paragraphs :( + event.content = event.content.replace( /
(?:
|\u00a0|\uFEFF| )*<\/p>/g, '
' ); + } + + /** + * @summary Finds the current selection position in the Visual editor. + * + * Find the current selection in the Visual editor by inserting marker elements at the start + * and end of the selection. + * + * Uses the standard DOM selection API to achieve that goal. + * + * Check the notes in the comments in the code below for more information on some gotchas + * and why this solution was chosen. + * + * @param {Object} editor The editor where we must find the selection + * @returns {(null|Object)} The selection range position in the editor + */ + function findBookmarkedPosition( editor ) { + // Get the TinyMCE `window` reference, since we need to access the raw selection. + var TinyMCEWIndow = editor.getWin(), + selection = TinyMCEWIndow.getSelection(); + + if ( selection.rangeCount <= 0 ) { + // no selection, no need to continue. + return; + } + + /** + * The ID is used to avoid replacing user generated content, that may coincide with the + * format specified below. + * @type {string} + */ + var selectionID = 'SELRES_' + Math.random(); + + /** + * Create two marker elements that will be used to mark the start and the end of the range. + * + * The elements have hardcoded style that makes them invisible. This is done to avoid seeing + * random content flickering in the editor when switching between modes. + */ + var spanSkeleton = getCursorMarkerSpan(editor, selectionID); + + var startElement = spanSkeleton.clone().addClass('mce_SELRES_start'); + var endElement = spanSkeleton.clone().addClass('mce_SELRES_end'); + + /** + * Inspired by: + * @link https://stackoverflow.com/a/17497803/153310 + * + * Why do it this way and not with TinyMCE's bookmarks? + * + * TinyMCE's bookmarks are very nice when working with selections and positions, BUT + * there is no way to determine the precise position of the bookmark when switching modes, since + * TinyMCE does some serialization of the content, to fix things like shortcodes, run plugins, prettify + * HTML code and so on. In this process, the bookmark markup gets lost. + * + * If we decide to hook right after the bookmark is added, we can see where the bookmark is in the raw HTML + * in TinyMCE. Unfortunately this state is before the serialization, so any visual markup in the content will + * throw off the positioning. + * + * To avoid this, we insert two custom `span`s that will serve as the markers at the beginning and end of the + * selection. + * + * Why not use TinyMCE's selection API or the DOM API to wrap the contents? Because if we do that, this creates + * a new node, which is inserted in the dom. Now this will be fine, if we worked with fixed selections to + * full nodes. Unfortunately in our case, the user can select whatever they like, which means that the + * selection may start in the middle of one node and end in the middle of a completely different one. If we + * wrap the selection in another node, this will create artifacts in the content. + * + * Using the method below, we insert the custom `span` nodes at the start and at the end of the selection. + * This helps us not break the content and also gives us the option to work with multi-node selections without + * breaking the markup. + */ + var range = selection.getRangeAt( 0 ), + startNode = range.startContainer, + startOffset = range.startOffset, + boundaryRange = range.cloneRange(); + + boundaryRange.collapse( false ); + boundaryRange.insertNode( endElement[0] ); + + /** + * Sometimes the selection starts at the `
tags with two line breaks. "Opposite" of wpautop(). *