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