1 /* markup.js is part of Aloha Editor project http://aloha-editor.org 2 * 3 * Aloha Editor is a WYSIWYG HTML5 inline editing library and editor. 4 * Copyright (c) 2010-2012 Gentics Software GmbH, Vienna, Austria. 5 * Contributors http://aloha-editor.org/contribution.php 6 * 7 * Aloha Editor is free software; you can redistribute it and/or 8 * modify it under the terms of the GNU General Public License 9 * as published by the Free Software Foundation; either version 2 10 * of the License, or any later version. 11 * 12 * Aloha Editor is distributed in the hope that it will be useful, 13 * but WITHOUT ANY WARRANTY; without even the implied warranty of 14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 * GNU General Public License for more details. 16 * 17 * You should have received a copy of the GNU General Public License 18 * along with this program; if not, write to the Free Software 19 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 20 * 21 * As an additional permission to the GNU GPL version 2, you may distribute 22 * non-source (e.g., minimized or compacted) forms of the Aloha-Editor 23 * source code without the copy of the GNU GPL normally required, 24 * provided you include this license notice and a URL through which 25 * recipients can access the Corresponding Source. 26 */ 27 define([ 28 'aloha/core', 29 'util/class', 30 'jquery', 31 'aloha/ecma5shims' 32 ], 33 function( Aloha, Class, jQuery, shims ) { 34 35 "use strict"; 36 37 var GENTICS = window.GENTICS; 38 39 var isOldIE = !!( jQuery.browser.msie && 40 9 > parseInt( jQuery.browser.version, 10 ) ); 41 42 function isBR( node ) { 43 return 'BR' === node.nodeName; 44 } 45 46 function isBlock( node ) { 47 return 'false' === jQuery( node ).attr( 'contenteditable' ); 48 } 49 50 function isTextNode( node ) { 51 return node && 3 === node.nodeType; // Node.TEXT_NODE 52 } 53 54 function nodeLength( node ) { 55 return !node ? 0 56 : ( isTextNode( node ) ? node.length 57 : node.childNodes.length ); 58 } 59 60 function nextVisibleNode( node ) { 61 if ( !node ) { 62 return null; 63 } 64 65 if ( node.nextSibling ) { 66 // Skip over nodes that the user cannot see ... 67 if ( isTextNode( node.nextSibling ) && 68 !isVisibleTextNode( node.nextSibling ) ) { 69 return nextVisibleNode( node.nextSibling ); 70 } 71 72 // Skip over propping <br>s ... 73 if ( isBR( node.nextSibling ) && 74 node.nextSibling === node.parentNode.lastChild ) { 75 return nextVisibleNode( node.nextSibling ); 76 } 77 78 // Skip over empty editable elements ... 79 if ( '' === node.nextSibling.innerHTML && 80 !isBlock( node.nextSibling ) ) { 81 return nextVisibleNode( node.nextSibling ); 82 } 83 84 return node.nextSibling; 85 } 86 87 if ( node.parentNode ) { 88 return nextVisibleNode( node.parentNode ); 89 } 90 91 return null; 92 } 93 94 function prevVisibleNode( node ) { 95 if ( !node ) { 96 return null; 97 } 98 99 if ( node.previousSibling ) { 100 // Skip over nodes that the user cannot see... 101 if ( isTextNode( node.previousSibling ) && 102 !isVisibleTextNode( node.previousSibling ) ) { 103 return prevVisibleNode( node.previousSibling ); 104 } 105 106 // Skip over empty editable elements ... 107 if ( '' === node.previousSibling.innerHTML && 108 !isBlock( node.previousSibling ) ) { 109 return prevVisibleNode( node.previouSibling ); 110 } 111 112 return node.previousSibling; 113 } 114 115 if ( node.parentNode ) { 116 return prevVisibleNode( node.parentNode ); 117 } 118 119 return null; 120 } 121 122 /** 123 * Determines whether the given text node is visible to the the user, 124 * based on our understanding that browsers will not display 125 * superfluous white spaces. 126 * 127 * @param {HTMLEmenent} node The text node to be checked. 128 */ 129 function isVisibleTextNode( node ) { 130 return 0 < node.data.replace( /\s+/g, '' ).length; 131 } 132 133 function isFrontPosition( node, offset ) { 134 return ( 0 === offset ) || 135 ( offset <= node.data.length - 136 node.data.replace( /^\s+/, '' ).length ); 137 } 138 139 function isBlockInsideEditable( $block ) { 140 return $block.parent().hasClass( 'aloha-editable' ); 141 } 142 143 function isEndPosition( node, offset ) { 144 var length = nodeLength( node ); 145 146 if ( length === offset ) { 147 return true; 148 } 149 150 var isText = isTextNode( node ); 151 152 // If within a text node, then ignore superfluous white-spaces, 153 // since they are invisible to the user. 154 if ( isText && 155 node.data.replace( /\s+$/, '' ).length === offset ) { 156 return true; 157 } 158 159 if ( 1 === length && !isText ) { 160 return isBR( node.childNodes[0] ); 161 } 162 163 return false; 164 } 165 166 function blink( node ) { 167 jQuery( node ) 168 .stop( true ) 169 .css({ opacity: 0 }) 170 .fadeIn( 0 ).delay( 100 ) 171 .fadeIn(function () { 172 jQuery( node ).css({ opacity: 1 }); 173 }); 174 175 return node; 176 } 177 178 /** 179 * @TODO(petro): We need to be more intelligent about whether we insert a 180 * block-level placeholder or a phrasing level element. 181 * @TODO(petro): test with <pre> 182 */ 183 function jumpBlock( block, isGoingLeft ) { 184 var range = new GENTICS.Utils.RangeObject(); 185 var sibling = isGoingLeft ? prevVisibleNode( block ) 186 : nextVisibleNode( block ); 187 188 if ( !sibling || isBlock( sibling ) ) { 189 var $landing = jQuery( '<div> </div>' ); 190 191 if ( isGoingLeft ) { 192 jQuery( block ).before( $landing ); 193 } else { 194 jQuery( block ).after( $landing ); 195 } 196 197 range.startContainer = range.endContainer = $landing[0]; 198 range.startOffset = range.endOffset = 0; 199 200 // Clear out any old placeholder first ... 201 cleanupPlaceholders( range ); 202 203 window.$_alohaPlaceholder = $landing; 204 } else { 205 range.startContainer = range.endContainer = sibling; 206 range.startOffset = range.endOffset = isGoingLeft ? 207 nodeLength( sibling ) : ( isOldIE ? 1 : 0 ); 208 209 cleanupPlaceholders( range ); 210 } 211 212 range.select(); 213 214 Aloha.trigger( 'aloha-block-selected', block ); 215 Aloha.Selection.preventSelectionChanged(); 216 } 217 218 function nodeContains( node1, node2 ) { 219 return isOldIE ? ( shims.compareDocumentPosition( node1, node2 ) & 16 ) 220 : 0 < jQuery( node1 ).find( node2 ).length; 221 } 222 223 function isInsidePlaceholder( range ) { 224 var start = range.startContainer; 225 var end = range.endContainer; 226 var $placeholder = window.$_alohaPlaceholder; 227 228 return $placeholder.is( start ) || 229 $placeholder.is( end ) || 230 nodeContains( $placeholder[0], start ) || 231 nodeContains( $placeholder[0], end ); 232 } 233 234 function cleanupPlaceholders( range ) { 235 if ( window.$_alohaPlaceholder && !isInsidePlaceholder( range ) ) { 236 if ( 0 === window.$_alohaPlaceholder.html() 237 .replace( /^( )*$/, '' ).length ) { 238 window.$_alohaPlaceholder.remove(); 239 } 240 241 window.$_alohaPlaceholder = null; 242 } 243 } 244 245 /** 246 * Markup object 247 */ 248 Aloha.Markup = Class.extend( { 249 250 /** 251 * Key handlers for special key codes 252 */ 253 keyHandlers: {}, 254 255 /** 256 * Add a key handler for the given key code 257 * @param keyCode key code 258 * @param handler handler function 259 */ 260 addKeyHandler: function( keyCode, handler ) { 261 if ( !this.keyHandlers[ keyCode ] ) { 262 this.keyHandlers[ keyCode ] = []; 263 } 264 265 this.keyHandlers[ keyCode ].push( handler ); 266 }, 267 268 insertBreak: function() { 269 var range = Aloha.Selection.rangeObject, 270 onWSIndex, 271 nextTextNode, 272 newBreak; 273 274 if ( !range.isCollapsed() ) { 275 this.removeSelectedMarkup(); 276 } 277 278 newBreak = jQuery( '<br/>' ); 279 GENTICS.Utils.Dom.insertIntoDOM( newBreak, range, Aloha.activeEditable.obj ); 280 281 nextTextNode = GENTICS.Utils.Dom.searchAdjacentTextNode( 282 newBreak.parent().get( 0 ), 283 GENTICS.Utils.Dom.getIndexInParent( newBreak.get( 0 ) ) + 1, 284 false 285 ); 286 287 if ( nextTextNode ) { 288 // trim leading whitespace 289 nonWSIndex = nextTextNode.data.search( /\S/ ); 290 if ( nonWSIndex > 0 ) { 291 nextTextNode.data = nextTextNode.data.substring( nonWSIndex ); 292 } 293 } 294 295 range.startContainer = range.endContainer = newBreak.get( 0 ).parentNode; 296 range.startOffset = range.endOffset = GENTICS.Utils.Dom.getIndexInParent( newBreak.get( 0 ) ) + 1; 297 range.correctRange(); 298 range.clearCaches(); 299 range.select(); 300 }, 301 302 /** 303 * first method to handle key strokes 304 * @param event DOM event 305 * @param rangeObject as provided by Aloha.Selection.getRangeObject(); 306 * @return "Aloha.Selection" 307 */ 308 preProcessKeyStrokes: function( event ) { 309 if ( event.type !== 'keydown' ) { 310 return false; 311 } 312 313 var rangeObject = Aloha.Selection.rangeObject, 314 handlers, 315 i; 316 317 if ( this.keyHandlers[ event.keyCode ] ) { 318 handlers = this.keyHandlers[ event.keyCode ]; 319 for ( i = 0; i < handlers.length; ++i ) { 320 if ( !handlers[i]( event ) ) { 321 return false; 322 } 323 } 324 } 325 326 // LEFT (37), RIGHT (39) keys for block detection 327 if ( event.keyCode === 37 || event.keyCode === 39 ) { 328 if ( this.processCursor( rangeObject, event.keyCode ) ) { 329 cleanupPlaceholders( Aloha.Selection.rangeObject ); 330 return true; 331 } 332 333 return false; 334 } 335 336 // BACKSPACE 337 if ( event.keyCode === 8 ) { 338 event.preventDefault(); // prevent history.back() even on exception 339 Aloha.execCommand( 'delete', false ); 340 return false; 341 } 342 343 // DELETE 344 if ( event.keyCode === 46 ) { 345 Aloha.execCommand( 'forwarddelete', false ); 346 return false; 347 } 348 349 // ENTER 350 if ( event.keyCode === 13 ) { 351 if ( event.shiftKey ) { 352 Aloha.execCommand( 'insertlinebreak', false ); 353 return false; 354 } else { 355 Aloha.execCommand( 'insertparagraph', false ); 356 return false; 357 } 358 } 359 360 return true; 361 }, 362 363 /** 364 * Processing of cursor keys. 365 * Detect blocks (elements with contenteditable=false) and will select them 366 * (normally the cursor would simply jump right past them). 367 * 368 * For each block that is selected, an 'aloha-block-selected' event will be 369 * triggered. 370 * 371 * @param {RangyRange} range A range object for the current selection. 372 * @param {number} keyCode Code of the currently pressed key. 373 * @return {boolean} False if a block was found, to prevent further events, 374 * true otherwise. 375 */ 376 processCursor: function( range, keyCode ) { 377 if ( !range.isCollapsed() ) { 378 return true; 379 } 380 381 var node = range.startContainer; 382 383 if ( !node ) { 384 return true; 385 } 386 387 var sibling; 388 389 // Versions of Internet Explorer that are older that 9, will 390 391 // erroneously allow you to enter and edit inside elements which have 392 // their contenteditable attribute set to false... 393 if ( isOldIE && !jQuery(node).contentEditable() ) { 394 var $parentBlock = jQuery( node ).parents( 395 '[contenteditable=false]' ); 396 var isInsideBlock = $parentBlock.length > 0; 397 398 if ( isInsideBlock ) { 399 if ( isBlockInsideEditable( $parentBlock ) ) { 400 sibling = $parentBlock[0]; 401 } else { 402 return true; 403 } 404 } 405 } 406 407 if ( !sibling ) { 408 // True if keyCode denotes LEFT or UP arrow key, otherwise they 409 // keyCode is for RIGHT or DOWN in which this value will be false. 410 var isLeft = (37 === keyCode || 38 === keyCode); 411 var offset = range.startOffset; 412 413 if ( isTextNode( node ) ) { 414 if ( isLeft ) { 415 // FIXME(Petro): Please consider if you have a better idea 416 // of how we can work around this. 417 // 418 // Here is the problem... with Internet Explorer: 419 // ---------------------------------------------- 420 // 421 // Versions of Internet Explorer older than 9, are buggy in 422 // how they `select()', or position a selection from cursor 423 // movements, when the following conditions are true: 424 // 425 // * The range is collapsed. 426 // * startContainer is a contenteditable text node. 427 // * startOffset is 1. 428 // * There is a non-conenteditable element left of the 429 // startContainer. 430 // * You attempt to move left to offset 0 (we consider a 431 // range to be at "frontposition" if it is at offset 0 432 // within its startContainer). 433 // 434 // What happens in IE 7, and IE 8, is that the selection 435 // will jump to the adjacent non-contenteditable 436 // element(s), instead moving to the front of the 437 // container, and the offset will be stuck at 1--even as 438 // the cursor is jumping around the screen! 439 // 440 // Our imperfect work-around is to reckon ourselves to be 441 // at the front of the next node (ie: offset 0 in other 442 // browsers), as soon as we detect that we are at offset 1 443 // in IEv<9. 444 // 445 // Considering the bug, I think this is acceptable because 446 // the user can still position themselve right between the 447 // block (non-contenteditable element) and the first 448 // characater of the text node by clicking there with the 449 // mouse, since this seems to work fine in all IE versions. 450 var isFrontPositionInIE = isOldIE && 1 === offset; 451 452 if ( !isFrontPositionInIE && 453 !isFrontPosition( node, offset ) ) { 454 return true; 455 } 456 457 } else if ( !isEndPosition( node, offset ) ) { 458 return true; 459 } 460 461 } else { 462 node = node.childNodes[ 463 offset === nodeLength( node ) ? offset - 1 : offset ]; 464 } 465 466 sibling = isLeft ? prevVisibleNode( node ) 467 : nextVisibleNode( node ); 468 } 469 470 if ( isBlock( sibling ) ) { 471 jumpBlock( sibling, isLeft ); 472 return false; 473 } 474 475 return true; 476 }, 477 478 /** 479 * method handling shiftEnter 480 * @param Aloha.Selection.SelectionRange of the current selection 481 * @return void 482 */ 483 processShiftEnter: function( rangeObject ) { 484 this.insertHTMLBreak( rangeObject.getSelectionTree(), rangeObject ); 485 }, 486 487 /** 488 * method handling Enter 489 * @param Aloha.Selection.SelectionRange of the current selection 490 * @return void 491 */ 492 processEnter: function( rangeObject ) { 493 if ( rangeObject.splitObject ) { 494 // now comes a very evil hack for ie, when the enter is pressed in a text node in an li element, we just append an empty text node 495 // if ( jQuery.browser.msie 496 // && GENTICS.Utils.Dom 497 // .isListElement( rangeObject.splitObject ) ) { 498 // jQuery( rangeObject.splitObject ).append( 499 // jQuery( document.createTextNode( '' ) ) ); 500 // } 501 this.splitRangeObject( rangeObject ); 502 } else { // if there is no split object, the Editable is the paragraph type itself (e.g. a p or h2) 503 this.insertHTMLBreak( rangeObject.getSelectionTree(), rangeObject ); 504 } 505 }, 506 507 /** 508 * Insert the given html markup at the current selection 509 * @param html html markup to be inserted 510 */ 511 insertHTMLCode: function( html ) { 512 var rangeObject = Aloha.Selection.rangeObject; 513 this.insertHTMLBreak( rangeObject.getSelectionTree(), rangeObject, jQuery( html ) ); 514 }, 515 516 /** 517 * insert an HTML Break <br /> into current selection 518 * @param Aloha.Selection.SelectionRange of the current selection 519 * @return void 520 */ 521 insertHTMLBreak: function( selectionTree, rangeObject, inBetweenMarkup ) { 522 var i, 523 treeLength, 524 el, 525 jqEl, 526 jqElBefore, 527 jqElAfter, 528 tmpObject, 529 offset, 530 checkObj; 531 532 inBetweenMarkup = inBetweenMarkup ? inBetweenMarkup: jQuery( '<br/>' ); 533 534 for ( i = 0, treeLength = selectionTree.length; i < treeLength; ++i ) { 535 el = selectionTree[ i ]; 536 jqEl = el.domobj ? jQuery( el.domobj ) : undefined; 537 538 if ( el.selection !== 'none' ) { // before cursor, leave this part inside the splitObject 539 if ( el.selection == 'collapsed' ) { 540 // collapsed selection found (between nodes) 541 if ( i > 0 ) { 542 // not at the start, so get the element to the left 543 jqElBefore = jQuery( selectionTree[ i - 1 ].domobj ); 544 545 // and insert the break after it 546 jqElBefore.after( inBetweenMarkup ); 547 548 } else { 549 // at the start, so get the element to the right 550 jqElAfter = jQuery( selectionTree[1].domobj ); 551 552 // and insert the break before it 553 jqElAfter.before( inBetweenMarkup ); 554 } 555 556 // now set the range 557 rangeObject.startContainer = rangeObject.endContainer = inBetweenMarkup[0].parentNode; 558 rangeObject.startOffset = rangeObject.endOffset = GENTICS.Utils.Dom.getIndexInParent( inBetweenMarkup[0] ) + 1; 559 rangeObject.correctRange(); 560 561 } else if ( el.domobj && el.domobj.nodeType === 3 ) { // textNode 562 // when the textnode is immediately followed by a blocklevel element (like p, h1, ...) we need to add an additional br in between 563 if ( el.domobj.nextSibling 564 && el.domobj.nextSibling.nodeType == 1 565 && Aloha.Selection.replacingElements[ 566 el.domobj.nextSibling.nodeName.toLowerCase() 567 ] ) { 568 // TODO check whether this depends on the browser 569 jqEl.after( '<br/>' ); 570 } 571 572 if ( this.needEndingBreak() ) { 573 // when the textnode is the last inside a blocklevel element 574 // (like p, h1, ...) we need to add an additional br as very 575 // last object in the blocklevel element 576 checkObj = el.domobj; 577 578 while ( checkObj ) { 579 if ( checkObj.nextSibling ) { 580 checkObj = false; 581 } else { 582 // go to the parent 583 checkObj = checkObj.parentNode; 584 585 // found a blocklevel or list element, we are done 586 if ( GENTICS.Utils.Dom.isBlockLevelElement( checkObj ) 587 || GENTICS.Utils.Dom.isListElement( checkObj ) ) { 588 break; 589 } 590 591 // reached the limit object, we are done 592 if ( checkObj === rangeObject.limitObject ) { 593 checkObj = false; 594 } 595 } 596 } 597 598 // when we found a blocklevel element, insert a break at the 599 // end. Mark the break so that it is cleaned when the 600 // content is fetched. 601 if ( checkObj ) { 602 jQuery( checkObj ).append( '<br class="aloha-cleanme" />' ); 603 } 604 605 } 606 607 // insert the break 608 jqEl.between( inBetweenMarkup, el.startOffset ); 609 610 // correct the range 611 // count the number of previous siblings 612 offset = 0; 613 tmpObject = inBetweenMarkup[0]; 614 while ( tmpObject ) { 615 tmpObject = tmpObject.previousSibling; 616 ++offset; 617 } 618 619 rangeObject.startContainer = inBetweenMarkup[0].parentNode; 620 rangeObject.endContainer = inBetweenMarkup[0].parentNode; 621 rangeObject.startOffset = offset; 622 rangeObject.endOffset = offset; 623 rangeObject.correctRange(); 624 625 } else if ( el.domobj && el.domobj.nodeType === 1 ) { // other node, normally a break 626 if ( jqEl.parent().find( 'br.aloha-ephemera' ).length === 0 ) { 627 // but before putting it, remove all: 628 jQuery( rangeObject.limitObject ).find( 'br.aloha-ephemera' ).remove(); 629 630 // now put it: 631 jQuery( rangeObject.commonAncestorContainer ) 632 .append( this.getFillUpElement( rangeObject.splitObject ) ); 633 } 634 635 jqEl.after( inBetweenMarkup ); 636 637 // now set the selection. Since we just added one break do the currect el 638 // the new position must be el's position + 1. el's position is the index 639 // of the el in the selection tree, which is i. then we must add 640 // another +1 because we want to be AFTER the object, not before. therefor +2 641 rangeObject.startContainer = rangeObject.commonAncestorContainer; 642 rangeObject.endContainer = rangeObject.startContainer; 643 rangeObject.startOffset = i + 2; 644 rangeObject.endOffset = i + 2; 645 rangeObject.update(); 646 } 647 } 648 } 649 rangeObject.select(); 650 }, 651 652 /** 653 * Check whether blocklevel elements need breaks at the end to visibly render a newline 654 * @return true if an ending break is necessary, false if not 655 */ 656 needEndingBreak: function() { 657 // currently, all browser except IE need ending breaks 658 return !jQuery.browser.msie; 659 }, 660 661 /** 662 * Get the currently selected text or false if nothing is selected (or the selection is collapsed) 663 * @return selected text 664 */ 665 getSelectedText: function() { 666 var rangeObject = Aloha.Selection.rangeObject; 667 668 if ( rangeObject.isCollapsed() ) { 669 return false; 670 } 671 672 return this.getFromSelectionTree( rangeObject.getSelectionTree(), true ); 673 }, 674 675 /** 676 * Recursive function to get the selected text from the selection tree starting at the given level 677 * @param selectionTree array of selectiontree elements 678 * @param astext true when the contents shall be fetched as text, false for getting as html markup 679 * @return selected text from that level (incluiding all sublevels) 680 */ 681 getFromSelectionTree: function( selectionTree, astext ) { 682 var text = '', i, treeLength, el, clone; 683 for ( i = 0, treeLength = selectionTree.length; i < treeLength; i++ ) { 684 el = selectionTree[i]; 685 if ( el.selection == 'partial' ) { 686 if ( el.domobj.nodeType === 3 ) { 687 // partial text node selected, get the selected part 688 text += el.domobj.data.substring( el.startOffset, el.endOffset ); 689 } else if ( el.domobj.nodeType === 1 && el.children ) { 690 // partial element node selected, do the recursion into the children 691 if ( astext ) { 692 text += this.getFromSelectionTree( el.children, astext ); 693 } else { 694 // when the html shall be fetched, we create a clone of 695 // the element and remove all the children 696 clone = jQuery( el.domobj.outerHTML ).empty(); 697 // then we do the recursion and add the selection into the clone 698 clone.html( this.getFromSelectionTree( el.children, astext ) ); 699 // finally we get the html of the clone 700 text += clone.outerHTML(); 701 } 702 } 703 } else if ( el.selection == 'full' ) { 704 if ( el.domobj.nodeType === 3 ) { 705 // full text node selected, get the text 706 text += jQuery( el.domobj ).text(); 707 } else if ( el.domobj.nodeType === 1 && el.children ) { 708 // full element node selected, get the html of the node and all children 709 text += astext ? jQuery( el.domobj ).text() : jQuery( el.domobj ).outerHTML(); 710 } 711 } 712 } 713 714 return text; 715 }, 716 717 /** 718 * Get the currently selected markup or false if nothing is selected (or the selection is collapsed) 719 * @return {?String} 720 */ 721 getSelectedMarkup: function() { 722 var rangeObject = Aloha.Selection.rangeObject; 723 return rangeObject.isCollapsed() ? null 724 : this.getFromSelectionTree( rangeObject.getSelectionTree(), false ); 725 }, 726 727 /** 728 * Remove the currently selected markup 729 */ 730 removeSelectedMarkup: function() { 731 var rangeObject = Aloha.Selection.rangeObject, newRange; 732 733 if ( rangeObject.isCollapsed() ) { 734 return; 735 } 736 737 newRange = new Aloha.Selection.SelectionRange(); 738 // remove the selection 739 this.removeFromSelectionTree( rangeObject.getSelectionTree(), newRange ); 740 741 // do a cleanup now (starting with the commonancestorcontainer) 742 newRange.update(); 743 GENTICS.Utils.Dom.doCleanup( { 'merge' : true, 'removeempty' : true }, Aloha.Selection.rangeObject ); 744 Aloha.Selection.rangeObject = newRange; 745 746 // need to set the collapsed selection now 747 newRange.correctRange(); 748 newRange.update(); 749 newRange.select(); 750 Aloha.Selection.updateSelection(); 751 }, 752 753 /** 754 * Recursively remove the selected items, starting with the given level in the selectiontree 755 * @param selectionTree current level of the selectiontree 756 * @param newRange new collapsed range to be set after the removal 757 */ 758 removeFromSelectionTree: function( selectionTree, newRange ) { 759 // remember the first found partially selected element node (in case we need 760 // to merge it with the last found partially selected element node) 761 var firstPartialElement, 762 newdata, 763 i, 764 el, 765 adjacentTextNode, 766 treeLength; 767 768 // iterate through the selection tree 769 for ( i = 0, treeLength = selectionTree.length; i < treeLength; i++ ) { 770 el = selectionTree[ i ]; 771 772 // check the type of selection 773 if ( el.selection == 'partial' ) { 774 if ( el.domobj.nodeType === 3 ) { 775 // partial text node selected, so remove the selected portion 776 newdata = ''; 777 if ( el.startOffset > 0 ) { 778 newdata += el.domobj.data.substring( 0, el.startOffset ); 779 } 780 if ( el.endOffset < el.domobj.data.length ) { 781 newdata += el.domobj.data.substring( el.endOffset, el.domobj.data.length ); 782 } 783 el.domobj.data = newdata; 784 785 // eventually set the new range (if not done before) 786 if ( !newRange.startContainer ) { 787 newRange.startContainer = newRange.endContainer = el.domobj; 788 newRange.startOffset = newRange.endOffset = el.startOffset; 789 } 790 } else if ( el.domobj.nodeType === 1 && el.children ) { 791 // partial element node selected, so do the recursion into the children 792 this.removeFromSelectionTree( el.children, newRange ); 793 794 if ( firstPartialElement ) { 795 // when the first parially selected element is the same type 796 // of element, we need to merge them 797 if ( firstPartialElement.nodeName == el.domobj.nodeName ) { 798 // merge the nodes 799 jQuery( firstPartialElement ).append( jQuery( el.domobj ).contents() ); 800 801 // and remove the latter one 802 jQuery( el.domobj ).remove(); 803 } 804 805 } else { 806 // remember this element as first partially selected element 807 firstPartialElement = el.domobj; 808 } 809 } 810 811 } else if ( el.selection == 'full' ) { 812 // eventually set the new range (if not done before) 813 if ( !newRange.startContainer ) { 814 adjacentTextNode = GENTICS.Utils.Dom.searchAdjacentTextNode( 815 el.domobj.parentNode, 816 GENTICS.Utils.Dom.getIndexInParent( el.domobj ) + 1, 817 false, 818 { 'blocklevel' : false } 819 ); 820 821 if ( adjacentTextNode ) { 822 newRange.startContainer = newRange.endContainer = adjacentTextNode; 823 newRange.startOffset = newRange.endOffset = 0; 824 } else { 825 newRange.startContainer = newRange.endContainer = el.domobj.parentNode; 826 newRange.startOffset = newRange.endOffset = GENTICS.Utils.Dom.getIndexInParent( el.domobj ) + 1; 827 } 828 } 829 830 // full node selected, so just remove it (will also remove all children) 831 jQuery( el.domobj ).remove(); 832 } 833 } 834 }, 835 836 /** 837 * split passed rangeObject without or with optional markup 838 * @param Aloha.Selection.SelectionRange of the current selection 839 * @param markup object (jQuery) to insert in between the split elements 840 * @return void 841 */ 842 splitRangeObject: function( rangeObject, markup ) { 843 // UAAAA: first check where the markup can be inserted... *grrrrr*, then decide where to split 844 // object which is split up 845 var 846 splitObject = jQuery( rangeObject.splitObject ), 847 selectionTree, insertAfterObject, followUpContainer; 848 849 // update the commonAncestor with the splitObject (so that the selectionTree is correct) 850 rangeObject.update( rangeObject.splitObject ); // set the splitObject as new commonAncestorContainer and update the selectionTree 851 852 // calculate the selection tree. NOTE: it is necessary to do this before 853 // getting the followupcontainer, since getting the selection tree might 854 // possibly merge text nodes, which would lead to differences in the followupcontainer 855 selectionTree = rangeObject.getSelectionTree(); 856 857 // object to be inserted after the splitObject 858 followUpContainer = this.getSplitFollowUpContainer( rangeObject ); 859 860 // now split up the splitObject into itself AND the followUpContainer 861 this.splitRangeObjectHelper( selectionTree, rangeObject, followUpContainer ); // split the current object into itself and the followUpContainer 862 863 // check whether the followupcontainer is still marked for removal 864 if ( followUpContainer.hasClass( 'preparedForRemoval' ) ) { 865 // TODO shall we just remove the class or shall we not use the followupcontainer? 866 followUpContainer.removeClass( 'preparedForRemoval' ); 867 } 868 869 // now let's find the place, where the followUp is inserted afterwards. normally that's the splitObject itself, but in 870 // some cases it might be their parent (e.g. inside a list, a <p> followUp must be inserted outside the list) 871 insertAfterObject = this.getInsertAfterObject( rangeObject, followUpContainer ); 872 873 // now insert the followUpContainer 874 jQuery( followUpContainer ).insertAfter( insertAfterObject ); // attach the followUpContainer right after the insertAfterObject 875 876 // in some cases, we want to remove the "empty" splitObject (e.g. LIs, if enter was hit twice) 877 if ( rangeObject.splitObject.nodeName.toLowerCase() === 'li' && !Aloha.Selection.standardTextLevelSemanticsComparator( rangeObject.splitObject, followUpContainer ) ) { 878 jQuery( rangeObject.splitObject ).remove(); 879 } 880 881 rangeObject.startContainer = null; 882 // first check whether the followUpContainer starts with a <br/> 883 // if so, place the cursor right before the <br/> 884 var followContents = followUpContainer.contents(); 885 if ( followContents.length > 0 886 && followContents.get( 0 ).nodeType == 1 887 && followContents.get( 0 ).nodeName.toLowerCase() === 'br' ) { 888 rangeObject.startContainer = followUpContainer.get( 0 ); 889 } 890 891 if ( !rangeObject.startContainer ) { 892 // find a possible text node in the followUpContainer and set the selection to it 893 // if no textnode is available, set the selection to the followup container itself 894 rangeObject.startContainer = followUpContainer.textNodes( true, true ).first().get( 0 ); 895 } 896 if ( !rangeObject.startContainer ) { // if no text node was found, select the parent object of <br class="aloha-ephemera" /> 897 rangeObject.startContainer = followUpContainer.textNodes( false ).first().parent().get( 0 ); 898 } 899 if ( rangeObject.startContainer ) { 900 // the cursor is always at the beginning of the followUp 901 rangeObject.endContainer = rangeObject.startContainer; 902 rangeObject.startOffset = 0; 903 rangeObject.endOffset = 0; 904 } else { 905 rangeObject.startContainer = rangeObject.endContainer = followUpContainer.parent().get( 0 ); 906 rangeObject.startOffset = rangeObject.endOffset = GENTICS.Utils.Dom.getIndexInParent( followUpContainer.get( 0 ) ); 907 } 908 909 // finally update the range object again 910 rangeObject.update(); 911 912 // now set the selection 913 rangeObject.select(); 914 }, 915 916 /** 917 * method to get the object after which the followUpContainer can be inserted during splitup 918 * this is a helper method, not needed anywhere else 919 * @param rangeObject Aloha.Selection.SelectionRange of the current selection 920 * @param followUpContainer optional jQuery object; if provided the rangeObject will be split and the second part will be insert inside of this object 921 * @return object after which the followUpContainer can be inserted 922 */ 923 getInsertAfterObject: function( rangeObject, followUpContainer ) { 924 var passedSplitObject, i, el; 925 926 for ( i = 0; i < rangeObject.markupEffectiveAtStart.length; i++ ) { 927 el = rangeObject.markupEffectiveAtStart[ i ]; 928 929 // check if we have already passed the splitObject (some other markup might come before) 930 if ( el === rangeObject.splitObject ) { 931 passedSplitObject = true; 932 } 933 934 // if not passed splitObject, skip this markup 935 if ( !passedSplitObject ) { 936 continue; 937 } 938 939 // once we are passed, check if the followUpContainer is allowed to be inserted into the currents el's parent 940 if ( Aloha.Selection.canTag1WrapTag2( jQuery( el ).parent()[0].nodeName, followUpContainer[0].nodeName ) ) { 941 return el; 942 } 943 } 944 945 return false; 946 }, 947 948 /** 949 * @fixme: Someone who knows what this function does, please refactor it. 950 * 1. splitObject arg is not used at all 951 * 2. Would be better to use ternary operation would be better than if else statement 952 * 953 * method to get the html code for a fillUpElement. this is needed for empty paragraphs etc., so that they take up their expected height 954 * @param splitObject split object (dom object) 955 * @return fillUpElement HTML Code 956 */ 957 getFillUpElement: function( splitObject ) { 958 if ( jQuery.browser.msie ) { 959 return false; 960 } else { 961 return jQuery( '<br class="aloha-cleanme"/>' ); 962 } 963 }, 964 965 /** 966 * removes textNodes from passed array, which only contain contentWhiteSpace (e.g. a \n between two tags) 967 * @param domArray array of domObjects 968 * @return void 969 */ 970 removeElementContentWhitespaceObj: function( domArray ) { 971 var correction = 0, 972 removeLater = [], 973 i, 974 el, removeIndex; 975 976 for ( i = 0; i < domArray.length; ++i ) { 977 el = domArray[ i ]; 978 if ( el.isElementContentWhitespace ) { 979 removeLater[ removeLater.length ] = i; 980 } 981 } 982 983 for ( i = 0; i < removeLater.length; ++i ) { 984 removeIndex = removeLater[ i ]; 985 domArray.splice( removeIndex - correction, 1 ); 986 ++correction; 987 } 988 }, 989 990 /** 991 * recursive method to parallelly walk through two dom subtrees, leave elements before startContainer in first subtree and move rest to other 992 * @param selectionTree tree to iterate over as contained in rangeObject. must be passed separately to allow recursion in the selection tree, but not in the rangeObject 993 * @param rangeObject Aloha.Selection.SelectionRange of the current selection 994 * @param followUpContainer optional jQuery object; if provided the rangeObject will be split and the second part will be insert inside of this object 995 * @param inBetweenMarkup jQuery object to be inserted between the two split parts. will be either a <br> (if no followUpContainer is passed) OR e.g. a table, which must be inserted between the splitobject AND the follow up 996 * @return void 997 */ 998 splitRangeObjectHelper: function( selectionTree, rangeObject, 999 followUpContainer, inBetweenMarkup ) { 1000 if ( !followUpContainer ) { 1001 Aloha.Log.warn( this, 'no followUpContainer, no inBetweenMarkup, nothing to do...' ); 1002 } 1003 1004 var fillUpElement = this.getFillUpElement( rangeObject.splitObject ), 1005 splitObject = jQuery( rangeObject.splitObject ), 1006 startMoving = false, 1007 el, 1008 i, 1009 completeText, 1010 jqObj, 1011 mirrorLevel, 1012 parent, 1013 treeLength; 1014 1015 if ( selectionTree.length > 0 ) { 1016 mirrorLevel = followUpContainer.contents(); 1017 1018 // if length of mirrorLevel and selectionTree are not equal, the mirrorLevel must be corrected. this happens, when the mirrorLevel contains whitespace textNodes 1019 if ( mirrorLevel.length !== selectionTree.length ) { 1020 this.removeElementContentWhitespaceObj( mirrorLevel ); 1021 } 1022 1023 for ( i = 0, treeLength = selectionTree.length; i < treeLength; ++i ) { 1024 el = selectionTree[ i ]; 1025 1026 // remove all objects in the mirrorLevel, which are BEFORE the cursor 1027 // OR if the cursor is at the last position of the last Textnode (causing an empty followUpContainer to be appended) 1028 if ( ( el.selection === 'none' && startMoving === false ) || 1029 ( el.domobj && el.domobj.nodeType === 3 1030 && el === selectionTree[ ( selectionTree.length - 1 ) ] 1031 && el.startOffset === el.domobj.data.length ) ) { 1032 // iteration is before cursor, leave this part inside the splitObject, remove from followUpContainer 1033 // however if the object to remove is the last existing textNode within the followUpContainer, insert a BR instead 1034 // otherwise the followUpContainer is invalid and takes up no vertical space 1035 1036 if ( followUpContainer.textNodes().length > 1 1037 || ( el.domobj.nodeType === 1 && el.children.length === 0 ) ) { 1038 // note: the second part of the if (el.domobj.nodeType === 1 && el.children.length === 0) covers a very special condition, 1039 // where an empty tag is located right before the cursor when pressing enter. In this case the empty tag would not be 1040 // removed correctly otherwise 1041 mirrorLevel.eq( i ).remove(); 1042 1043 } else if ( GENTICS.Utils.Dom.isSplitObject( followUpContainer[0] ) ) { 1044 if ( fillUpElement ) { 1045 followUpContainer.html( fillUpElement ); // for your zoological german knowhow: ephemera = Eintagsfliege 1046 } else { 1047 followUpContainer.empty(); 1048 } 1049 1050 } else { 1051 followUpContainer.empty(); 1052 followUpContainer.addClass( 'preparedForRemoval' ); 1053 } 1054 1055 continue; 1056 1057 } else { 1058 // split objects, which are AT the cursor Position or directly above 1059 if ( el.selection !== 'none' ) { // before cursor, leave this part inside the splitObject 1060 // TODO better check for selection == 'partial' here? 1061 if ( el.domobj && el.domobj.nodeType === 3 && el.startOffset !== undefined ) { 1062 completeText = el.domobj.data; 1063 if ( el.startOffset > 0 ) {// first check, if there will be some text left in the splitObject 1064 el.domobj.data = completeText.substr( 0, el.startOffset ); 1065 } else if ( selectionTree.length > 1 ) { // if not, check if the splitObject contains more than one node, because then it can be removed. this happens, when ENTER is pressed inside of a textnode, but not at the borders 1066 jQuery( el.domobj ).remove(); 1067 } else { // if the "empty" textnode is the last node left in the splitObject, replace it with a ephemera break 1068 // if the parent is a blocklevel element, we insert the fillup element 1069 parent = jQuery( el.domobj ).parent(); 1070 if ( GENTICS.Utils.Dom.isSplitObject( parent[0] ) ) { 1071 if ( fillUpElement ) { 1072 parent.html( fillUpElement ); 1073 } else { 1074 parent.empty(); 1075 } 1076 1077 } else { 1078 // if the parent is no blocklevel element and would be empty now, we completely remove it 1079 parent.remove(); 1080 } 1081 } 1082 if ( completeText.length - el.startOffset > 0 ) { 1083 // first check if there is text left to put in the followUpContainer's textnode. this happens, when ENTER is pressed inside of a textnode, but not at the borders 1084 mirrorLevel[i].data = completeText.substr( el.startOffset, completeText.length ); 1085 } else if ( mirrorLevel.length > 1 ) { 1086 // if not, check if the followUpContainer contains more than one node, because if yes, the "empty" textnode can be removed 1087 mirrorLevel.eq( ( i ) ).remove(); 1088 } else if ( GENTICS.Utils.Dom.isBlockLevelElement( followUpContainer[0] ) ) { 1089 // if the "empty" textnode is the last node left in the followUpContainer (which is a blocklevel element), replace it with a ephemera break 1090 if ( fillUpElement ) { 1091 followUpContainer.html( fillUpElement ); 1092 } else { 1093 followUpContainer.empty(); 1094 } 1095 1096 } else { 1097 // if the "empty" textnode is the last node left in a non-blocklevel element, mark it for removal 1098 followUpContainer.empty(); 1099 followUpContainer.addClass( 'preparedForRemoval' ); 1100 } 1101 } 1102 1103 startMoving = true; 1104 1105 if ( el.children.length > 0 ) { 1106 this.splitRangeObjectHelper( el.children, rangeObject, mirrorLevel.eq( i ), inBetweenMarkup ); 1107 } 1108 1109 } else { 1110 // remove all objects in the origin, which are AFTER the cursor 1111 if ( el.selection === 'none' && startMoving === true ) { 1112 // iteration is after cursor, remove from splitObject and leave this part inside the followUpContainer 1113 jqObj = jQuery( el.domobj ).remove(); 1114 } 1115 } 1116 } 1117 } 1118 } else { 1119 Aloha.Log.error( this, 'can not split splitObject due to an empty selection tree' ); 1120 } 1121 1122 // and finally cleanup: remove all fillUps > 1 1123 splitObject.find( 'br.aloha-ephemera:gt(0)' ).remove(); // remove all elements greater than (gt) 0, that also means: leave one 1124 followUpContainer.find( 'br.aloha-ephemera:gt(0)' ).remove(); // remove all elements greater than (gt) 0, that also means: leave one 1125 1126 // remove objects prepared for removal 1127 splitObject.find( '.preparedForRemoval' ).remove(); 1128 followUpContainer.find( '.preparedForRemoval' ).remove(); 1129 1130 // if splitObject / followUp are empty, place a fillUp inside 1131 if ( splitObject.contents().length === 0 1132 && GENTICS.Utils.Dom.isSplitObject( splitObject[0] ) 1133 && fillUpElement ) { 1134 splitObject.html( fillUpElement ); 1135 } 1136 1137 if ( followUpContainer.contents().length === 0 1138 && GENTICS.Utils.Dom.isSplitObject( followUpContainer[0] ) 1139 && fillUpElement ) { 1140 followUpContainer.html( fillUpElement ); 1141 } 1142 }, 1143 1144 /** 1145 * returns a jQuery object fitting the passed splitObject as follow up object 1146 * examples, 1147 * - when passed a p it will return an empty p (clone of the passed p) 1148 * - when passed an h1, it will return either an h1 (clone of the passed one) or a new p (if the collapsed selection was at the end) 1149 * @param rangeObject Aloha.RangeObject 1150 * @return void 1151 */ 1152 getSplitFollowUpContainer: function( rangeObject ) { 1153 var tagName = rangeObject.splitObject.nodeName.toLowerCase(), 1154 returnObj, 1155 inside, 1156 lastObj; 1157 1158 switch ( tagName ) { 1159 case 'h1': 1160 case 'h2': 1161 case 'h3': 1162 case 'h4': 1163 case 'h5': 1164 case 'h6': 1165 // get the last textnode in the splitobject, but don't consider aloha-cleanme elements 1166 lastObj = jQuery( rangeObject.splitObject ).textNodes( ':not(.aloha-cleanme)' ).last()[0]; 1167 // special case: when enter is hit at the end of a heading, the followUp should be a <p> 1168 if ( lastObj && rangeObject.startContainer === lastObj 1169 && rangeObject.startOffset === lastObj.length ) { 1170 returnObj = jQuery( '<p></p>' ); 1171 inside = jQuery( rangeObject.splitObject.outerHTML ).contents(); 1172 returnObj.append( inside ); 1173 return returnObj; 1174 } 1175 break; 1176 1177 case 'li': 1178 // TODO check whether the li is the last one 1179 // special case: if enter is hit twice inside a list, the next item should be a <p> (and inserted outside the list) 1180 if ( rangeObject.startContainer.nodeName.toLowerCase() === 'br' 1181 && jQuery( rangeObject.startContainer ).hasClass( 'aloha-ephemera' ) ) { 1182 returnObj = jQuery( '<p></p>' ); 1183 inside = jQuery( rangeObject.splitObject.outerHTML ).contents(); 1184 returnObj.append( inside ); 1185 return returnObj; 1186 } 1187 // when the li is the last one and empty, we also just return a <p> 1188 if ( !rangeObject.splitObject.nextSibling 1189 && jQuery.trim( jQuery( rangeObject.splitObject ).text() ).length === 0 ) { 1190 returnObj = jQuery( '<p></p>' ); 1191 return returnObj; 1192 } 1193 } 1194 1195 return jQuery( rangeObject.splitObject.outerHTML ); 1196 }, 1197 1198 /** 1199 * Transform the given domobj into an object with the given new nodeName. 1200 * Preserves the content and all attributes. If a range object is given, also the range will be preserved 1201 * @param domobj dom object to transform 1202 * @param nodeName new node name 1203 * @param range range object 1204 * @api 1205 * @return new object as jQuery object 1206 */ 1207 transformDomObject: function( domobj, nodeName, range ) { 1208 // first create the new element 1209 var jqOldObj = jQuery( domobj ), 1210 jqNewObj = jQuery('<' + nodeName + '>'), 1211 i, 1212 attributes = jqOldObj[0].cloneNode(false).attributes; 1213 1214 // TODO what about events? 1215 // copy attributes 1216 if (attributes) { 1217 for ( i = 0; i < attributes.length; ++i ) { 1218 if ( typeof attributes[i].specified === 'undefined' 1219 || attributes[i].specified) { 1220 jqNewObj.attr( 1221 attributes[ i ].nodeName, 1222 attributes[ i ].nodeValue 1223 ); 1224 } 1225 } 1226 } 1227 1228 // copy inline CSS 1229 if ( jqOldObj[0].style && jqOldObj[0].style.cssText ) { 1230 jqNewObj[0].style.cssText = jqOldObj[0].style.cssText; 1231 } 1232 1233 // now move the contents of the old dom object into the new dom object 1234 jqOldObj.contents().appendTo( jqNewObj ); 1235 1236 // finally replace the old object with the new one 1237 jqOldObj.replaceWith( jqNewObj ); 1238 1239 // preserve the range 1240 if ( range ) { 1241 if ( range.startContainer == domobj ) { 1242 range.startContainer = jqNewObj.get( 0 ); 1243 } 1244 1245 if ( range.endContainer == domobj ) { 1246 range.endContainer = jqNewObj.get( 0 ); 1247 } 1248 } 1249 1250 return jqNewObj; 1251 }, 1252 1253 /** 1254 * String representation 1255 * @return {String} 1256 */ 1257 toString: function() { 1258 return 'Aloha.Markup'; 1259 } 1260 1261 } ); 1262 1263 Aloha.Markup = new Aloha.Markup(); 1264 1265 return Aloha.Markup; 1266 1267 } ); 1268