Make WordPress Core

Changeset 41630


Ignore:
Timestamp:
09/28/2017 06:44:09 AM (9 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.