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 690 // the element and remove all the children 691 clone = jQuery( el.domobj.outerHTML ).empty(); 692 // then we do the recursion and add the selection into the clone 693 clone.html( this.getFromSelectionTree( el.children, astext ) ); 694 // finally we get the html of the clone 695 text += clone.outerHTML(); 696 } 697 } 698 } else if ( el.selection == 'full' ) { 699 if ( el.domobj.nodeType === 3 ) { 700 // full text node selected, get the text 701 text += jQuery( el.domobj ).text(); 702 } else if ( el.domobj.nodeType === 1 && el.children ) { 703 // full element node selected, get the html of the node and all children 704 text += astext ? jQuery( el.domobj ).text() : jQuery( el.domobj ).outerHTML(); 705 } 706 } 707 } 708 709 return text; 710 }, 711 712 /** 713 * Get the currently selected markup or false if nothing is selected (or the selection is collapsed) 714 * @return {?String} 715 */ 716 getSelectedMarkup: function() { 717 var rangeObject = Aloha.Selection.rangeObject; 718 719 return rangeObject.isCollapsed() ? null 720 : this.getFromSelectionTree( rangeObject.getSelectionTree(), false ); 721 }, 722 723 /** 724 * Remove the currently selected markup 725 */ 726 removeSelectedMarkup: function() { 727 var rangeObject = Aloha.Selection.rangeObject, newRange; 728 729 if ( rangeObject.isCollapsed() ) { 730 return; 731 } 732 733 newRange = new Aloha.Selection.SelectionRange(); 734 // remove the selection 735 this.removeFromSelectionTree( rangeObject.getSelectionTree(), newRange ); 736 737 // do a cleanup now (starting with the commonancestorcontainer) 738 newRange.update(); 739 GENTICS.Utils.Dom.doCleanup( { 'merge' : true, 'removeempty' : true }, Aloha.Selection.rangeObject ); 740 Aloha.Selection.rangeObject = newRange; 741 742 // need to set the collapsed selection now 743 newRange.correctRange(); 744 newRange.update(); 745 newRange.select(); 746 Aloha.Selection.updateSelection(); 747 }, 748 749 /** 750 * Recursively remove the selected items, starting with the given level in the selectiontree 751 * @param selectionTree current level of the selectiontree 752 * @param newRange new collapsed range to be set after the removal 753 */ 754 removeFromSelectionTree: function( selectionTree, newRange ) { 755 // remember the first found partially selected element node (in case we need 756 // to merge it with the last found partially selected element node) 757 var firstPartialElement, 758 newdata, 759 i, 760 el, 761 adjacentTextNode, 762 treeLength; 763 764 // iterate through the selection tree 765 for ( i = 0, treeLength = selectionTree.length; i < treeLength; i++ ) { 766 el = selectionTree[ i ]; 767 768 // check the type of selection 769 if ( el.selection == 'partial' ) { 770 if ( el.domobj.nodeType === 3 ) { 771 // partial text node selected, so remove the selected portion 772 newdata = ''; 773 if ( el.startOffset > 0 ) { 774 newdata += el.domobj.data.substring( 0, el.startOffset ); 775 } 776 if ( el.endOffset < el.domobj.data.length ) { 777 newdata += el.domobj.data.substring( el.endOffset, el.domobj.data.length ); 778 } 779 el.domobj.data = newdata; 780 781 // eventually set the new range (if not done before) 782 if ( !newRange.startContainer ) { 783 newRange.startContainer = newRange.endContainer = el.domobj; 784 newRange.startOffset = newRange.endOffset = el.startOffset; 785 } 786 } else if ( el.domobj.nodeType === 1 && el.children ) { 787 // partial element node selected, so do the recursion into the children 788 this.removeFromSelectionTree( el.children, newRange ); 789 790 if ( firstPartialElement ) { 791 // when the first parially selected element is the same type 792 // of element, we need to merge them 793 if ( firstPartialElement.nodeName == el.domobj.nodeName ) { 794 // merge the nodes 795 jQuery( firstPartialElement ).append( jQuery( el.domobj ).contents() ); 796 797 // and remove the latter one 798 jQuery( el.domobj ).remove(); 799 } 800 801 } else { 802 // remember this element as first partially selected element 803 firstPartialElement = el.domobj; 804 } 805 } 806 807 } else if ( el.selection == 'full' ) { 808 // eventually set the new range (if not done before) 809 if ( !newRange.startContainer ) { 810 adjacentTextNode = GENTICS.Utils.Dom.searchAdjacentTextNode( 811 el.domobj.parentNode, 812 GENTICS.Utils.Dom.getIndexInParent( el.domobj ) + 1, 813 false, 814 { 'blocklevel' : false } 815 ); 816 817 if ( adjacentTextNode ) { 818 newRange.startContainer = newRange.endContainer = adjacentTextNode; 819 newRange.startOffset = newRange.endOffset = 0; 820 } else { 821 newRange.startContainer = newRange.endContainer = el.domobj.parentNode; 822 newRange.startOffset = newRange.endOffset = GENTICS.Utils.Dom.getIndexInParent( el.domobj ) + 1; 823 } 824 } 825 826 // full node selected, so just remove it (will also remove all children) 827 jQuery( el.domobj ).remove(); 828 } 829 } 830 }, 831 832 /** 833 * split passed rangeObject without or with optional markup 834 * @param Aloha.Selection.SelectionRange of the current selection 835 * @param markup object (jQuery) to insert in between the split elements 836 * @return void 837 */ 838 splitRangeObject: function( rangeObject, markup ) { 839 // UAAAA: first check where the markup can be inserted... *grrrrr*, then decide where to split 840 // object which is split up 841 var 842 splitObject = jQuery( rangeObject.splitObject ), 843 selectionTree, insertAfterObject, followUpContainer; 844 845 846 // update the commonAncestor with the splitObject (so that the selectionTree is correct) 847 rangeObject.update( rangeObject.splitObject ); // set the splitObject as new commonAncestorContainer and update the selectionTree 848 849 // calculate the selection tree. NOTE: it is necessary to do this before 850 // getting the followupcontainer, since getting the selection tree might 851 // possibly merge text nodes, which would lead to differences in the followupcontainer 852 selectionTree = rangeObject.getSelectionTree(); 853 854 // object to be inserted after the splitObject 855 followUpContainer = this.getSplitFollowUpContainer( rangeObject ); 856 857 // now split up the splitObject into itself AND the followUpContainer 858 this.splitRangeObjectHelper( selectionTree, rangeObject, followUpContainer ); // split the current object into itself and the followUpContainer 859 860 // check whether the followupcontainer is still marked for removal 861 if ( followUpContainer.hasClass( 'preparedForRemoval' ) ) { 862 // TODO shall we just remove the class or shall we not use the followupcontainer? 863 followUpContainer.removeClass( 'preparedForRemoval' ); 864 } 865 866 // now let's find the place, where the followUp is inserted afterwards. normally that's the splitObject itself, but in 867 // some cases it might be their parent (e.g. inside a list, a <p> followUp must be inserted outside the list) 868 insertAfterObject = this.getInsertAfterObject( rangeObject, followUpContainer ); 869 870 // now insert the followUpContainer 871 jQuery( followUpContainer ).insertAfter( insertAfterObject ); // attach the followUpContainer right after the insertAfterObject 872 873 // in some cases, we want to remove the "empty" splitObject (e.g. LIs, if enter was hit twice) 874 if ( rangeObject.splitObject.nodeName.toLowerCase() === 'li' && !Aloha.Selection.standardTextLevelSemanticsComparator( rangeObject.splitObject, followUpContainer ) ) { 875 jQuery( rangeObject.splitObject ).remove(); 876 } 877 878 rangeObject.startContainer = null; 879 // first check whether the followUpContainer starts with a <br/> 880 // if so, place the cursor right before the <br/> 881 var followContents = followUpContainer.contents(); 882 if ( followContents.length > 0 883 && followContents.get( 0 ).nodeType == 1 884 && followContents.get( 0 ).nodeName.toLowerCase() === 'br' ) { 885 rangeObject.startContainer = followUpContainer.get( 0 ); 886 } 887 888 if ( !rangeObject.startContainer ) { 889 // find a possible text node in the followUpContainer and set the selection to it 890 // if no textnode is available, set the selection to the followup container itself 891 rangeObject.startContainer = followUpContainer.textNodes( true, true ).first().get( 0 ); 892 } 893 if ( !rangeObject.startContainer ) { // if no text node was found, select the parent object of <br class="aloha-ephemera" /> 894 rangeObject.startContainer = followUpContainer.textNodes( false ).first().parent().get( 0 ); 895 } 896 if ( rangeObject.startContainer ) { 897 // the cursor is always at the beginning of the followUp 898 rangeObject.endContainer = rangeObject.startContainer; 899 rangeObject.startOffset = 0; 900 rangeObject.endOffset = 0; 901 } else { 902 rangeObject.startContainer = rangeObject.endContainer = followUpContainer.parent().get( 0 ); 903 rangeObject.startOffset = rangeObject.endOffset = GENTICS.Utils.Dom.getIndexInParent( followUpContainer.get( 0 ) ); 904 } 905 906 // finally update the range object again 907 rangeObject.update(); 908 909 // now set the selection 910 rangeObject.select(); 911 }, 912 913 /** 914 * method to get the object after which the followUpContainer can be inserted during splitup 915 * this is a helper method, not needed anywhere else 916 * @param rangeObject Aloha.Selection.SelectionRange of the current selection 917 * @param followUpContainer optional jQuery object; if provided the rangeObject will be split and the second part will be insert inside of this object 918 * @return object after which the followUpContainer can be inserted 919 */ 920 getInsertAfterObject: function( rangeObject, followUpContainer ) { 921 var passedSplitObject, i, el; 922 923 for ( i = 0; i < rangeObject.markupEffectiveAtStart.length; i++ ) { 924 el = rangeObject.markupEffectiveAtStart[ i ]; 925 926 // check if we have already passed the splitObject (some other markup might come before) 927 if ( el === rangeObject.splitObject ) { 928 passedSplitObject = true; 929 } 930 931 // if not passed splitObject, skip this markup 932 if ( !passedSplitObject ) { 933 continue; 934 } 935 936 // once we are passed, check if the followUpContainer is allowed to be inserted into the currents el's parent 937 if ( Aloha.Selection.canTag1WrapTag2( jQuery( el ).parent()[0].nodeName, followUpContainer[0].nodeName ) ) { 938 return el; 939 } 940 } 941 942 return false; 943 }, 944 945 /** 946 * @fixme: Someone who knows what this function does, please refactor it. 947 * 1. splitObject arg is not used at all 948 * 2. Would be better to use ternary operation would be better than if else statement 949 * 950 * method to get the html code for a fillUpElement. this is needed for empty paragraphs etc., so that they take up their expected height 951 * @param splitObject split object (dom object) 952 * @return fillUpElement HTML Code 953 */ 954 getFillUpElement: function( splitObject ) { 955 if ( jQuery.browser.msie ) { 956 return false; 957 } else { 958 return jQuery( '<br class="aloha-cleanme"/>' ); 959 } 960 }, 961 962 /** 963 * removes textNodes from passed array, which only contain contentWhiteSpace (e.g. a \n between two tags) 964 * @param domArray array of domObjects 965 * @return void 966 */ 967 removeElementContentWhitespaceObj: function( domArray ) { 968 var correction = 0, 969 removeLater = [], 970 i, 971 el, removeIndex; 972 973 for ( i = 0; i < domArray.length; ++i ) { 974 el = domArray[ i ]; 975 if ( el.isElementContentWhitespace ) { 976 removeLater[ removeLater.length ] = i; 977 } 978 } 979 980 for ( i = 0; i < removeLater.length; ++i ) { 981 removeIndex = removeLater[ i ]; 982 domArray.splice( removeIndex - correction, 1 ); 983 ++correction; 984 } 985 }, 986 987 /** 988 * recursive method to parallelly walk through two dom subtrees, leave elements before startContainer in first subtree and move rest to other 989 * @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 990 * @param rangeObject Aloha.Selection.SelectionRange of the current selection 991 * @param followUpContainer optional jQuery object; if provided the rangeObject will be split and the second part will be insert inside of this object 992 * @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 993 * @return void 994 */ 995 splitRangeObjectHelper: function( selectionTree, rangeObject, 996 followUpContainer, inBetweenMarkup ) { 997 if ( !followUpContainer ) { 998 Aloha.Log.warn( this, 'no followUpContainer, no inBetweenMarkup, nothing to do...' ); 999 } 1000 1001 var fillUpElement = this.getFillUpElement( rangeObject.splitObject ), 1002 splitObject = jQuery( rangeObject.splitObject ), 1003 startMoving = false, 1004 el, 1005 i, 1006 completeText, 1007 jqObj, 1008 mirrorLevel, 1009 parent, 1010 treeLength; 1011 1012 if ( selectionTree.length > 0 ) { 1013 mirrorLevel = followUpContainer.contents(); 1014 1015 // if length of mirrorLevel and selectionTree are not equal, the mirrorLevel must be corrected. this happens, when the mirrorLevel contains whitespace textNodes 1016 if ( mirrorLevel.length !== selectionTree.length ) { 1017 this.removeElementContentWhitespaceObj( mirrorLevel ); 1018 } 1019 1020 for ( i = 0, treeLength = selectionTree.length; i < treeLength; ++i ) { 1021 el = selectionTree[ i ]; 1022 1023 // remove all objects in the mirrorLevel, which are BEFORE the cursor 1024 // OR if the cursor is at the last position of the last Textnode (causing an empty followUpContainer to be appended) 1025 if ( ( el.selection === 'none' && startMoving === false ) || 1026 ( el.domobj && el.domobj.nodeType === 3 1027 && el === selectionTree[ ( selectionTree.length - 1 ) ] 1028 && el.startOffset === el.domobj.data.length ) ) { 1029 // iteration is before cursor, leave this part inside the splitObject, remove from followUpContainer 1030 // however if the object to remove is the last existing textNode within the followUpContainer, insert a BR instead 1031 // otherwise the followUpContainer is invalid and takes up no vertical space 1032 1033 if ( followUpContainer.textNodes().length > 1 1034 || ( el.domobj.nodeType === 1 && el.children.length === 0 ) ) { 1035 // note: the second part of the if (el.domobj.nodeType === 1 && el.children.length === 0) covers a very special condition, 1036 // where an empty tag is located right before the cursor when pressing enter. In this case the empty tag would not be 1037 // removed correctly otherwise 1038 mirrorLevel.eq( i ).remove(); 1039 1040 } else if ( GENTICS.Utils.Dom.isSplitObject( followUpContainer[0] ) ) { 1041 if ( fillUpElement ) { 1042 followUpContainer.html( fillUpElement ); // for your zoological german knowhow: ephemera = Eintagsfliege 1043 } else { 1044 followUpContainer.empty(); 1045 } 1046 1047 } else { 1048 followUpContainer.empty(); 1049 followUpContainer.addClass( 'preparedForRemoval' ); 1050 } 1051 1052 continue; 1053 1054 } else { 1055 // split objects, which are AT the cursor Position or directly above 1056 if ( el.selection !== 'none' ) { // before cursor, leave this part inside the splitObject 1057 // TODO better check for selection == 'partial' here? 1058 if ( el.domobj && el.domobj.nodeType === 3 && el.startOffset !== undefined ) { 1059 completeText = el.domobj.data; 1060 if ( el.startOffset > 0 ) {// first check, if there will be some text left in the splitObject 1061 1062 el.domobj.data = completeText.substr( 0, el.startOffset ); 1063 } 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 1064 jQuery( el.domobj ).remove(); 1065 } else { // if the "empty" textnode is the last node left in the splitObject, replace it with a ephemera break 1066 // if the parent is a blocklevel element, we insert the fillup element 1067 parent = jQuery( el.domobj ).parent(); 1068 if ( GENTICS.Utils.Dom.isSplitObject( parent[0] ) ) { 1069 if ( fillUpElement ) { 1070 parent.html( fillUpElement ); 1071 } else { 1072 parent.empty(); 1073 } 1074 1075 } else { 1076 // if the parent is no blocklevel element and would be empty now, we completely remove it 1077 parent.remove(); 1078 } 1079 } 1080 if ( completeText.length - el.startOffset > 0 ) { 1081 // 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 1082 mirrorLevel[i].data = completeText.substr( el.startOffset, completeText.length ); 1083 } else if ( mirrorLevel.length > 1 ) { 1084 // if not, check if the followUpContainer contains more than one node, because if yes, the "empty" textnode can be removed 1085 mirrorLevel.eq( ( i ) ).remove(); 1086 } else if ( GENTICS.Utils.Dom.isBlockLevelElement( followUpContainer[0] ) ) { 1087 // if the "empty" textnode is the last node left in the followUpContainer (which is a blocklevel element), replace it with a ephemera break 1088 if ( fillUpElement ) { 1089 followUpContainer.html( fillUpElement ); 1090 } else { 1091 followUpContainer.empty(); 1092 } 1093 1094 } else { 1095 // if the "empty" textnode is the last node left in a non-blocklevel element, mark it for removal 1096 followUpContainer.empty(); 1097 followUpContainer.addClass( 'preparedForRemoval' ); 1098 } 1099 } 1100 1101 startMoving = true; 1102 1103 if ( el.children.length > 0 ) { 1104 this.splitRangeObjectHelper( el.children, rangeObject, mirrorLevel.eq( i ), inBetweenMarkup ); 1105 } 1106 1107 } else { 1108 // remove all objects in the origin, which are AFTER the cursor 1109 if ( el.selection === 'none' && startMoving === true ) { 1110 // iteration is after cursor, remove from splitObject and leave this part inside the followUpContainer 1111 jqObj = jQuery( el.domobj ).remove(); 1112 } 1113 } 1114 } 1115 } 1116 } else { 1117 Aloha.Log.error( this, 'can not split splitObject due to an empty selection tree' ); 1118 } 1119 1120 // and finally cleanup: remove all fillUps > 1 1121 splitObject.find( 'br.aloha-ephemera:gt(0)' ).remove(); // remove all elements greater than (gt) 0, that also means: leave one 1122 followUpContainer.find( 'br.aloha-ephemera:gt(0)' ).remove(); // remove all elements greater than (gt) 0, that also means: leave one 1123 1124 // remove objects prepared for removal 1125 splitObject.find( '.preparedForRemoval' ).remove(); 1126 followUpContainer.find( '.preparedForRemoval' ).remove(); 1127 1128 // if splitObject / followUp are empty, place a fillUp inside 1129 if ( splitObject.contents().length === 0 1130 && GENTICS.Utils.Dom.isSplitObject( splitObject[0] ) 1131 && fillUpElement ) { 1132 splitObject.html( fillUpElement ); 1133 } 1134 1135 if ( followUpContainer.contents().length === 0 1136 && GENTICS.Utils.Dom.isSplitObject( followUpContainer[0] ) 1137 && fillUpElement ) { 1138 followUpContainer.html( fillUpElement ); 1139 } 1140 }, 1141 1142 /** 1143 * returns a jQuery object fitting the passed splitObject as follow up object 1144 * examples, 1145 * - when passed a p it will return an empty p (clone of the passed p) 1146 * - 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) 1147 * @param rangeObject Aloha.RangeObject 1148 * @return void 1149 */ 1150 getSplitFollowUpContainer: function( rangeObject ) { 1151 var tagName = rangeObject.splitObject.nodeName.toLowerCase(), 1152 returnObj, 1153 inside, 1154 lastObj; 1155 1156 switch ( tagName ) { 1157 case 'h1': 1158 case 'h2': 1159 case 'h3': 1160 case 'h4': 1161 case 'h5': 1162 case 'h6': 1163 // get the last textnode in the splitobject, but don't consider aloha-cleanme elements 1164 lastObj = jQuery( rangeObject.splitObject ).textNodes( ':not(.aloha-cleanme)' ).last()[0]; 1165 // special case: when enter is hit at the end of a heading, the followUp should be a <p> 1166 if ( lastObj && rangeObject.startContainer === lastObj 1167 && rangeObject.startOffset === lastObj.length ) { 1168 returnObj = jQuery( '<p></p>' ); 1169 inside = jQuery( rangeObject.splitObject.outerHTML ).contents(); 1170 returnObj.append( inside ); 1171 return returnObj; 1172 } 1173 break; 1174 1175 case 'li': 1176 // TODO check whether the li is the last one 1177 // special case: if enter is hit twice inside a list, the next item should be a <p> (and inserted outside the list) 1178 if ( rangeObject.startContainer.nodeName.toLowerCase() === 'br' 1179 && jQuery( rangeObject.startContainer ).hasClass( 'aloha-ephemera' ) ) { 1180 returnObj = jQuery( '<p></p>' ); 1181 inside = jQuery( rangeObject.splitObject.outerHTML ).contents(); 1182 returnObj.append( inside ); 1183 return returnObj; 1184 } 1185 // when the li is the last one and empty, we also just return a <p> 1186 if ( !rangeObject.splitObject.nextSibling 1187 && jQuery.trim( jQuery( rangeObject.splitObject ).text() ).length === 0 ) { 1188 returnObj = jQuery( '<p></p>' ); 1189 return returnObj; 1190 } 1191 } 1192 1193 return jQuery( rangeObject.splitObject.outerHTML ); 1194 }, 1195 1196 /** 1197 * Transform the given domobj into an object with the given new nodeName. 1198 * Preserves the content and all attributes. If a range object is given, also the range will be preserved 1199 * @param domobj dom object to transform 1200 * @param nodeName new node name 1201 * @param range range object 1202 * @api 1203 * @return new object as jQuery object 1204 */ 1205 transformDomObject: function( domobj, nodeName, range ) { 1206 // first create the new element 1207 var jqOldObj = jQuery( domobj ), 1208 jqNewObj = jQuery( '<' + nodeName + '></' + nodeName + '>' ), 1209 i; 1210 1211 // TODO what about events? 1212 1213 // copy attributes 1214 if ( jqOldObj[0].attributes ) { 1215 for ( i = 0; i < jqOldObj[0].attributes.length; ++i ) { 1216 jqNewObj.attr( 1217 jqOldObj[0].attributes[ i ].nodeName, 1218 jqOldObj[0].attributes[ i ].nodeValue 1219 ); 1220 } 1221 } 1222 1223 // copy inline CSS 1224 if ( jqOldObj[0].style && jqOldObj[0].style.cssText ) { 1225 jqNewObj[0].style.cssText = jqOldObj[0].style.cssText; 1226 } 1227 1228 // now move the contents of the old dom object into the new dom object 1229 jqOldObj.contents().appendTo( jqNewObj ); 1230 1231 // finally replace the old object with the new one 1232 jqOldObj.replaceWith( jqNewObj ); 1233 1234 // preserve the range 1235 if ( range ) { 1236 if ( range.startContainer == domobj ) { 1237 range.startContainer = jqNewObj.get( 0 ); 1238 } 1239 1240 if ( range.endContainer == domobj ) { 1241 range.endContainer = jqNewObj.get( 0 ); 1242 } 1243 } 1244 1245 return jqNewObj; 1246 }, 1247 1248 /** 1249 * String representation 1250 * @return {String} 1251 */ 1252 toString: function() { 1253 return 'Aloha.Markup'; 1254 } 1255 1256 } ); 1257 1258 Aloha.Markup = new Aloha.Markup(); 1259 1260 return Aloha.Markup; 1261 1262 } ); 1263