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 ], function ( 35 Aloha, 36 Class, 37 jQuery, 38 shims, 39 console, 40 BlockJump 41 ) { 42 "use strict"; 43 44 var GENTICS = window.GENTICS; 45 46 var isOldIE = !!(jQuery.browser.msie && 9 > parseInt(jQuery.browser.version, 10)); 47 48 function isBR(node) { 49 return 'BR' === node.nodeName; 50 } 51 52 function isBlock(node) { 53 return 'false' === jQuery(node).attr('contenteditable'); 54 } 55 56 function isTextNode(node) { 57 return node && 3 === node.nodeType; // Node.TEXT_NODE 58 } 59 60 function nodeLength(node) { 61 return !node ? 0 : (isTextNode(node) ? node.length : node.childNodes.length); 62 } 63 64 /** 65 * Determines whether the given text node is visible to the the user, 66 * based on our understanding that browsers will not display 67 * superfluous white spaces. 68 * 69 * @param {HTMLEmenent} node The text node to be checked. 70 */ 71 function isVisibleTextNode(node) { 72 return 0 < node.data.replace(/\s+/g, '').length; 73 } 74 75 function nextVisibleNode(node) { 76 if (!node) { 77 return null; 78 } 79 80 if (node.nextSibling) { 81 // Skip over nodes that the user cannot see ... 82 if (isTextNode(node.nextSibling) && !isVisibleTextNode(node.nextSibling)) { 83 return nextVisibleNode(node.nextSibling); 84 } 85 86 // Skip over propping <br>s ... 87 if (isBR(node.nextSibling) && node.nextSibling === node.parentNode.lastChild) { 88 return nextVisibleNode(node.nextSibling); 89 } 90 91 // Skip over empty editable elements ... 92 if ('' === node.nextSibling.innerHTML && !isBlock(node.nextSibling)) { 93 return nextVisibleNode(node.nextSibling); 94 } 95 96 return node.nextSibling; 97 } 98 99 if (node.parentNode) { 100 return nextVisibleNode(node.parentNode); 101 } 102 103 return null; 104 } 105 106 function prevVisibleNode(node) { 107 if (!node) { 108 return null; 109 } 110 111 if (node.previousSibling) { 112 // Skip over nodes that the user cannot see... 113 if (isTextNode(node.previousSibling) && !isVisibleTextNode(node.previousSibling)) { 114 return prevVisibleNode(node.previousSibling); 115 } 116 117 // Skip over empty editable elements ... 118 if ('' === node.previousSibling.innerHTML && !isBlock(node.previousSibling)) { 119 return prevVisibleNode(node.previouSibling); 120 } 121 122 return node.previousSibling; 123 } 124 125 if (node.parentNode) { 126 return prevVisibleNode(node.parentNode); 127 } 128 129 return null; 130 } 131 132 function isFrontPosition(node, offset) { 133 return (0 === offset) || (offset <= node.data.length - node.data.replace(/^\s+/, '').length); 134 } 135 136 function isBlockInsideEditable($block) { 137 return $block.parent().hasClass('aloha-editable'); 138 } 139 140 function isEndPosition(node, offset) { 141 var length = nodeLength(node); 142 143 if (length === offset) { 144 return true; 145 } 146 147 var isText = isTextNode(node); 148 149 // If within a text node, then ignore superfluous white-spaces, 150 // since they are invisible to the user. 151 if (isText && node.data.replace(/\s+$/, '').length === offset) { 152 return true; 153 } 154 155 if (1 === length && !isText) { 156 return isBR(node.childNodes[0]); 157 } 158 159 return false; 160 } 161 162 function blink(node) { 163 jQuery(node).stop(true).css({ 164 opacity: 0 165 }).fadeIn(0).delay(100).fadeIn(function () { 166 jQuery(node).css({ 167 opacity: 1 168 }); 169 }); 170 171 return node; 172 } 173 174 function nodeContains(node1, node2) { 175 return isOldIE ? (shims.compareDocumentPosition(node1, node2) & 16) : 0 < jQuery(node1).find(node2).length; 176 } 177 178 function isInsidePlaceholder(range) { 179 var start = range.startContainer; 180 var end = range.endContainer; 181 var $placeholder = window.$_alohaPlaceholder; 182 183 return $placeholder.is(start) || $placeholder.is(end) || nodeContains($placeholder[0], start) || nodeContains($placeholder[0], end); 184 } 185 186 function cleanupPlaceholders(range) { 187 if (window.$_alohaPlaceholder && !isInsidePlaceholder(range)) { 188 if (0 === window.$_alohaPlaceholder.html().replace(/^( )*$/, '').length) { 189 window.$_alohaPlaceholder.remove(); 190 } 191 192 window.$_alohaPlaceholder = null; 193 } 194 } 195 196 /** 197 * @TODO(petro): We need to be more intelligent about whether we insert a 198 * block-level placeholder or a phrasing level element. 199 * @TODO(petro): test with <pre> 200 * @TODO: move to block-jump.js 201 */ 202 function jumpBlock(block, isGoingLeft, currentRange) { 203 var range = new GENTICS.Utils.RangeObject(); 204 var sibling = isGoingLeft ? prevVisibleNode(block) : nextVisibleNode(block); 205 206 if (!sibling || isBlock(sibling)) { 207 var $landing = jQuery('<div> </div>'); 208 209 if (isGoingLeft) { 210 jQuery(block).before($landing); 211 } else { 212 jQuery(block).after($landing); 213 } 214 215 range.startContainer = range.endContainer = $landing[0]; 216 range.startOffset = range.endOffset = 0; 217 218 // Clear out any old placeholder first ... 219 cleanupPlaceholders(range); 220 221 window.$_alohaPlaceholder = $landing; 222 } else { 223 224 // Don't jump the block yet if the cursor is moving to the 225 // beginning or end of a text node, or if it is about to leave 226 227 // an element node. Both these cases require a hack in some 228 // browsers. 229 var moveToBoundaryPositionInIE = ( // To the beginning or end of a text node? 230 (currentRange.startContainer.nodeType === 3 231 && currentRange.startContainer === currentRange.endContainer 232 && currentRange.startContainer.nodeValue !== "" 233 && (isGoingLeft ? currentRange.startOffset === 1 : currentRange.endOffset + 1 === currentRange.endContainer.length)) 234 // Leaving an element node? 235 || (currentRange.startContainer.nodeType === 1 236 && (!currentRange.startOffset 237 || (currentRange.startContainer.childNodes[currentRange.startOffset] && currentRange.startContainer.childNodes[currentRange.startOffset].nodeType === 1))) 238 ); 239 240 if (moveToBoundaryPositionInIE) { 241 // The cursor is moving to the beginning or end of a text 242 // node, or is leaving an element node, which requires a 243 // hack in some browsers. 244 var zeroWidthNode = BlockJump.insertZeroWidthTextNodeFix(block, isGoingLeft); 245 range.startContainer = range.endContainer = zeroWidthNode; 246 range.startOffset = range.endOffset = isGoingLeft ? 1 : 0; 247 } else { 248 // The selection is already at the boundary position - jump 249 // the block. 250 range.startContainer = range.endContainer = sibling; 251 range.startOffset = range.endOffset = isGoingLeft ? nodeLength(sibling) : 0; 252 if (!isGoingLeft) { 253 // Just as above, jumping to the first position right of 254 // a block requires a hack in some browsers. Jumping 255 // left seems to be fine. 256 BlockJump.insertZeroWidthTextNodeFix(block, true); 257 } 258 } 259 cleanupPlaceholders(range); 260 } 261 262 range.select(); 263 264 Aloha.trigger('aloha-block-selected', block); 265 Aloha.Selection.preventSelectionChanged(); 266 } 267 268 /** 269 * Markup object 270 */ 271 Aloha.Markup = Class.extend({ 272 273 /** 274 * Key handlers for special key codes 275 */ 276 keyHandlers: {}, 277 278 /** 279 * Add a key handler for the given key code 280 * @param keyCode key code 281 * @param handler handler function 282 */ 283 addKeyHandler: function (keyCode, handler) { 284 if (!this.keyHandlers[keyCode]) { 285 this.keyHandlers[keyCode] = []; 286 } 287 288 this.keyHandlers[keyCode].push(handler); 289 }, 290 291 /** 292 * Removes a key handler for the given key code 293 * @param keyCode key code 294 */ 295 removeKeyHandler: function (keyCode) { 296 if (this.keyHandlers[keyCode]) { 297 this.keyHandlers[keyCode] = null; 298 } 299 }, 300 301 insertBreak: function () { 302 var range = Aloha.Selection.rangeObject, 303 nonWSIndex, 304 nextTextNode, 305 newBreak; 306 307 if (!range.isCollapsed()) { 308 this.removeSelectedMarkup(); 309 } 310 311 newBreak = jQuery('<br/>'); 312 GENTICS.Utils.Dom.insertIntoDOM(newBreak, range, Aloha.activeEditable.obj); 313 314 nextTextNode = GENTICS.Utils.Dom.searchAdjacentTextNode( 315 newBreak.parent().get(0), 316 GENTICS.Utils.Dom.getIndexInParent(newBreak.get(0)) + 1, 317 false 318 ); 319 320 if (nextTextNode) { 321 // trim leading whitespace 322 nonWSIndex = nextTextNode.data.search(/\S/); 323 if (nonWSIndex > 0) { 324 nextTextNode.data = nextTextNode.data.substring(nonWSIndex); 325 } 326 } 327 328 range.startContainer = range.endContainer = newBreak.get(0).parentNode; 329 range.startOffset = range.endOffset = GENTICS.Utils.Dom.getIndexInParent(newBreak.get(0)) + 1; 330 range.correctRange(); 331 range.clearCaches(); 332 range.select(); 333 }, 334 335 /** 336 * first method to handle key strokes 337 * @param event DOM event 338 * @param rangeObject as provided by Aloha.Selection.getRangeObject(); 339 * @return "Aloha.Selection" 340 */ 341 preProcessKeyStrokes: function (event) { 342 if (event.type !== 'keydown') { 343 return false; 344 } 345 346 var rangeObject, 347 handlers, 348 i; 349 350 if (this.keyHandlers[event.keyCode]) { 351 handlers = this.keyHandlers[event.keyCode]; 352 for (i = 0; i < handlers.length; ++i) { 353 if (!handlers[i](event)) { 354 return false; 355 } 356 } 357 } 358 359 // LEFT (37), RIGHT (39) keys for block detection 360 if (event.keyCode === 37 || event.keyCode === 39) { 361 if (Aloha.getSelection().getRangeCount()) { 362 rangeObject = Aloha.getSelection().getRangeAt(0); 363 364 if (this.processCursor(rangeObject, event.keyCode)) { 365 cleanupPlaceholders(Aloha.Selection.rangeObject); 366 return true; 367 } 368 } 369 370 return false; 371 } 372 373 // BACKSPACE 374 if (event.keyCode === 8) { 375 event.preventDefault(); // prevent history.back() even on exception 376 Aloha.execCommand('delete', false); 377 return false; 378 } 379 380 // DELETE 381 if (event.keyCode === 46) { 382 Aloha.execCommand('forwarddelete', false); 383 return false; 384 } 385 386 // ENTER 387 if (event.keyCode === 13) { 388 if (event.shiftKey) { 389 Aloha.execCommand('insertlinebreak', false); 390 return false; 391 } 392 Aloha.execCommand('insertparagraph', false); 393 return false; 394 } 395 396 return true; 397 }, 398 399 /** 400 * Processing of cursor keys. 401 * Detect blocks (elements with contenteditable=false) and will select them 402 * (normally the cursor would simply jump right past them). 403 * 404 * For each block that is selected, an 'aloha-block-selected' event will be 405 * triggered. 406 * 407 * TODO: the above is what should happen. Currently we just skip past blocks. 408 * 409 * @param {RangyRange} range A range object for the current selection. 410 * @param {number} keyCode Code of the currently pressed key. 411 * @return {boolean} False if a block was found, to prevent further events, 412 * true otherwise. 413 * @TODO move to block-jump.js 414 */ 415 processCursor: function (range, keyCode) { 416 if (!range.collapsed) { 417 return true; 418 } 419 420 BlockJump.removeZeroWidthTextNodeFix(); 421 422 var node = range.startContainer, 423 selection = Aloha.getSelection(); 424 425 if (!node) { 426 return true; 427 } 428 429 var sibling, offset; 430 431 // special handling for moving Cursor around zero-width whitespace in IE7 432 if (jQuery.browser.msie && parseInt(jQuery.browser.version, 10) <= 7 && isTextNode(node)) { 433 if (keyCode == 37) { 434 // moving left -> skip zwsp to the left 435 offset = range.startOffset; 436 while (offset > 0 && node.data.charAt(offset - 1) === '\u200b') { 437 offset--; 438 } 439 if (offset != range.startOffset) { 440 range.setStart(range.startContainer, offset); 441 range.setEnd(range.startContainer, offset); 442 selection = Aloha.getSelection(); 443 selection.removeAllRanges(); 444 selection.addRange(range); 445 } 446 } else if (keyCode == 39) { 447 // moving right -> skip zwsp to the right 448 offset = range.startOffset; 449 while (offset < node.data.length && node.data.charAt(offset) === '\u200b') { 450 offset++; 451 } 452 if (offset != range.startOffset) { 453 range.setStart(range.startContainer, offset); 454 range.setEnd(range.startContainer, offset); 455 selection.removeAllRanges(); 456 selection.addRange(range); 457 } 458 } 459 } 460 461 // Versions of Internet Explorer that are older that 9, will 462 // erroneously allow you to enter and edit inside elements which have 463 // their contenteditable attribute set to false... 464 if (isOldIE && !jQuery(node).contentEditable()) { 465 var $parentBlock = jQuery(node).parents('[contenteditable=false]'); 466 var isInsideBlock = $parentBlock.length > 0; 467 468 if (isInsideBlock) { 469 if (isBlockInsideEditable($parentBlock)) { 470 sibling = $parentBlock[0]; 471 } else { 472 return true; 473 } 474 } 475 } 476 477 var isLeft; 478 if (!sibling) { 479 // True if keyCode denotes LEFT or UP arrow key, otherwise they 480 // keyCode is for RIGHT or DOWN in which this value will be false. 481 isLeft = (37 === keyCode || 38 === keyCode); 482 offset = range.startOffset; 483 484 if (isTextNode(node)) { 485 if (isLeft) { 486 var isApproachingFrontPosition = (1 === offset); 487 if (!isApproachingFrontPosition && !isFrontPosition(node, offset)) { 488 return true; 489 } 490 } else if (!isEndPosition(node, offset)) { 491 return true; 492 } 493 494 } else { 495 node = node.childNodes[offset === nodeLength(node) ? offset - 1 : offset]; 496 } 497 498 sibling = isLeft ? prevVisibleNode(node) : nextVisibleNode(node); 499 } 500 501 if (isBlock(sibling)) { 502 jumpBlock(sibling, isLeft, range); 503 return false; 504 } 505 506 return true; 507 }, 508 509 /** 510 * method handling shiftEnter 511 * @param Aloha.Selection.SelectionRange of the current selection 512 * @return void 513 */ 514 processShiftEnter: function (rangeObject) { 515 this.insertHTMLBreak(rangeObject.getSelectionTree(), rangeObject); 516 }, 517 518 /** 519 * method handling Enter 520 * @param Aloha.Selection.SelectionRange of the current selection 521 * @return void 522 */ 523 processEnter: function (rangeObject) { 524 if (rangeObject.splitObject) { 525 // 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 526 // if ( jQuery.browser.msie 527 // && GENTICS.Utils.Dom 528 // .isListElement( rangeObject.splitObject ) ) { 529 // jQuery( rangeObject.splitObject ).append( 530 // jQuery( document.createTextNode( '' ) ) ); 531 // } 532 this.splitRangeObject(rangeObject); 533 } else { // if there is no split object, the Editable is the paragraph type itself (e.g. a p or h2) 534 this.insertHTMLBreak(rangeObject.getSelectionTree(), rangeObject); 535 } 536 }, 537 538 /** 539 * Insert the given html markup at the current selection 540 * @param html html markup to be inserted 541 */ 542 insertHTMLCode: function (html) { 543 var rangeObject = Aloha.Selection.rangeObject; 544 this.insertHTMLBreak(rangeObject.getSelectionTree(), rangeObject, jQuery(html)); 545 }, 546 547 /** 548 * insert an HTML Break <br /> into current selection 549 * @param Aloha.Selection.SelectionRange of the current selection 550 * @return void 551 */ 552 insertHTMLBreak: function (selectionTree, rangeObject, inBetweenMarkup) { 553 var i, 554 treeLength, 555 el, 556 jqEl, 557 jqElBefore, 558 jqElAfter, 559 tmpObject, 560 offset, 561 checkObj; 562 563 inBetweenMarkup = inBetweenMarkup || jQuery('<br/>'); 564 565 for (i = 0, treeLength = selectionTree.length; i < treeLength; ++i) { 566 el = selectionTree[i]; 567 jqEl = el.domobj ? jQuery(el.domobj) : undefined; 568 569 if (el.selection !== 'none') { // before cursor, leave this part inside the splitObject 570 if (el.selection == 'collapsed') { 571 // collapsed selection found (between nodes) 572 if (i > 0) { 573 // not at the start, so get the element to the left 574 jqElBefore = jQuery(selectionTree[i - 1].domobj); 575 576 // and insert the break after it 577 jqElBefore.after(inBetweenMarkup); 578 579 } else { 580 // at the start, so get the element to the right 581 jqElAfter = jQuery(selectionTree[1].domobj); 582 583 // and insert the break before it 584 jqElAfter.before(inBetweenMarkup); 585 } 586 587 // now set the range 588 rangeObject.startContainer = rangeObject.endContainer = inBetweenMarkup[0].parentNode; 589 rangeObject.startOffset = rangeObject.endOffset = GENTICS.Utils.Dom.getIndexInParent(inBetweenMarkup[0]) + 1; 590 rangeObject.correctRange(); 591 592 } else if (el.domobj && el.domobj.nodeType === 3) { // textNode 593 // when the textnode is immediately followed by a blocklevel element (like p, h1, ...) we need to add an additional br in between 594 if (el.domobj.nextSibling && el.domobj.nextSibling.nodeType == 1 && Aloha.Selection.replacingElements[el.domobj.nextSibling.nodeName.toLowerCase()]) { 595 // TODO check whether this depends on the browser 596 jqEl.after('<br/>'); 597 } 598 599 if (this.needEndingBreak()) { 600 // when the textnode is the last inside a blocklevel element 601 // (like p, h1, ...) we need to add an additional br as very 602 // last object in the blocklevel element 603 checkObj = el.domobj; 604 605 while (checkObj) { 606 if (checkObj.nextSibling) { 607 checkObj = false; 608 } else { 609 // go to the parent 610 checkObj = checkObj.parentNode; 611 612 // found a blocklevel or list element, we are done 613 if (GENTICS.Utils.Dom.isBlockLevelElement(checkObj) || GENTICS.Utils.Dom.isListElement(checkObj)) { 614 break; 615 } 616 617 // reached the limit object, we are done 618 if (checkObj === rangeObject.limitObject) { 619 checkObj = false; 620 } 621 } 622 } 623 624 // when we found a blocklevel element, insert a break at the 625 // end. Mark the break so that it is cleaned when the 626 // content is fetched. 627 if (checkObj) { 628 jQuery(checkObj).append('<br class="aloha-cleanme" />'); 629 } 630 } 631 632 // insert the break 633 jqEl.between(inBetweenMarkup, el.startOffset); 634 635 // correct the range 636 // count the number of previous siblings 637 offset = 0; 638 tmpObject = inBetweenMarkup[0]; 639 640 while (tmpObject) { 641 tmpObject = tmpObject.previousSibling; 642 ++offset; 643 } 644 645 rangeObject.startContainer = inBetweenMarkup[0].parentNode; 646 rangeObject.endContainer = inBetweenMarkup[0].parentNode; 647 rangeObject.startOffset = offset; 648 rangeObject.endOffset = offset; 649 650 rangeObject.correctRange(); 651 652 } else if (el.domobj && el.domobj.nodeType === 1) { // other node, normally a break 653 if (jqEl.parent().find('br.aloha-ephemera').length === 0) { 654 // but before putting it, remove all: 655 jQuery(rangeObject.limitObject).find('br.aloha-ephemera').remove(); 656 657 // now put it: 658 jQuery(rangeObject.commonAncestorContainer).append(this.getFillUpElement(rangeObject.splitObject)); 659 } 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 * @return {?String} 747 */ 748 getSelectedMarkup: function () { 749 var rangeObject = Aloha.Selection.rangeObject; 750 return rangeObject.isCollapsed() ? null : this.getFromSelectionTree(rangeObject.getSelectionTree(), false); 751 }, 752 753 /** 754 * Remove the currently selected markup 755 */ 756 removeSelectedMarkup: function () { 757 var rangeObject = Aloha.Selection.rangeObject, 758 newRange; 759 760 if (rangeObject.isCollapsed()) { 761 return; 762 } 763 764 newRange = new Aloha.Selection.SelectionRange(); 765 // remove the selection 766 this.removeFromSelectionTree(rangeObject.getSelectionTree(), newRange); 767 768 // do a cleanup now (starting with the commonancestorcontainer) 769 newRange.update(); 770 GENTICS.Utils.Dom.doCleanup({ 771 'merge': true, 772 'removeempty': true 773 }, Aloha.Selection.rangeObject); 774 Aloha.Selection.rangeObject = newRange; 775 776 // need to set the collapsed selection now 777 newRange.correctRange(); 778 newRange.update(); 779 newRange.select(); 780 Aloha.Selection.updateSelection(); 781 }, 782 783 /** 784 * Recursively remove the selected items, starting with the given level in the selectiontree 785 * @param selectionTree current level of the selectiontree 786 * @param newRange new collapsed range to be set after the removal 787 */ 788 removeFromSelectionTree: function (selectionTree, newRange) { 789 // remember the first found partially selected element node (in case we need 790 // to merge it with the last found partially selected element node) 791 var firstPartialElement, newdata, i, el, adjacentTextNode, treeLength; 792 793 // iterate through the selection tree 794 for (i = 0, treeLength = selectionTree.length; i < treeLength; i++) { 795 el = selectionTree[i]; 796 797 // check the type of selection 798 if (el.selection == 'partial') { 799 if (el.domobj.nodeType === 3) { 800 // partial text node selected, so remove the selected portion 801 newdata = ''; 802 if (el.startOffset > 0) { 803 newdata += el.domobj.data.substring(0, el.startOffset); 804 } 805 if (el.endOffset < el.domobj.data.length) { 806 newdata += el.domobj.data.substring(el.endOffset, el.domobj.data.length); 807 } 808 el.domobj.data = newdata; 809 810 // eventually set the new range (if not done before) 811 if (!newRange.startContainer) { 812 newRange.startContainer = newRange.endContainer = el.domobj; 813 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