WordPress.org

Make WordPress Core

Changeset 41630


Ignore:
Timestamp:
09/28/2017 06:44:09 AM (3 years ago)
Author:
pento
Message:

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 #41962.

File:
1 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-admin/js/editor.js

    r41344 r41630  
    100100                editorHeight = parseInt( textarea.style.height, 10 ) || 0;
    101101
     102                // Save the selection
     103                addHTMLBookmarkInTextAreaContent( $textarea, $ );
     104
    102105                if ( editor ) {
    103106                    editor.show();
     
    113116                        }
    114117                    }
     118
     119                    // Restore the selection
     120                    focusHTMLBookmarkInVisualEditor( editor );
    115121                } else {
    116                     tinymce.init( window.tinyMCEPreInit.mceInit[id] );
     122                    /**
     123                     * TinyMCE is still not loaded. In order to restore the selection
     124                     * when the editor loads, a `on('init')` event is added, that will
     125                     * do the restoration.
     126                     *
     127                     * To achieve that, the initialization config is cloned and extended
     128                     * to include the `setup` method, which makes it possible to add the
     129                     * `on('init')` event.
     130                     *
     131                     * Cloning is used to prevent modification of the original init config,
     132                     * which may cause unwanted side effects.
     133                     */
     134                    var tinyMCEConfig = $.extend(
     135                        {},
     136                        window.tinyMCEPreInit.mceInit[id],
     137                        {
     138                            setup: function(editor) {
     139                                editor.on('init', function(event) {
     140                                    focusHTMLBookmarkInVisualEditor( event.target );
     141                                });
     142                            }
     143                        }
     144                    );
     145
     146                    tinymce.init( tinyMCEConfig );
    117147                }
    118148
     
    127157                }
    128158
     159                var selectionRange = null;
    129160                if ( editor ) {
    130161                    // Don't resize the textarea in iOS. The iframe is forced to 100% height there, we shouldn't match it.
     
    144175                    }
    145176
     177                    selectionRange = findBookmarkedPosition( editor );
     178
    146179                    editor.hide();
     180
     181                    if ( selectionRange ) {
     182                        selectTextInTextArea( editor, selectionRange );
     183                    }
    147184                } else {
    148185                    // There is probably a JS error on the page. The TinyMCE editor instance doesn't exist. Show the textarea.
     
    154191                window.setUserSetting( 'editor', 'html' );
    155192            }
     193        }
     194
     195        /**
     196         * @summary Checks if a cursor is inside an HTML tag.
     197         *
     198         * In order to prevent breaking HTML tags when selecting text, the cursor
     199         * must be moved to either the start or end of the tag.
     200         *
     201         * This will prevent the selection marker to be inserted in the middle of an HTML tag.
     202         *
     203         * This function gives information whether the cursor is inside a tag or not, as well as
     204         * the tag type, if it is a closing tag and check if the HTML tag is inside a shortcode tag,
     205         * e.g. `[caption]<img.../>..`.
     206         *
     207         * @param {string} content The test content where the cursor is.
     208         * @param {number} cursorPosition The cursor position inside the content.
     209         *
     210         * @returns {(null|Object)} Null if cursor is not in a tag, Object if the cursor is inside a tag.
     211         */
     212        function getContainingTagInfo( content, cursorPosition ) {
     213            var lastLtPos = content.lastIndexOf( '<', cursorPosition ),
     214                lastGtPos = content.lastIndexOf( '>', cursorPosition );
     215
     216            if ( lastLtPos > lastGtPos || content.substr( cursorPosition, 1 ) === '>' ) {
     217                // find what the tag is
     218                var tagContent = content.substr( lastLtPos );
     219                var tagMatch = tagContent.match( /<\s*(\/)?(\w+)/ );
     220                if ( ! tagMatch ) {
     221                    return null;
     222                }
     223
     224                var tagType = tagMatch[ 2 ];
     225                var closingGt = tagContent.indexOf( '>' );
     226                var isClosingTag = ! ! tagMatch[ 1 ];
     227                var shortcodeWrapperInfo = getShortcodeWrapperInfo( content, lastLtPos );
     228
     229                return {
     230                    ltPos: lastLtPos,
     231                    gtPos: lastLtPos + closingGt + 1, // offset by one to get the position _after_ the character,
     232                    tagType: tagType,
     233                    isClosingTag: isClosingTag,
     234                    shortcodeTagInfo: shortcodeWrapperInfo
     235                };
     236            }
     237            return null;
     238        }
     239
     240        /**
     241         * @summary Check if a given HTML tag is enclosed in a shortcode tag
     242         *
     243         * If the cursor is inside a shortcode wrapping tag, e.g. `[caption]` it's better to
     244         * move the selection marker to before the short tag.
     245         *
     246         * For example `[caption]` rewrites/removes anything that's between the `[caption]` tag and the
     247         * `<img/>` tag inside.
     248         *
     249         *  `[caption]<span>ThisIsGone</span><img .../>[caption]`
     250         *
     251         *  Moving the selection to before the short code is better, since it allows to select
     252         *  something, instead of just losing focus and going to the start of the content.
     253         *
     254         *  @param {string} content The text content to check against
     255         *  @param {number} cursorPosition  The cursor position to check from. Usually this is the opening symbol of
     256         *                                  an HTML tag.
     257         *
     258         * @return {(null|Object)}  Null if the oject is not wrapped in a shortcode tag.
     259         *                          Information about the wrapping shortcode tag if it's wrapped in one.
     260         */
     261        function getShortcodeWrapperInfo( content, cursorPosition ) {
     262            if ( content.substr( cursorPosition - 1, 1 ) === ']' ) {
     263                var shortTagStart = content.lastIndexOf( '[', cursorPosition );
     264                var shortTagContent = content.substr(shortTagStart, cursorPosition - shortTagStart);
     265                var shortTag = content.match( /\[\s*(\/)?(\w+)/ );
     266                var tagType = shortTag[ 2 ];
     267                var closingGt = shortTagContent.indexOf( '>' );
     268                var isClosingTag = ! ! shortTag[ 1 ];
     269
     270                return {
     271                    openingBracket: shortTagStart,
     272                    shortcode: tagType,
     273                    closingBracket: closingGt,
     274                    isClosingTag: isClosingTag
     275                };
     276            }
     277
     278            return null;
     279        }
     280
     281        /**
     282         * Generate a cursor marker element to be inserted in the content.
     283         *
     284         * `span` seems to be the least destructive element that can be used.
     285         *
     286         * Using DomQuery syntax to create it, since it's used as both text and as a DOM element.
     287         *
     288         * @param {Object} editor The TinyMCE editor instance.
     289         * @param {string} content The content to insert into the cusror marker element.
     290         */
     291        function getCursorMarkerSpan( editor, content ) {
     292            return editor.$( '<span>' ).css( {
     293                        display: 'inline-block',
     294                        width: 0,
     295                        overflow: 'hidden',
     296                        'line-height': 0
     297                    } )
     298                    .html( content ? content : '' );
     299        }
     300
     301        /**
     302         * @summary Adds text selection markers in the editor textarea.
     303         *
     304         * Adds selection markers in the content of the editor `textarea`.
     305         * The method directly manipulates the `textarea` content, to allow TinyMCE plugins
     306         * to run after the markers are added.
     307         *
     308         * @param {object} $textarea TinyMCE's textarea wrapped as a DomQuery object
     309         * @param {object} jQuery A jQuery instance
     310         */
     311        function addHTMLBookmarkInTextAreaContent( $textarea, jQuery ) {
     312            var textArea = $textarea[ 0 ], // TODO add error checking
     313                htmlModeCursorStartPosition = textArea.selectionStart,
     314                htmlModeCursorEndPosition = textArea.selectionEnd;
     315
     316            var voidElements = [
     317                'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
     318                'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr'
     319            ];
     320
     321            // check if the cursor is in a tag and if so, adjust it
     322            var isCursorStartInTag = getContainingTagInfo( textArea.value, htmlModeCursorStartPosition );
     323            if ( isCursorStartInTag ) {
     324                /**
     325                 * Only move to the start of the HTML tag (to select the whole element) if the tag
     326                 * is part of the voidElements list above.
     327                 *
     328                 * This list includes tags that are self-contained and don't need a closing tag, according to the
     329                 * HTML5 specification.
     330                 *
     331                 * This is done in order to make selection of text a bit more consistent when selecting text in
     332                 * `<p>` tags or such.
     333                 *
     334                 * In cases where the tag is not a void element, the cursor is put to the end of the tag,
     335                 * so it's either between the opening and closing tag elements or after the closing tag.
     336                 */
     337                if ( voidElements.indexOf( isCursorStartInTag.tagType ) !== - 1 ) {
     338                    htmlModeCursorStartPosition = isCursorStartInTag.ltPos;
     339                }
     340                else {
     341                    htmlModeCursorStartPosition = isCursorStartInTag.gtPos;
     342                }
     343            }
     344
     345            var isCursorEndInTag = getContainingTagInfo( textArea.value, htmlModeCursorEndPosition );
     346            if ( isCursorEndInTag ) {
     347                htmlModeCursorEndPosition = isCursorEndInTag.gtPos;
     348            }
     349
     350            var mode = htmlModeCursorStartPosition !== htmlModeCursorEndPosition ? 'range' : 'single';
     351
     352            var selectedText = null;
     353            var cursorMarkerSkeleton = getCursorMarkerSpan( { $: jQuery }, '&#65279;' );
     354
     355            if ( mode === 'range' ) {
     356                var markedText = textArea.value.slice( htmlModeCursorStartPosition, htmlModeCursorEndPosition );
     357
     358                /**
     359                 * Since the shortcodes convert the tags in them a bit, we need to mark the tag itself,
     360                 * and not rely on the cursor marker.
     361                 *
     362                 * @see getShortcodeWrapperInfo
     363                 */
     364                if ( isCursorStartInTag && isCursorStartInTag.shortcodeTagInfo ) {
     365                    // Get the tag on the cursor start
     366                    var tagEndPosition = isCursorStartInTag.gtPos - isCursorStartInTag.ltPos;
     367                    var tagContent = markedText.slice( 0, tagEndPosition );
     368
     369                    // Check if the tag already has a `class` attribute.
     370                    var classMatch = /class=(['"])([^$1]*?)\1/;
     371
     372                    /**
     373                     * Add a marker class to the selected tag, to be used later.
     374                     *
     375                     * @see focusHTMLBookmarkInVisualEditor
     376                     */
     377                    if ( tagContent.match( classMatch ) ) {
     378                        tagContent = tagContent.replace( classMatch, 'class=$1$2 mce_SELRES_start_target$1' );
     379                    }
     380                    else {
     381                        tagContent = tagContent.replace( /(<\w+)/, '$1 class="mce_SELRES_start_target" ' );
     382                    }
     383
     384                    // Update the selected text content with the marked tag above
     385                    markedText = [
     386                        tagContent,
     387                        markedText.substr( tagEndPosition )
     388                    ].join( '' );
     389                }
     390
     391                var bookMarkEnd = cursorMarkerSkeleton.clone()
     392                    .addClass( 'mce_SELRES_end' )[ 0 ].outerHTML;
     393
     394                /**
     395                 * A small workaround when selecting just a single HTML tag inside a shortcode.
     396                 *
     397                 * This removes the end selection marker, to make sure the HTML tag is the only selected
     398                 * thing. This prevents the selection to appear like it contains multiple items in it (i.e.
     399                 * all highlighted blue)
     400                 */
     401                if ( isCursorStartInTag && isCursorStartInTag.shortcodeTagInfo && isCursorEndInTag &&
     402                        isCursorStartInTag.ltPos === isCursorEndInTag.ltPos ) {
     403                    bookMarkEnd = '';
     404                }
     405
     406                selectedText = [
     407                    markedText,
     408                    bookMarkEnd
     409                ].join( '' );
     410            }
     411
     412            textArea.value = [
     413                textArea.value.slice( 0, htmlModeCursorStartPosition ), // text until the cursor/selection position
     414                cursorMarkerSkeleton.clone()                            // cursor/selection start marker
     415                    .addClass( 'mce_SELRES_start')[0].outerHTML,
     416                selectedText,                                           // selected text with end cursor/position marker
     417                textArea.value.slice( htmlModeCursorEndPosition )       // text from last cursor/selection position to end
     418            ].join( '' );
     419        }
     420
     421        /**
     422         * @summary Focus the selection markers in Visual mode.
     423         *
     424         * The method checks for existing selection markers inside the editor DOM (Visual mode)
     425         * and create a selection between the two nodes using the DOM `createRange` selection API
     426         *
     427         * If there is only a single node, select only the single node through TinyMCE's selection API
     428         *
     429         * @param {Object} editor TinyMCE editor instance.
     430         */
     431        function focusHTMLBookmarkInVisualEditor( editor ) {
     432            var startNode = editor.$( '.mce_SELRES_start' ),
     433                endNode = editor.$( '.mce_SELRES_end' );
     434
     435            if ( ! startNode.length ) {
     436                startNode = editor.$( '.mce_SELRES_start_target' );
     437            }
     438
     439            if ( startNode.length ) {
     440                editor.focus();
     441
     442                if ( ! endNode.length ) {
     443                    editor.selection.select( startNode[ 0 ] );
     444                } else {
     445                    var selection = editor.getDoc().createRange();
     446
     447                    selection.setStartAfter( startNode[ 0 ] );
     448                    selection.setEndBefore( endNode[ 0 ] );
     449
     450                    editor.selection.setRng( selection );
     451                }
     452
     453                scrollVisualModeToStartElement( editor, startNode );
     454            }
     455
     456            if ( startNode.hasClass( 'mce_SELRES_start_target' ) ) {
     457                startNode.removeClass( 'mce_SELRES_start_target' );
     458            }
     459            else {
     460                startNode.remove();
     461            }
     462            endNode.remove();
     463        }
     464
     465        /**
     466         * @summary Scrolls the content to place the selected element in the center of the screen.
     467         *
     468         * Takes an element, that is usually the selection start element, selected in
     469         * `focusHTMLBookmarkInVisualEditor()` and scrolls the screen so the element appears roughly
     470         * in the middle of the screen.
     471         *
     472         * I order to achieve the proper positioning, the editor media bar and toolbar are subtracted
     473         * from the window height, to get the proper viewport window, that the user sees.
     474         *
     475         * @param {Object} editor TinyMCE editor instance.
     476         * @param {Object} element HTMLElement that should be scrolled into view.
     477         */
     478        function scrollVisualModeToStartElement( editor, element ) {
     479            /**
     480             * TODO:
     481             *  * Decide if we should animate the transition or not ( motion sickness/accessibility )
     482             */
     483            var elementTop = editor.$( element ).offset().top;
     484            var TinyMCEContentAreaTop = editor.$( editor.getContentAreaContainer() ).offset().top;
     485
     486            var edTools = $('#wp-content-editor-tools');
     487            var edToolsHeight = edTools.height();
     488            var edToolsOffsetTop = edTools.offset().top;
     489
     490            var toolbarHeight = getToolbarHeight( editor );
     491
     492            var windowHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
     493
     494            var selectionPosition = TinyMCEContentAreaTop + elementTop;
     495            var visibleAreaHeight = windowHeight - ( edToolsHeight + toolbarHeight );
     496
     497            /**
     498             * The minimum scroll height should be to the top of the editor, to offer a consistent
     499             * experience.
     500             *
     501             * In order to find the top of the editor, we calculate the offset of `#wp-content-editor-tools` and
     502             * subtracting the height. This gives the scroll position where the top of the editor tools aligns with
     503             * the top of the viewport (under the Master Bar)
     504             */
     505            var adjustedScroll = Math.max(selectionPosition - visibleAreaHeight / 2, edToolsOffsetTop - edToolsHeight);
     506
     507
     508            $( 'body' ).animate( {
     509                scrollTop: parseInt( adjustedScroll, 10 )
     510            }, 100 );
     511        }
     512
     513        /**
     514         * This method was extracted from the `SaveContent` hook in
     515         * `wp-includes/js/tinymce/plugins/wordpress/plugin.js`.
     516         *
     517         * It's needed here, since the method changes the content a bit, which confuses the cursor position.
     518         *
     519         * @param {Object} event TinyMCE event object.
     520         */
     521        function fixTextAreaContent( event ) {
     522            // Keep empty paragraphs :(
     523            event.content = event.content.replace( /<p>(?:<br ?\/?>|\u00a0|\uFEFF| )*<\/p>/g, '<p>&nbsp;</p>' );
     524        }
     525
     526        /**
     527         * @summary Finds the current selection position in the Visual editor.
     528         *
     529         * Find the current selection in the Visual editor by inserting marker elements at the start
     530         * and end of the selection.
     531         *
     532         * Uses the standard DOM selection API to achieve that goal.
     533         *
     534         * Check the notes in the comments in the code below for more information on some gotchas
     535         * and why this solution was chosen.
     536         *
     537         * @param {Object} editor The editor where we must find the selection
     538         * @returns {(null|Object)} The selection range position in the editor
     539         */
     540        function findBookmarkedPosition( editor ) {
     541            // Get the TinyMCE `window` reference, since we need to access the raw selection.
     542            var TinyMCEWIndow = editor.getWin(),
     543                selection = TinyMCEWIndow.getSelection();
     544
     545            if ( selection.rangeCount <= 0 ) {
     546                // no selection, no need to continue.
     547                return;
     548            }
     549
     550            /**
     551             * The ID is used to avoid replacing user generated content, that may coincide with the
     552             * format specified below.
     553             * @type {string}
     554             */
     555            var selectionID = 'SELRES_' + Math.random();
     556
     557            /**
     558             * Create two marker elements that will be used to mark the start and the end of the range.
     559             *
     560             * The elements have hardcoded style that makes them invisible. This is done to avoid seeing
     561             * random content flickering in the editor when switching between modes.
     562             */
     563            var spanSkeleton = getCursorMarkerSpan(editor, selectionID);
     564
     565            var startElement = spanSkeleton.clone().addClass('mce_SELRES_start');
     566            var endElement = spanSkeleton.clone().addClass('mce_SELRES_end');
     567
     568            /**
     569             * Inspired by:
     570             * @link https://stackoverflow.com/a/17497803/153310
     571             *
     572             * Why do it this way and not with TinyMCE's bookmarks?
     573             *
     574             * TinyMCE's bookmarks are very nice when working with selections and positions, BUT
     575             * there is no way to determine the precise position of the bookmark when switching modes, since
     576             * TinyMCE does some serialization of the content, to fix things like shortcodes, run plugins, prettify
     577             * HTML code and so on. In this process, the bookmark markup gets lost.
     578             *
     579             * If we decide to hook right after the bookmark is added, we can see where the bookmark is in the raw HTML
     580             * in TinyMCE. Unfortunately this state is before the serialization, so any visual markup in the content will
     581             * throw off the positioning.
     582             *
     583             * To avoid this, we insert two custom `span`s that will serve as the markers at the beginning and end of the
     584             * selection.
     585             *
     586             * Why not use TinyMCE's selection API or the DOM API to wrap the contents? Because if we do that, this creates
     587             * a new node, which is inserted in the dom. Now this will be fine, if we worked with fixed selections to
     588             * full nodes. Unfortunately in our case, the user can select whatever they like, which means that the
     589             * selection may start in the middle of one node and end in the middle of a completely different one. If we
     590             * wrap the selection in another node, this will create artifacts in the content.
     591             *
     592             * Using the method below, we insert the custom `span` nodes at the start and at the end of the selection.
     593             * This helps us not break the content and also gives us the option to work with multi-node selections without
     594             * breaking the markup.
     595             */
     596            var range = selection.getRangeAt( 0 ),
     597                startNode = range.startContainer,
     598                startOffset = range.startOffset,
     599                boundaryRange = range.cloneRange();
     600
     601            boundaryRange.collapse( false );
     602            boundaryRange.insertNode( endElement[0] );
     603
     604            /**
     605             * Sometimes the selection starts at the `<img>` tag, which makes the
     606             * boundary range `insertNode` insert `startElement` inside the `<img>` tag itself, i.e.:
     607             *
     608             * `<img><span class="mce_SELRES_start"...>...</span></img>`
     609             *
     610             * As this is an invalid syntax, it breaks the selection.
     611             *
     612             * The conditional below checks if `startNode` is a tag that suffer from that and
     613             * manually inserts the selection start maker before it.
     614             *
     615             * In the future this will probably include a list of tags, not just `<img>`, depending on the needs.
     616             */
     617            if ( startNode && startNode.tagName && startNode.tagName.toLowerCase() === 'img' ) {
     618                editor.$( startNode ).before( startElement[ 0 ] );
     619            }
     620            else {
     621                boundaryRange.setStart( startNode, startOffset );
     622                boundaryRange.collapse( true );
     623                boundaryRange.insertNode( startElement[ 0 ] );
     624            }
     625
     626
     627            range.setStartAfter( startElement[0] );
     628            range.setEndBefore( endElement[0] );
     629            selection.removeAllRanges();
     630            selection.addRange( range );
     631
     632            /**
     633             * Now the editor's content has the start/end nodes.
     634             *
     635             * Unfortunately the content goes through some more changes after this step, before it gets inserted
     636             * in the `textarea`. This means that we have to do some minor cleanup on our own here.
     637             */
     638            editor.on( 'GetContent', fixTextAreaContent );
     639
     640            var content = removep( editor.getContent() );
     641
     642            editor.off( 'GetContent', fixTextAreaContent );
     643
     644            startElement.remove();
     645            endElement.remove();
     646
     647            var startRegex = new RegExp(
     648                '<span[^>]*\\s*class="mce_SELRES_start"[^>]+>\\s*' + selectionID + '[^<]*<\\/span>'
     649            );
     650
     651            var endRegex = new RegExp(
     652                '<span[^>]*\\s*class="mce_SELRES_end"[^>]+>\\s*' + selectionID + '[^<]*<\\/span>'
     653            );
     654
     655            var startMatch = content.match( startRegex );
     656            var endMatch = content.match( endRegex );
     657            if ( ! startMatch ) {
     658                return null;
     659            }
     660
     661            return {
     662                start: startMatch.index,
     663
     664                // We need to adjust the end position to discard the length of the range start marker
     665                end: endMatch ? endMatch.index - startMatch[ 0 ].length : null
     666            };
     667        }
     668
     669        /**
     670         * @summary Selects text in the TinyMCE `textarea`.
     671         *
     672         * Selects the text in TinyMCE's textarea that's between `selection.start` and `selection.end`.
     673         *
     674         * For `selection` parameter:
     675         * @see findBookmarkedPosition
     676         *
     677         * @param {Object} editor TinyMCE's editor instance.
     678         * @param {Object} selection Selection data.
     679         */
     680        function selectTextInTextArea( editor, selection ) {
     681            // only valid in the text area mode and if we have selection
     682            if ( ! selection ) {
     683                return;
     684            }
     685
     686            var textArea = editor.getElement(),
     687                start = selection.start,
     688                end = selection.end || selection.start;
     689
     690            if ( textArea.focus ) {
     691                // focus and scroll to the position
     692                setTimeout( function() {
     693                    if ( textArea.blur ) {
     694                        // defocus before focusing
     695                        textArea.blur();
     696                    }
     697                    textArea.focus();
     698                }, 100 );
     699
     700                textArea.focus();
     701            }
     702
     703            textArea.setSelectionRange( start, end );
    156704        }
    157705
Note: See TracChangeset for help on using the changeset viewer.