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