Changeset 41630
- Timestamp:
- 09/28/2017 06:44:09 AM (7 years ago)
- File:
-
- 1 edited
Legend:
- Unmodified
- Added
- Removed
-
trunk/src/wp-admin/js/editor.js
r41344 r41630 100 100 editorHeight = parseInt( textarea.style.height, 10 ) || 0; 101 101 102 // Save the selection 103 addHTMLBookmarkInTextAreaContent( $textarea, $ ); 104 102 105 if ( editor ) { 103 106 editor.show(); … … 113 116 } 114 117 } 118 119 // Restore the selection 120 focusHTMLBookmarkInVisualEditor( editor ); 115 121 } 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 ); 117 147 } 118 148 … … 127 157 } 128 158 159 var selectionRange = null; 129 160 if ( editor ) { 130 161 // Don't resize the textarea in iOS. The iframe is forced to 100% height there, we shouldn't match it. … … 144 175 } 145 176 177 selectionRange = findBookmarkedPosition( editor ); 178 146 179 editor.hide(); 180 181 if ( selectionRange ) { 182 selectTextInTextArea( editor, selectionRange ); 183 } 147 184 } else { 148 185 // There is probably a JS error on the page. The TinyMCE editor instance doesn't exist. Show the textarea. … … 154 191 window.setUserSetting( 'editor', 'html' ); 155 192 } 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 }, '' ); 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> </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 ); 156 704 } 157 705
Note: See TracChangeset
for help on using the changeset viewer.