<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[41630] trunk/src/wp-admin/js/editor.js: Post Editor: Keep text selection between Visual and Text modes</title>
</head>
<body>
<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; }
#msg dl a { font-weight: bold}
#msg dl a:link { color:#fc3; }
#msg dl a:active { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta" style="font-size: 105%">
<dt style="float: left; width: 6em; font-weight: bold">Revision</dt> <dd><a style="font-weight: bold" href="https://core.trac.wordpress.org/changeset/41630">41630</a><script type="application/ld+json">{"@context":"http://schema.org","@type":"EmailMessage","description":"Review this Commit","action":{"@type":"ViewAction","url":"https://core.trac.wordpress.org/changeset/41630","name":"Review Commit"}}</script></dd>
<dt style="float: left; width: 6em; font-weight: bold">Author</dt> <dd>pento</dd>
<dt style="float: left; width: 6em; font-weight: bold">Date</dt> <dd>2017-09-28 06:44:09 +0000 (Thu, 28 Sep 2017)</dd>
</dl>
<pre style='padding-left: 1em; margin: 2em 0; border-left: 2px solid #ccc; line-height: 1.25; font-size: 105%; font-family: sans-serif'>Post Editor: Keep text selection between Visual and Text modes
When switching between post editor modes, the current cursor position and selection is now preserved. This allows authors to switch modes without losing the context of where they were in the document.
Props biskobe.
Fixes <a href="https://core.trac.wordpress.org/ticket/41962">#41962</a>.</pre>
<h3>Modified Paths</h3>
<ul>
<li><a href="#trunksrcwpadminjseditorjs">trunk/src/wp-admin/js/editor.js</a></li>
</ul>
</div>
<div id="patch">
<h3>Diff</h3>
<a id="trunksrcwpadminjseditorjs"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/src/wp-admin/js/editor.js</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-admin/js/editor.js 2017-09-28 05:36:34 UTC (rev 41629)
+++ trunk/src/wp-admin/js/editor.js 2017-09-28 06:44:09 UTC (rev 41630)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -99,6 +99,9 @@
</span><span class="cx" style="display: block; padding: 0 10px">
</span><span class="cx" style="display: block; padding: 0 10px"> editorHeight = parseInt( textarea.style.height, 10 ) || 0;
</span><span class="cx" style="display: block; padding: 0 10px">
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+ // Save the selection
+ addHTMLBookmarkInTextAreaContent( $textarea, $ );
+
</ins><span class="cx" style="display: block; padding: 0 10px"> if ( editor ) {
</span><span class="cx" style="display: block; padding: 0 10px"> editor.show();
</span><span class="cx" style="display: block; padding: 0 10px">
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -112,8 +115,35 @@
</span><span class="cx" style="display: block; padding: 0 10px"> editor.theme.resizeTo( null, editorHeight );
</span><span class="cx" style="display: block; padding: 0 10px"> }
</span><span class="cx" style="display: block; padding: 0 10px"> }
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+
+ // Restore the selection
+ focusHTMLBookmarkInVisualEditor( editor );
</ins><span class="cx" style="display: block; padding: 0 10px"> } else {
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">- tinymce.init( window.tinyMCEPreInit.mceInit[id] );
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+ /**
+ * TinyMCE is still not loaded. In order to restore the selection
+ * when the editor loads, a `on('init')` event is added, that will
+ * do the restoration.
+ *
+ * To achieve that, the initialization config is cloned and extended
+ * to include the `setup` method, which makes it possible to add the
+ * `on('init')` event.
+ *
+ * Cloning is used to prevent modification of the original init config,
+ * which may cause unwanted side effects.
+ */
+ var tinyMCEConfig = $.extend(
+ {},
+ window.tinyMCEPreInit.mceInit[id],
+ {
+ setup: function(editor) {
+ editor.on('init', function(event) {
+ focusHTMLBookmarkInVisualEditor( event.target );
+ });
+ }
+ }
+ );
+
+ tinymce.init( tinyMCEConfig );
</ins><span class="cx" style="display: block; padding: 0 10px"> }
</span><span class="cx" style="display: block; padding: 0 10px">
</span><span class="cx" style="display: block; padding: 0 10px"> wrap.removeClass( 'html-active' ).addClass( 'tmce-active' );
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -126,6 +156,7 @@
</span><span class="cx" style="display: block; padding: 0 10px"> return false;
</span><span class="cx" style="display: block; padding: 0 10px"> }
</span><span class="cx" style="display: block; padding: 0 10px">
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+ var selectionRange = null;
</ins><span class="cx" style="display: block; padding: 0 10px"> if ( editor ) {
</span><span class="cx" style="display: block; padding: 0 10px"> // Don't resize the textarea in iOS. The iframe is forced to 100% height there, we shouldn't match it.
</span><span class="cx" style="display: block; padding: 0 10px"> if ( ! tinymce.Env.iOS ) {
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -143,7 +174,13 @@
</span><span class="cx" style="display: block; padding: 0 10px"> }
</span><span class="cx" style="display: block; padding: 0 10px"> }
</span><span class="cx" style="display: block; padding: 0 10px">
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+ selectionRange = findBookmarkedPosition( editor );
+
</ins><span class="cx" style="display: block; padding: 0 10px"> editor.hide();
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+
+ if ( selectionRange ) {
+ selectTextInTextArea( editor, selectionRange );
+ }
</ins><span class="cx" style="display: block; padding: 0 10px"> } else {
</span><span class="cx" style="display: block; padding: 0 10px"> // There is probably a JS error on the page. The TinyMCE editor instance doesn't exist. Show the textarea.
</span><span class="cx" style="display: block; padding: 0 10px"> $textarea.css({ 'display': '', 'visibility': '' });
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -156,6 +193,517 @@
</span><span class="cx" style="display: block; padding: 0 10px"> }
</span><span class="cx" style="display: block; padding: 0 10px">
</span><span class="cx" style="display: block; padding: 0 10px"> /**
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+ * @summary Checks if a cursor is inside an HTML tag.
+ *
+ * In order to prevent breaking HTML tags when selecting text, the cursor
+ * must be moved to either the start or end of the tag.
+ *
+ * This will prevent the selection marker to be inserted in the middle of an HTML tag.
+ *
+ * This function gives information whether the cursor is inside a tag or not, as well as
+ * the tag type, if it is a closing tag and check if the HTML tag is inside a shortcode tag,
+ * e.g. `[caption]<img.../>..`.
+ *
+ * @param {string} content The test content where the cursor is.
+ * @param {number} cursorPosition The cursor position inside the content.
+ *
+ * @returns {(null|Object)} Null if cursor is not in a tag, Object if the cursor is inside a tag.
+ */
+ function getContainingTagInfo( content, cursorPosition ) {
+ var lastLtPos = content.lastIndexOf( '<', cursorPosition ),
+ lastGtPos = content.lastIndexOf( '>', cursorPosition );
+
+ if ( lastLtPos > lastGtPos || content.substr( cursorPosition, 1 ) === '>' ) {
+ // find what the tag is
+ var tagContent = content.substr( lastLtPos );
+ var tagMatch = tagContent.match( /<\s*(\/)?(\w+)/ );
+ if ( ! tagMatch ) {
+ return null;
+ }
+
+ var tagType = tagMatch[ 2 ];
+ var closingGt = tagContent.indexOf( '>' );
+ var isClosingTag = ! ! tagMatch[ 1 ];
+ var shortcodeWrapperInfo = getShortcodeWrapperInfo( content, lastLtPos );
+
+ return {
+ ltPos: lastLtPos,
+ gtPos: lastLtPos + closingGt + 1, // offset by one to get the position _after_ the character,
+ tagType: tagType,
+ isClosingTag: isClosingTag,
+ shortcodeTagInfo: shortcodeWrapperInfo
+ };
+ }
+ return null;
+ }
+
+ /**
+ * @summary Check if a given HTML tag is enclosed in a shortcode tag
+ *
+ * If the cursor is inside a shortcode wrapping tag, e.g. `[caption]` it's better to
+ * move the selection marker to before the short tag.
+ *
+ * For example `[caption]` rewrites/removes anything that's between the `[caption]` tag and the
+ * `<img/>` tag inside.
+ *
+ * `[caption]<span>ThisIsGone</span><img .../>[caption]`
+ *
+ * Moving the selection to before the short code is better, since it allows to select
+ * something, instead of just losing focus and going to the start of the content.
+ *
+ * @param {string} content The text content to check against
+ * @param {number} cursorPosition The cursor position to check from. Usually this is the opening symbol of
+ * an HTML tag.
+ *
+ * @return {(null|Object)} Null if the oject is not wrapped in a shortcode tag.
+ * Information about the wrapping shortcode tag if it's wrapped in one.
+ */
+ function getShortcodeWrapperInfo( content, cursorPosition ) {
+ if ( content.substr( cursorPosition - 1, 1 ) === ']' ) {
+ var shortTagStart = content.lastIndexOf( '[', cursorPosition );
+ var shortTagContent = content.substr(shortTagStart, cursorPosition - shortTagStart);
+ var shortTag = content.match( /\[\s*(\/)?(\w+)/ );
+ var tagType = shortTag[ 2 ];
+ var closingGt = shortTagContent.indexOf( '>' );
+ var isClosingTag = ! ! shortTag[ 1 ];
+
+ return {
+ openingBracket: shortTagStart,
+ shortcode: tagType,
+ closingBracket: closingGt,
+ isClosingTag: isClosingTag
+ };
+ }
+
+ return null;
+ }
+
+ /**
+ * Generate a cursor marker element to be inserted in the content.
+ *
+ * `span` seems to be the least destructive element that can be used.
+ *
+ * Using DomQuery syntax to create it, since it's used as both text and as a DOM element.
+ *
+ * @param {Object} editor The TinyMCE editor instance.
+ * @param {string} content The content to insert into the cusror marker element.
+ */
+ function getCursorMarkerSpan( editor, content ) {
+ return editor.$( '<span>' ).css( {
+ display: 'inline-block',
+ width: 0,
+ overflow: 'hidden',
+ 'line-height': 0
+ } )
+ .html( content ? content : '' );
+ }
+
+ /**
+ * @summary Adds text selection markers in the editor textarea.
+ *
+ * Adds selection markers in the content of the editor `textarea`.
+ * The method directly manipulates the `textarea` content, to allow TinyMCE plugins
+ * to run after the markers are added.
+ *
+ * @param {object} $textarea TinyMCE's textarea wrapped as a DomQuery object
+ * @param {object} jQuery A jQuery instance
+ */
+ function addHTMLBookmarkInTextAreaContent( $textarea, jQuery ) {
+ var textArea = $textarea[ 0 ], // TODO add error checking
+ htmlModeCursorStartPosition = textArea.selectionStart,
+ htmlModeCursorEndPosition = textArea.selectionEnd;
+
+ var voidElements = [
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
+ 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr'
+ ];
+
+ // check if the cursor is in a tag and if so, adjust it
+ var isCursorStartInTag = getContainingTagInfo( textArea.value, htmlModeCursorStartPosition );
+ if ( isCursorStartInTag ) {
+ /**
+ * Only move to the start of the HTML tag (to select the whole element) if the tag
+ * is part of the voidElements list above.
+ *
+ * This list includes tags that are self-contained and don't need a closing tag, according to the
+ * HTML5 specification.
+ *
+ * This is done in order to make selection of text a bit more consistent when selecting text in
+ * `<p>` 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( /<p>(?:<br ?\/?>|\u00a0|\uFEFF| )*<\/p>/g, '<p> </p>' );
+ }
+
+ /**
+ * @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 `<img>` tag, which makes the
+ * boundary range `insertNode` insert `startElement` inside the `<img>` tag itself, i.e.:
+ *
+ * `<img><span class="mce_SELRES_start"...>...</span></img>`
+ *
+ * As this is an invalid syntax, it breaks the selection.
+ *
+ * The conditional below checks if `startNode` is a tag that suffer from that and
+ * manually inserts the selection start maker before it.
+ *
+ * In the future this will probably include a list of tags, not just `<img>`, depending on the needs.
+ */
+ if ( startNode && startNode.tagName && startNode.tagName.toLowerCase() === 'img' ) {
+ editor.$( startNode ).before( startElement[ 0 ] );
+ }
+ else {
+ boundaryRange.setStart( startNode, startOffset );
+ boundaryRange.collapse( true );
+ boundaryRange.insertNode( startElement[ 0 ] );
+ }
+
+
+ range.setStartAfter( startElement[0] );
+ range.setEndBefore( endElement[0] );
+ selection.removeAllRanges();
+ selection.addRange( range );
+
+ /**
+ * Now the editor's content has the start/end nodes.
+ *
+ * Unfortunately the content goes through some more changes after this step, before it gets inserted
+ * in the `textarea`. This means that we have to do some minor cleanup on our own here.
+ */
+ editor.on( 'GetContent', fixTextAreaContent );
+
+ var content = removep( editor.getContent() );
+
+ editor.off( 'GetContent', fixTextAreaContent );
+
+ startElement.remove();
+ endElement.remove();
+
+ var startRegex = new RegExp(
+ '<span[^>]*\\s*class="mce_SELRES_start"[^>]+>\\s*' + selectionID + '[^<]*<\\/span>'
+ );
+
+ var endRegex = new RegExp(
+ '<span[^>]*\\s*class="mce_SELRES_end"[^>]+>\\s*' + selectionID + '[^<]*<\\/span>'
+ );
+
+ var startMatch = content.match( startRegex );
+ var endMatch = content.match( endRegex );
+ if ( ! startMatch ) {
+ return null;
+ }
+
+ return {
+ start: startMatch.index,
+
+ // We need to adjust the end position to discard the length of the range start marker
+ end: endMatch ? endMatch.index - startMatch[ 0 ].length : null
+ };
+ }
+
+ /**
+ * @summary Selects text in the TinyMCE `textarea`.
+ *
+ * Selects the text in TinyMCE's textarea that's between `selection.start` and `selection.end`.
+ *
+ * For `selection` parameter:
+ * @see findBookmarkedPosition
+ *
+ * @param {Object} editor TinyMCE's editor instance.
+ * @param {Object} selection Selection data.
+ */
+ function selectTextInTextArea( editor, selection ) {
+ // only valid in the text area mode and if we have selection
+ if ( ! selection ) {
+ return;
+ }
+
+ var textArea = editor.getElement(),
+ start = selection.start,
+ end = selection.end || selection.start;
+
+ if ( textArea.focus ) {
+ // focus and scroll to the position
+ setTimeout( function() {
+ if ( textArea.blur ) {
+ // defocus before focusing
+ textArea.blur();
+ }
+ textArea.focus();
+ }, 100 );
+
+ textArea.focus();
+ }
+
+ textArea.setSelectionRange( start, end );
+ }
+
+ /**
</ins><span class="cx" style="display: block; padding: 0 10px"> * @summary Replaces <p> tags with two line breaks. "Opposite" of wpautop().
</span><span class="cx" style="display: block; padding: 0 10px"> *
</span><span class="cx" style="display: block; padding: 0 10px"> * Replaces <p> tags with two line breaks except where the <p> has attributes.
</span></span></pre>
</div>
</div>
</body>
</html>