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, 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 323 // LEFT (37), RIGHT (39) keys for block detection 324 if ( event.keyCode === 37 || event.keyCode === 39 ) { 325 if (Aloha.getSelection().getRangeCount()) { 326 rangeObject = Aloha.getSelection().getRangeAt( 0 ); 327 328 if ( this.processCursor( rangeObject, event.keyCode ) ) { 329 cleanupPlaceholders( Aloha.Selection.rangeObject ); 330 return true; 331 } 332 } 333 334 return false; 335 } 336 337 // BACKSPACE 338 if ( event.keyCode === 8 ) { 339 event.preventDefault(); // prevent history.back() even on exception 340 Aloha.execCommand( 'delete', false ); 341 return false; 342 } 343 344 // DELETE 345 if ( event.keyCode === 46 ) { 346 Aloha.execCommand( 'forwarddelete', false ); 347 return false; 348 } 349 350 // ENTER 351 if ( event.keyCode === 13 ) { 352 if ( event.shiftKey ) { 353 Aloha.execCommand( 'insertlinebreak', false ); 354 return false; 355 } else { 356 Aloha.execCommand( 'insertparagraph', false ); 357 return false; 358 } 359 } 360 361 return true; 362 }, 363 364 /** 365 * Processing of cursor keys. 366 * Detect blocks (elements with contenteditable=false) and will select them 367 * (normally the cursor would simply jump right past them). 368 * 369 * For each block that is selected, an 'aloha-block-selected' event will be 370 * triggered. 371 * 372 * @param {RangyRange} range A range object for the current selection. 373 * @param {number} keyCode Code of the currently pressed key. 374 * @return {boolean} False if a block was found, to prevent further events, 375 * true otherwise. 376 */ 377 processCursor: function( range, keyCode ) { 378 if ( !range.collapsed ) { 379 return true; 380 } 381 382 var node = range.startContainer, selection = Aloha.getSelection(); 383 384 if ( !node ) { 385 return true; 386 } 387 388 var sibling; 389 390 // special handling for moving Cursor around zero-width whitespace in IE7 391 if (jQuery.browser.msie && jQuery.browser.version <= 7 && isTextNode(node)) { 392 if (keyCode == 37) { 393 // moving left -> skip zwsp to the left 394 var offset = range.startOffset; 395 while (offset > 0 && node.data.charAt(offset - 1) === '\u200b') { 396 offset--; 397 } 398 if (offset != range.startOffset) { 399 range.setStart(range.startContainer, offset); 400 range.setEnd(range.startContainer, offset); 401 selection = Aloha.getSelection(); 402 selection.removeAllRanges(); 403 selection.addRange(range); 404 } 405 } else if (keyCode == 39) { 406 // moving right -> skip zwsp to the right 407 var offset = range.startOffset; 408 while (offset < node.data.length && node.data.charAt(offset) === '\u200b') { 409 offset++; 410 } 411 if (offset != range.startOffset) { 412 range.setStart(range.startContainer, offset); 413 range.setEnd(range.startContainer, offset); 414 selection.removeAllRanges(); 415 selection.addRange(range); 416 } 417 } 418 } 419 420 // Versions of Internet Explorer that are older that 9, will 421 // erroneously allow you to enter and edit inside elements which have 422 // their contenteditable attribute set to false... 423 if ( isOldIE && !jQuery(node).contentEditable() ) { 424 var $parentBlock = jQuery( node ).parents( 425 '[contenteditable=false]' ); 426 var isInsideBlock = $parentBlock.length > 0; 427 428 if ( isInsideBlock ) { 429 if ( isBlockInsideEditable( $parentBlock ) ) { 430 sibling = $parentBlock[0]; 431 } else { 432 return true; 433 } 434 } 435 } 436 437 if ( !sibling ) { 438 // True if keyCode denotes LEFT or UP arrow key, otherwise they 439 // keyCode is for RIGHT or DOWN in which this value will be false. 440 var isLeft = (37 === keyCode || 38 === keyCode); 441 var offset = range.startOffset; 442 443 if ( isTextNode( node ) ) { 444 if ( isLeft ) { 445 // FIXME(Petro): Please consider if you have a better idea 446 // of how we can work around this. 447 // 448 // Here is the problem... with Internet Explorer: 449 // ---------------------------------------------- 450 // 451 // Versions of Internet Explorer older than 9, are buggy in 452 // how they `select()', or position a selection from cursor 453 // movements, when the following conditions are true: 454 // 455 // * The range is collapsed. 456 // * startContainer is a contenteditable text node. 457 // * startOffset is 1. 458 // * There is a non-conenteditable element left of the 459 // startContainer. 460 // * You attempt to move left to offset 0 (we consider a 461 // range to be at "frontposition" if it is at offset 0 462 // within its startContainer). 463 // 464 // What happens in IE 7, and IE 8, is that the selection 465 // will jump to the adjacent non-contenteditable 466 // element(s), instead moving to the front of the 467 // container, and the offset will be stuck at 1--even as 468 // the cursor is jumping around the screen! 469 // 470 // Our imperfect work-around is to reckon ourselves to be 471 // at the front of the next node (ie: offset 0 in other 472 // browsers), as soon as we detect that we are at offset 1 473 // in IEv<9. 474 // 475 // Considering the bug, I think this is acceptable because 476 // the user can still position themselve right between the 477 // block (non-contenteditable element) and the first 478 // characater of the text node by clicking there with the 479 // mouse, since this seems to work fine in all IE versions. 480 var isFrontPositionInIE = isOldIE && 1 === offset; 481 482 if ( !isFrontPositionInIE && 483 !isFrontPosition( node, offset ) ) { 484 return true; 485 } 486 487 } else if ( !isEndPosition( node, offset ) ) { 488 return true; 489 } 490 491 } else { 492 node = node.childNodes[ 493 offset === nodeLength( node ) ? offset - 1 : offset ]; 494 } 495 496 sibling = isLeft ? prevVisibleNode( node ) 497 : nextVisibleNode( node ); 498 } 499 500 if ( isBlock( sibling ) ) { 501 jumpBlock( sibling, isLeft ); 502 return false; 503 } 504 505 return true; 506 }, 507 508 /** 509 * method handling shiftEnter 510 * @param Aloha.Selection.SelectionRange of the current selection 511 * @return void 512 */ 513 processShiftEnter: function( rangeObject ) { 514 this.insertHTMLBreak( rangeObject.getSelectionTree(), rangeObject ); 515 }, 516 517 /** 518 * method handling Enter 519 * @param Aloha.Selection.SelectionRange of the current selection 520 * @return void 521 */ 522 processEnter: function( rangeObject ) { 523 if ( rangeObject.splitObject ) { 524 // 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 525 // if ( jQuery.browser.msie 526 // && GENTICS.Utils.Dom 527 // .isListElement( rangeObject.splitObject ) ) { 528 // jQuery( rangeObject.splitObject ).append( 529 // jQuery( document.createTextNode( '' ) ) ); 530 // } 531 this.splitRangeObject( rangeObject ); 532 } else { // if there is no split object, the Editable is the paragraph type itself (e.g. a p or h2) 533 this.insertHTMLBreak( rangeObject.getSelectionTree(), rangeObject ); 534 } 535 }, 536 537 /** 538 * Insert the given html markup at the current selection 539 * @param html html markup to be inserted 540 */ 541 insertHTMLCode: function( html ) { 542 var rangeObject = Aloha.Selection.rangeObject; 543 this.insertHTMLBreak( rangeObject.getSelectionTree(), rangeObject, jQuery( html ) ); 544 }, 545 546 /** 547 * insert an HTML Break <br /> into current selection 548 * @param Aloha.Selection.SelectionRange of the current selection 549 * @return void 550 */ 551 insertHTMLBreak: function( selectionTree, rangeObject, inBetweenMarkup ) { 552 var i, 553 treeLength, 554 el, 555 jqEl, 556 jqElBefore, 557 jqElAfter, 558 tmpObject, 559 offset, 560 checkObj; 561 562 inBetweenMarkup = inBetweenMarkup ? inBetweenMarkup: jQuery( '<br/>' ); 563 564 for ( i = 0, treeLength = selectionTree.length; i < treeLength; ++i ) { 565 el = selectionTree[ i ]; 566 jqEl = el.domobj ? jQuery( el.domobj ) : undefined; 567 568 if ( el.selection !== 'none' ) { // before cursor, leave this part inside the splitObject 569 if ( el.selection == 'collapsed' ) { 570 // collapsed selection found (between nodes) 571 if ( i > 0 ) { 572 // not at the start, so get the element to the left 573 jqElBefore = jQuery( selectionTree[ i - 1 ].domobj ); 574 575 // and insert the break after it 576 jqElBefore.after( inBetweenMarkup ); 577 578 } else { 579 // at the start, so get the element to the right 580 jqElAfter = jQuery( selectionTree[1].domobj ); 581 582 // and insert the break before it 583 jqElAfter.before( inBetweenMarkup ); 584 } 585 586 // now set the range 587 rangeObject.startContainer = rangeObject.endContainer = inBetweenMarkup[0].parentNode; 588 rangeObject.startOffset = rangeObject.endOffset = GENTICS.Utils.Dom.getIndexInParent( inBetweenMarkup[0] ) + 1; 589 rangeObject.correctRange(); 590 591 } else if ( el.domobj && el.domobj.nodeType === 3 ) { // textNode 592 // when the textnode is immediately followed by a blocklevel element (like p, h1, ...) we need to add an additional br in between 593 if ( el.domobj.nextSibling 594 && el.domobj.nextSibling.nodeType == 1 595 && Aloha.Selection.replacingElements[ 596 el.domobj.nextSibling.nodeName.toLowerCase() 597 ] ) { 598 // TODO check whether this depends on the browser 599 jqEl.after( '<br/>' ); 600 } 601 602 if ( this.needEndingBreak() ) { 603 // when the textnode is the last inside a blocklevel element 604 // (like p, h1, ...) we need to add an additional br as very 605 // last object in the blocklevel element 606 checkObj = el.domobj; 607 608 while ( checkObj ) { 609 if ( checkObj.nextSibling ) { 610 checkObj = false; 611 } else { 612 // go to the parent 613 checkObj = checkObj.parentNode; 614 615 // found a blocklevel or list element, we are done 616 if ( GENTICS.Utils.Dom.isBlockLevelElement( checkObj ) 617 || GENTICS.Utils.Dom.isListElement( checkObj ) ) { 618 break; 619 } 620 621 // reached the limit object, we are done 622 if ( checkObj === rangeObject.limitObject ) { 623 checkObj = false; 624 } 625 } 626 } 627 628 // when we found a blocklevel element, insert a break at the 629 // end. Mark the break so that it is cleaned when the 630 // content is fetched. 631 if ( checkObj ) { 632 jQuery( checkObj ).append( '<br class="aloha-cleanme" />' ); 633 } 634 } 635 636 // insert the break 637 jqEl.between( inBetweenMarkup, el.startOffset ); 638 639 // correct the range 640 // count the number of previous siblings 641 offset = 0; 642 tmpObject = inBetweenMarkup[0]; 643 while ( tmpObject ) { 644 tmpObject = tmpObject.previousSibling; 645 ++offset; 646 } 647 648 649 rangeObject.startContainer = inBetweenMarkup[0].parentNode; 650 rangeObject.endContainer = inBetweenMarkup[0].parentNode; 651 rangeObject.startOffset = offset; 652 rangeObject.endOffset = offset; 653 rangeObject.correctRange(); 654 655 } else if ( el.domobj && el.domobj.nodeType === 1 ) { // other node, normally a break 656 if ( jqEl.parent().find( 'br.aloha-ephemera' ).length === 0 ) { 657 // but before putting it, remove all: 658 jQuery( rangeObject.limitObject ).find( 'br.aloha-ephemera' ).remove(); 659 660 // now put it: 661 jQuery( rangeObject.commonAncestorContainer ) 662 .append( this.getFillUpElement( rangeObject.splitObject ) ); 663 } 664 665 jqEl.after( inBetweenMarkup ); 666 667 // now set the selection. Since we just added one break do the currect el 668 // the new position must be el's position + 1. el's position is the index 669 // of the el in the selection tree, which is i. then we must add 670 // another +1 because we want to be AFTER the object, not before. therefor +2 671 rangeObject.startContainer = rangeObject.commonAncestorContainer; 672 rangeObject.endContainer = rangeObject.startContainer; 673 rangeObject.startOffset = i + 2; 674 rangeObject.endOffset = i + 2; 675 rangeObject.update(); 676 } 677 } 678 } 679 rangeObject.select(); 680 }, 681 682 /** 683 * Check whether blocklevel elements need breaks at the end to visibly render a newline 684 * @return true if an ending break is necessary, false if not 685 */ 686 needEndingBreak: function() { 687 // currently, all browser except IE need ending breaks 688 return !jQuery.browser.msie; 689 }, 690 691 /** 692 * Get the currently selected text or false if nothing is selected (or the selection is collapsed) 693 * @return selected text 694 */ 695 696 getSelectedText: function() { 697 var rangeObject = Aloha.Selection.rangeObject; 698 699 if ( rangeObject.isCollapsed() ) { 700 return false; 701 } 702 703 return this.getFromSelectionTree( rangeObject.getSelectionTree(), true ); 704 }, 705 706 /** 707 * Recursive function to get the selected text from the selection tree starting at the given level 708 * @param selectionTree array of selectiontree elements 709 * @param astext true when the contents shall be fetched as text, false for getting as html markup 710 * @return selected text from that level (incluiding all sublevels) 711 */ 712 getFromSelectionTree: function( selectionTree, astext ) { 713 var text = '', i, treeLength, el, clone; 714 for ( i = 0, treeLength = selectionTree.length; i < treeLength; i++ ) { 715 el = selectionTree[i]; 716 if ( el.selection == 'partial' ) { 717 if ( el.domobj.nodeType === 3 ) { 718 // partial text node selected, get the selected part 719 text += el.domobj.data.substring( el.startOffset, el.endOffset ); 720 } else if ( el.domobj.nodeType === 1 && el.children ) { 721 // partial element node selected, do the recursion into the children 722 if ( astext ) { 723 text += this.getFromSelectionTree( el.children, astext ); 724 } else { 725 // when the html shall be fetched, we create a clone of 726 // the element and remove all the children 727 clone = jQuery( el.domobj.outerHTML ).empty(); 728 // then we do the recursion and add the selection into the clone 729 clone.html( this.getFromSelectionTree( el.children, astext ) ); 730 // finally we get the html of the clone 731 text += clone.outerHTML(); 732 } 733 } 734 } else if ( el.selection == 'full' ) { 735 if ( el.domobj.nodeType === 3 ) { 736 // full text node selected, get the text 737 text += jQuery( el.domobj ).text(); 738 } else if ( el.domobj.nodeType === 1 && el.children ) { 739 // full element node selected, get the html of the node and all children 740 text += astext ? jQuery( el.domobj ).text() : jQuery( el.domobj ).outerHTML(); 741 } 742 } 743 } 744 745 return text; 746 }, 747 748 /** 749 * Get the currently selected markup or false if nothing is selected (or the selection is collapsed) 750 * @return {?String} 751 */ 752 getSelectedMarkup: function() { 753 var rangeObject = Aloha.Selection.rangeObject; 754 return rangeObject.isCollapsed() ? null 755 : this.getFromSelectionTree( rangeObject.getSelectionTree(), false ); 756 }, 757 758 /** 759 * Remove the currently selected markup 760 */ 761 removeSelectedMarkup: function() { 762 var rangeObject = Aloha.Selection.rangeObject, newRange; 763 764 if ( rangeObject.isCollapsed() ) { 765 return; 766 } 767 768 newRange = new Aloha.Selection.SelectionRange(); 769 // remove the selection 770 this.removeFromSelectionTree( rangeObject.getSelectionTree(), newRange ); 771 772 // do a cleanup now (starting with the commonancestorcontainer) 773 newRange.update(); 774 GENTICS.Utils.Dom.doCleanup( { 'merge' : true, 'removeempty' : true }, Aloha.Selection.rangeObject ); 775 Aloha.Selection.rangeObject = newRange; 776 777 // need to set the collapsed selection now 778 newRange.correctRange(); 779 newRange.update(); 780 newRange.select(); 781 Aloha.Selection.updateSelection(); 782 }, 783 784 /** 785 * Recursively remove the selected items, starting with the given level in the selectiontree 786 * @param selectionTree current level of the selectiontree 787 * @param newRange new collapsed range to be set after the removal 788 */ 789 removeFromSelectionTree: function( selectionTree, newRange ) { 790 // remember the first found partially selected element node (in case we need 791 // to merge it with the last found partially selected element node) 792 var firstPartialElement, 793 newdata, 794 i, 795 el, 796 adjacentTextNode, 797 treeLength; 798 799 // iterate through the selection tree 800 for ( i = 0, treeLength = selectionTree.length; i < treeLength; i++ ) { 801 el = selectionTree[ i ]; 802 803 // check the type of selection 804 if ( el.selection == 'partial' ) { 805 if ( el.domobj.nodeType === 3 ) { 806 // partial text node selected, so remove the selected portion 807 newdata = ''; 808 if ( el.startOffset > 0 ) { 809 newdata += el.domobj.data.substring( 0, el.startOffset ); 810 } 811 if ( el.endOffset < el.domobj.data.length ) { 812 newdata += el.domobj.data.substring( el.endOffset, el.domobj.data.length ); 813 } 814 el.domobj.data = newdata; 815 816 // eventually set the new range (if not done before) 817 if ( !newRange.startContainer ) { 818 newRange.startContainer = newRange.endContainer = el.domobj; 819 newRange.startOffset = newRange.endOffset = el.startOffset; 820 } 821 } else if ( el.domobj.nodeType === 1 && el.children ) { 822 // partial element node selected, so do the recursion into the children 823 this.removeFromSelectionTree( el.children, newRange ); 824 825 if ( firstPartialElement ) { 826 // when the first parially selected element is the same type 827 // of element, we need to merge them 828 if ( firstPartialElement.nodeName == el.domobj.nodeName ) { 829 // merge the nodes 830 jQuery( firstPartialElement ).append( jQuery( el.domobj ).contents() ); 831 832 // and remove the latter one 833 jQuery( el.domobj ).remove(); 834 } 835 836 } else { 837 // remember this element as first partially selected element 838 firstPartialElement = el.domobj; 839 } 840 } 841 842 } else if ( el.selection == 'full' ) { 843 // eventually set the new range (if not done before) 844 if ( !newRange.startContainer ) { 845 adjacentTextNode = GENTICS.Utils.Dom.searchAdjacentTextNode( 846 el.domobj.parentNode, 847 GENTICS.Utils.Dom.getIndexInParent( el.domobj ) + 1, 848 false, 849 { 'blocklevel' : false } 850 ); 851 852 if ( adjacentTextNode ) { 853 newRange.startContainer = newRange.endContainer = adjacentTextNode; 854 newRange.startOffset = newRange.endOffset = 0; 855 } else { 856 newRange.startContainer = newRange.endContainer = el.domobj.parentNode; 857 newRange.startOffset = newRange.endOffset = GENTICS.Utils.Dom.getIndexInParent( el.domobj ) + 1; 858 } 859 } 860 861 // full node selected, so just remove it (will also remove all children) 862 jQuery( el.domobj ).remove(); 863 } 864 } 865 }, 866 867 /** 868 * split passed rangeObject without or with optional markup 869 * @param Aloha.Selection.SelectionRange of the current selection 870 * @param markup object (jQuery) to insert in between the split elements 871 * @return void 872 */ 873 splitRangeObject: function( rangeObject, markup ) { 874 // UAAAA: first check where the markup can be inserted... *grrrrr*, then decide where to split 875 // object which is split up 876 var 877 splitObject = jQuery( rangeObject.splitObject ), 878 selectionTree, insertAfterObject, followUpContainer; 879 880 // update the commonAncestor with the splitObject (so that the selectionTree is correct) 881 rangeObject.update( rangeObject.splitObject ); // set the splitObject as new commonAncestorContainer and update the selectionTree 882 883 // calculate the selection tree. NOTE: it is necessary to do this before 884 // getting the followupcontainer, since getting the selection tree might 885 // possibly merge text nodes, which would lead to differences in the followupcontainer 886 selectionTree = rangeObject.getSelectionTree(); 887 888 // object to be inserted after the splitObject 889 followUpContainer = this.getSplitFollowUpContainer( rangeObject ); 890 891 // now split up the splitObject into itself AND the followUpContainer 892 this.splitRangeObjectHelper( selectionTree, rangeObject, followUpContainer ); // split the current object into itself and the followUpContainer 893 894 // check whether the followupcontainer is still marked for removal 895 if ( followUpContainer.hasClass( 'preparedForRemoval' ) ) { 896 // TODO shall we just remove the class or shall we not use the followupcontainer? 897 followUpContainer.removeClass( 'preparedForRemoval' ); 898 } 899 900 // now let's find the place, where the followUp is inserted afterwards. normally that's the splitObject itself, but in 901 // some cases it might be their parent (e.g. inside a list, a <p> followUp must be inserted outside the list) 902 insertAfterObject = this.getInsertAfterObject( rangeObject, followUpContainer ); 903 904 // now insert the followUpContainer 905 jQuery( followUpContainer ).insertAfter( insertAfterObject ); // attach the followUpContainer right after the insertAfterObject 906 907 // in some cases, we want to remove the "empty" splitObject (e.g. LIs, if enter was hit twice) 908 if ( rangeObject.splitObject.nodeName.toLowerCase() === 'li' && !Aloha.Selection.standardTextLevelSemanticsComparator( rangeObject.splitObject, followUpContainer ) ) { 909 jQuery( rangeObject.splitObject ).remove(); 910 } 911 912 rangeObject.startContainer = null; 913 // first check whether the followUpContainer starts with a <br/> 914 // if so, place the cursor right before the <br/> 915 var followContents = followUpContainer.contents(); 916 if ( followContents.length > 0 917 && followContents.get( 0 ).nodeType == 1 918 && followContents.get( 0 ).nodeName.toLowerCase() === 'br' ) { 919 rangeObject.startContainer = followUpContainer.get( 0 ); 920 } 921 922 if ( !rangeObject.startContainer ) { 923 // find a possible text node in the followUpContainer and set the selection to it 924 // if no textnode is available, set the selection to the followup container itself 925 rangeObject.startContainer = followUpContainer.textNodes( true, true ).first().get( 0 ); 926 } 927 if ( !rangeObject.startContainer ) { // if no text node was found, select the parent object of <br class="aloha-ephemera" /> 928 rangeObject.startContainer = followUpContainer.textNodes( false ).first().parent().get( 0 ); 929 } 930 if ( rangeObject.startContainer ) { 931 // the cursor is always at the beginning of the followUp 932 rangeObject.endContainer = rangeObject.startContainer; 933 rangeObject.startOffset = 0; 934 rangeObject.endOffset = 0; 935 } else { 936 rangeObject.startContainer = rangeObject.endContainer = followUpContainer.parent().get( 0 ); 937 rangeObject.startOffset = rangeObject.endOffset = GENTICS.Utils.Dom.getIndexInParent( followUpContainer.get( 0 ) ); 938 } 939 940 // finally update the range object again 941 rangeObject.update(); 942 943 // now set the selection 944 rangeObject.select(); 945 }, 946 947 /** 948 * method to get the object after which the followUpContainer can be inserted during splitup 949 * this is a helper method, not needed anywhere else 950 * @param rangeObject Aloha.Selection.SelectionRange of the current selection 951 * @param followUpContainer optional jQuery object; if provided the rangeObject will be split and the second part will be insert inside of this object 952 * @return object after which the followUpContainer can be inserted 953 */ 954 getInsertAfterObject: function( rangeObject, followUpContainer ) { 955 var passedSplitObject, i, el; 956 957 for ( i = 0; i < rangeObject.markupEffectiveAtStart.length; i++ ) { 958 el = rangeObject.markupEffectiveAtStart[ i ]; 959 960 // check if we have already passed the splitObject (some other markup might come before) 961 if ( el === rangeObject.splitObject ) { 962 passedSplitObject = true; 963 } 964 965 // if not passed splitObject, skip this markup 966 if ( !passedSplitObject ) { 967 continue; 968 } 969 970 // once we are passed, check if the followUpContainer is allowed to be inserted into the currents el's parent 971 if ( Aloha.Selection.canTag1WrapTag2( jQuery( el ).parent()[0].nodeName, followUpContainer[0].nodeName ) ) { 972 return el; 973 } 974 } 975 976 return false; 977 }, 978 979 /** 980 * @fixme: Someone who knows what this function does, please refactor it. 981 * 1. splitObject arg is not used at all 982 * 2. Would be better to use ternary operation would be better than if else statement 983 * 984 * method to get the html code for a fillUpElement. this is needed for empty paragraphs etc., so that they take up their expected height 985 * @param splitObject split object (dom object) 986 * @return fillUpElement HTML Code 987 */ 988 getFillUpElement: function( splitObject ) { 989 if ( jQuery.browser.msie ) { 990 return false; 991 } else { 992 return jQuery( '<br class="aloha-cleanme"/>' ); 993 } 994 }, 995 996 /** 997 * removes textNodes from passed array, which only contain contentWhiteSpace (e.g. a \n between two tags) 998 * @param domArray array of domObjects 999 * @return void 1000 */ 1001 removeElementContentWhitespaceObj: function( domArray ) { 1002 var correction = 0, 1003 removeLater = [], 1004 i, 1005 el, removeIndex; 1006 1007 for ( i = 0; i < domArray.length; ++i ) { 1008 el = domArray[ i ]; 1009 if ( el.isElementContentWhitespace ) { 1010 removeLater[ removeLater.length ] = i; 1011 } 1012 } 1013 1014 for ( i = 0; i < removeLater.length; ++i ) { 1015 removeIndex = removeLater[ i ]; 1016 domArray.splice( removeIndex - correction, 1 ); 1017 ++correction; 1018 } 1019 }, 1020 1021 /** 1022 * recursive method to parallelly walk through two dom subtrees, leave elements before startContainer in first subtree and move rest to other 1023 * @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 1024 * @param rangeObject Aloha.Selection.SelectionRange of the current selection 1025 * @param followUpContainer optional jQuery object; if provided the rangeObject will be split and the second part will be insert inside of this object 1026 * @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 1027 * @return void 1028 */ 1029 splitRangeObjectHelper: function( selectionTree, rangeObject, 1030 followUpContainer, inBetweenMarkup ) { 1031 if ( !followUpContainer ) { 1032 Aloha.Log.warn( this, 'no followUpContainer, no inBetweenMarkup, nothing to do...' ); 1033 } 1034 1035 var fillUpElement = this.getFillUpElement( rangeObject.splitObject ), 1036 splitObject = jQuery( rangeObject.splitObject ), 1037 startMoving = false, 1038 el, 1039 i, 1040 completeText, 1041 jqObj, 1042 mirrorLevel, 1043 parent, 1044 treeLength; 1045 1046 if ( selectionTree.length > 0 ) { 1047 mirrorLevel = followUpContainer.contents(); 1048 1049 // if length of mirrorLevel and selectionTree are not equal, the mirrorLevel must be corrected. this happens, when the mirrorLevel contains whitespace textNodes 1050 if ( mirrorLevel.length !== selectionTree.length ) { 1051 this.removeElementContentWhitespaceObj( mirrorLevel ); 1052 } 1053 1054 for ( i = 0, treeLength = selectionTree.length; i < treeLength; ++i ) { 1055 el = selectionTree[ i ]; 1056 1057 // remove all objects in the mirrorLevel, which are BEFORE the cursor 1058 // OR if the cursor is at the last position of the last Textnode (causing an empty followUpContainer to be appended) 1059 if ( ( el.selection === 'none' && startMoving === false ) || 1060 ( el.domobj && el.domobj.nodeType === 3 1061 && el === selectionTree[ ( selectionTree.length - 1 ) ] 1062 && el.startOffset === el.domobj.data.length ) ) { 1063 // iteration is before cursor, leave this part inside the splitObject, remove from followUpContainer 1064 // however if the object to remove is the last existing textNode within the followUpContainer, insert a BR instead 1065 // otherwise the followUpContainer is invalid and takes up no vertical space 1066 1067 if ( followUpContainer.textNodes().length > 1 1068 || ( el.domobj.nodeType === 1 && el.children.length === 0 ) ) { 1069 // note: the second part of the if (el.domobj.nodeType === 1 && el.children.length === 0) covers a very special condition, 1070 // where an empty tag is located right before the cursor when pressing enter. In this case the empty tag would not be 1071 // removed correctly otherwise 1072 mirrorLevel.eq( i ).remove(); 1073 1074 } else if ( GENTICS.Utils.Dom.isSplitObject( followUpContainer[0] ) ) { 1075 if ( fillUpElement ) { 1076 followUpContainer.html( fillUpElement ); // for your zoological german knowhow: ephemera = Eintagsfliege 1077 } else { 1078 followUpContainer.empty(); 1079 } 1080 1081 } else { 1082 followUpContainer.empty(); 1083 followUpContainer.addClass( 'preparedForRemoval' ); 1084 } 1085 1086 continue; 1087 1088 } else { 1089 // split objects, which are AT the cursor Position or directly above 1090 if ( el.selection !== 'none' ) { // before cursor, leave this part inside the splitObject 1091 // TODO better check for selection == 'partial' here? 1092 if ( el.domobj && el.domobj.nodeType === 3 && el.startOffset !== undefined ) { 1093 completeText = el.domobj.data; 1094 if ( el.startOffset > 0 ) {// first check, if there will be some text left in the splitObject 1095 el.domobj.data = completeText.substr( 0, el.startOffset ); 1096 } 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 1097 jQuery( el.domobj ).remove(); 1098 1099 } else { // if the "empty" textnode is the last node left in the splitObject, replace it with a ephemera break 1100 // if the parent is a blocklevel element, we insert the fillup element 1101 parent = jQuery( el.domobj ).parent(); 1102 if ( GENTICS.Utils.Dom.isSplitObject( parent[0] ) ) { 1103 if ( fillUpElement ) { 1104 parent.html( fillUpElement ); 1105 } else { 1106 parent.empty(); 1107 } 1108 1109 } else { 1110 // if the parent is no blocklevel element and would be empty now, we completely remove it 1111 parent.remove(); 1112 } 1113 } 1114 if ( completeText.length - el.startOffset > 0 ) { 1115 // 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 1116 mirrorLevel[i].data = completeText.substr( el.startOffset, completeText.length ); 1117 } else if ( mirrorLevel.length > 1 ) { 1118 // if not, check if the followUpContainer contains more than one node, because if yes, the "empty" textnode can be removed 1119 mirrorLevel.eq( ( i ) ).remove(); 1120 } else if ( GENTICS.Utils.Dom.isBlockLevelElement( followUpContainer[0] ) ) { 1121 // if the "empty" textnode is the last node left in the followUpContainer (which is a blocklevel element), replace it with a ephemera break 1122 if ( fillUpElement ) { 1123 followUpContainer.html( fillUpElement ); 1124 } else { 1125 followUpContainer.empty(); 1126 } 1127 1128 } else { 1129 // if the "empty" textnode is the last node left in a non-blocklevel element, mark it for removal 1130 followUpContainer.empty(); 1131 followUpContainer.addClass( 'preparedForRemoval' ); 1132 } 1133 } 1134 1135 startMoving = true; 1136 1137 if ( el.children.length > 0 ) { 1138 this.splitRangeObjectHelper( el.children, rangeObject, mirrorLevel.eq( i ), inBetweenMarkup ); 1139 } 1140 1141 } else { 1142 // remove all objects in the origin, which are AFTER the cursor 1143 if ( el.selection === 'none' && startMoving === true ) { 1144 // iteration is after cursor, remove from splitObject and leave this part inside the followUpContainer 1145 jqObj = jQuery( el.domobj ).remove(); 1146 } 1147 } 1148 } 1149 } 1150 } else { 1151 Aloha.Log.error( this, 'can not split splitObject due to an empty selection tree' ); 1152 } 1153 1154 // and finally cleanup: remove all fillUps > 1 1155 splitObject.find( 'br.aloha-ephemera:gt(0)' ).remove(); // remove all elements greater than (gt) 0, that also means: leave one 1156 followUpContainer.find( 'br.aloha-ephemera:gt(0)' ).remove(); // remove all elements greater than (gt) 0, that also means: leave one 1157 1158 // remove objects prepared for removal 1159 splitObject.find( '.preparedForRemoval' ).remove(); 1160 followUpContainer.find( '.preparedForRemoval' ).remove(); 1161 1162 // if splitObject / followUp are empty, place a fillUp inside 1163 if ( splitObject.contents().length === 0 1164 && GENTICS.Utils.Dom.isSplitObject( splitObject[0] ) 1165 && fillUpElement ) { 1166 splitObject.html( fillUpElement ); 1167 } 1168 1169 if ( followUpContainer.contents().length === 0 1170 && GENTICS.Utils.Dom.isSplitObject( followUpContainer[0] ) 1171 && fillUpElement ) { 1172 followUpContainer.html( fillUpElement ); 1173 } 1174 }, 1175 1176 /** 1177 * returns a jQuery object fitting the passed splitObject as follow up object 1178 * examples, 1179 * - when passed a p it will return an empty p (clone of the passed p) 1180 * - 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) 1181 * @param rangeObject Aloha.RangeObject 1182 * @return void 1183 */ 1184 getSplitFollowUpContainer: function( rangeObject ) { 1185 var tagName = rangeObject.splitObject.nodeName.toLowerCase(), 1186 returnObj, 1187 inside, 1188 lastObj; 1189 1190 switch ( tagName ) { 1191 case 'h1': 1192 case 'h2': 1193 case 'h3': 1194 case 'h4': 1195 case 'h5': 1196 case 'h6': 1197 // get the last textnode in the splitobject, but don't consider aloha-cleanme elements 1198 lastObj = jQuery( rangeObject.splitObject ).textNodes( ':not(.aloha-cleanme)' ).last()[0]; 1199 // special case: when enter is hit at the end of a heading, the followUp should be a <p> 1200 if ( lastObj && rangeObject.startContainer === lastObj 1201 && rangeObject.startOffset === lastObj.length ) { 1202 returnObj = jQuery( '<p></p>' ); 1203 inside = jQuery( rangeObject.splitObject.outerHTML ).contents(); 1204 returnObj.append( inside ); 1205 return returnObj; 1206 } 1207 break; 1208 1209 case 'li': 1210 // TODO check whether the li is the last one 1211 // special case: if enter is hit twice inside a list, the next item should be a <p> (and inserted outside the list) 1212 if ( rangeObject.startContainer.nodeName.toLowerCase() === 'br' 1213 && jQuery( rangeObject.startContainer ).hasClass( 'aloha-ephemera' ) ) { 1214 returnObj = jQuery( '<p></p>' ); 1215 inside = jQuery( rangeObject.splitObject.outerHTML ).contents(); 1216 1217 returnObj.append( inside ); 1218 return returnObj; 1219 } 1220 // when the li is the last one and empty, we also just return a <p> 1221 if ( !rangeObject.splitObject.nextSibling 1222 && jQuery.trim( jQuery( rangeObject.splitObject ).text() ).length === 0 ) { 1223 returnObj = jQuery( '<p></p>' ); 1224 return returnObj; 1225 } 1226 } 1227 1228 return jQuery( rangeObject.splitObject.outerHTML ); 1229 }, 1230 1231 /** 1232 * Transform the given domobj into an object with the given new nodeName. 1233 * Preserves the content and all attributes. If a range object is given, also the range will be preserved 1234 * @param domobj dom object to transform 1235 * @param nodeName new node name 1236 * @param range range object 1237 * @api 1238 * @return new object as jQuery object 1239 */ 1240 transformDomObject: function( domobj, nodeName, range ) { 1241 // first create the new element 1242 var jqOldObj = jQuery( domobj ), 1243 jqNewObj = jQuery( '<' + nodeName + '></' + nodeName + '>' ), 1244 i; 1245 1246 // TODO what about events? 1247 1248 // copy attributes 1249 if ( jqOldObj[0].attributes ) { 1250 for ( i = 0; i < jqOldObj[0].attributes.length; ++i ) { 1251 if (jqOldObj[0].attributes[i].specified) { 1252 jqNewObj.attr( 1253 jqOldObj[0].attributes[ i ].nodeName, 1254 jqOldObj[0].attributes[ i ].nodeValue 1255 ); 1256 } 1257 } 1258 } 1259 1260 // copy inline CSS 1261 if ( jqOldObj[0].style && jqOldObj[0].style.cssText ) { 1262 jqNewObj[0].style.cssText = jqOldObj[0].style.cssText; 1263 } 1264 1265 // now move the contents of the old dom object into the new dom object 1266 jqOldObj.contents().appendTo( jqNewObj ); 1267 1268 // finally replace the old object with the new one 1269 jqOldObj.replaceWith( jqNewObj ); 1270 1271 // preserve the range 1272 if ( range ) { 1273 if ( range.startContainer == domobj ) { 1274 range.startContainer = jqNewObj.get( 0 ); 1275 } 1276 1277 if ( range.endContainer == domobj ) { 1278 range.endContainer = jqNewObj.get( 0 ); 1279 } 1280 } 1281 1282 return jqNewObj; 1283 }, 1284 1285 /** 1286 * String representation 1287 * @return {String} 1288 */ 1289 toString: function() { 1290 return 'Aloha.Markup'; 1291 } 1292 1293 } ); 1294 1295 Aloha.Markup = new Aloha.Markup(); 1296 1297 return Aloha.Markup; 1298 1299 } ); 1300