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