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