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