1 /* selection.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 "use strict"; 28 define( 29 [ 'aloha/core', 'jquery', 'util/class', 'util/range', 'util/arrays', 'util/strings', 'aloha/console', 'PubSub', 'aloha/engine', 'aloha/ecma5shims', 'aloha/rangy-core' ], 30 function(Aloha, jQuery, Class, Range, Arrays, Strings, console, PubSub, Engine, e5s) { 31 var GENTICS = window.GENTICS; 32 /** 33 * @namespace Aloha 34 * @class Selection 35 * This singleton class always represents the current user selection 36 * @singleton 37 */ 38 var Selection = Class.extend({ 39 _constructor: function(){ 40 // Pseudo Range Clone being cleaned up for better HTML wrapping support 41 this.rangeObject = {}; 42 43 this.preventSelectionChangedFlag = false; // will remember if someone urged us to skip the next aloha-selection-changed event 44 45 // define basics first 46 this.tagHierarchy = { 47 'textNode': {}, 48 'abbr': { 49 'textNode' : true 50 }, 51 'b': { 52 'textNode' : true, 'b' : true, 'i' : true, 'em' : true, 'sup' : true, 53 'sub' : true, 'br' : true, 'span' : true, 'img' : true, 'a' : true, 54 'del' : true, 'ins' : true, 'u' : true, 'cite' : true, 'q' : true, 55 'code' : true, 'abbr' : true, 'strong' : true 56 }, 57 'pre': { 58 'textNode' : true, 'b' : true, 'i' : true, 'em' : true, 'sup' : true, 59 'sub' : true, 'br' : true, 'span' : true, 'img' : true, 'a' : true, 60 'del' : true, 'ins' : true, 'u' : true, 'cite' : true, 'q' : true, 61 'code' : true, 'abbr' : true 62 }, 63 'blockquote': { 64 'textNode' : true, 'b' : true, 'i' : true, 'em' : true, 'sup' : true, 65 'sub' : true, 'br' : true, 'span' : true, 'img' : true, 'a' : true, 66 'del' : true, 'ins' : true, 'u' : true, 'cite' : true, 'q' : true, 67 'code' : true, 'abbr' : true, 'p' : true, 'h1' : true, 'h2' : true, 68 'h3' : true, 'h4' : true, 'h5' : true, 'h6' : true 69 }, 70 'ins': { 71 'textNode' : true, 'b' : true, 'i' : true, 'em' : true, 'sup' : true, 72 'sub' : true, 'br' : true, 'span' : true, 'img' : true, 'a' : true, 73 'u' : true, 'p' : true, 'h1' : true, 'h2' : true, 'h3' : true, 74 'h4' : true, 'h5' : true, 'h6' : true 75 }, 76 'ul': { 'li' : true }, 77 'ol': { 'li' : true }, 78 'li': { 79 'textNode' : true, 'b' : true, 'i' : true, 'em' : true, 'sup' : true, 80 'sub' : true, 'br' : true, 'span' : true, 'img' : true, 'ul' : true, 81 'ol' : true, 'h1' : true, 'h2' : true, 'h3' : true, 'h4' : true, 82 'h5' : true, 'h6' : true, 'del' : true, 'ins' : true, 'u' : true, 83 'a' : true 84 }, 85 'tr': { 'td': true, 'th' : true }, 86 'table': { 'tr': true }, 87 'div': { 88 'textNode' : true, 'b' : true, 'i' : true, 'em' : true, 'sup' : true, 89 'sub' : true, 'br' : true, 'span' : true, 'img' : true, 'ul' : true, 90 'ol' : true, 'table' : true, 'h1' : true, 'h2' : true, 'h3' : true, 91 'h4' : true, 'h5' : true, 'h6' : true, 'del' : true, 'ins' : true, 92 'u' : true, 'p' : true, 'div' : true, 'pre' : true, 'blockquote' : true, 93 'a' : true 94 }, 95 'h1': { 96 'textNode' : true, 'b' : true, 'i' : true, 'em' : true, 'sup' : true, 97 'sub' : true, 'br' : true, 'span' : true, 'img' : true, 'a' : true, 98 'del' : true, 'ins' : true, 'u' : true 99 } 100 }; 101 102 // now reference the basics for all other equal tags (important: don't forget to include 103 // the basics itself as reference: 'b' : this.tagHierarchy.b 104 this.tagHierarchy = { 105 'textNode' : this.tagHierarchy.textNode, 106 'abbr' : this.tagHierarchy.abbr, 107 'br' : this.tagHierarchy.textNode, 108 'img' : this.tagHierarchy.textNode, 109 'b' : this.tagHierarchy.b, 110 'strong' : this.tagHierarchy.b, 111 'code' : this.tagHierarchy.b, 112 'q' : this.tagHierarchy.b, 113 'blockquote' : this.tagHierarchy.blockquote, 114 'cite' : this.tagHierarchy.b, 115 'i' : this.tagHierarchy.b, 116 'em' : this.tagHierarchy.b, 117 'sup' : this.tagHierarchy.b, 118 'sub' : this.tagHierarchy.b, 119 'span' : this.tagHierarchy.b, 120 'del' : this.tagHierarchy.del, 121 'ins' : this.tagHierarchy.ins, 122 'u' : this.tagHierarchy.b, 123 'p' : this.tagHierarchy.b, 124 'pre' : this.tagHierarchy.pre, 125 'a' : this.tagHierarchy.b, 126 'ul' : this.tagHierarchy.ul, 127 'ol' : this.tagHierarchy.ol, 128 'li' : this.tagHierarchy.li, 129 'td' : this.tagHierarchy.li, 130 131 'div' : this.tagHierarchy.div, 132 'h1' : this.tagHierarchy.h1, 133 'h2' : this.tagHierarchy.h1, 134 'h3' : this.tagHierarchy.h1, 135 'h4' : this.tagHierarchy.h1, 136 'h5' : this.tagHierarchy.h1, 137 'h6' : this.tagHierarchy.h1, 138 'table' : this.tagHierarchy.table 139 }; 140 141 // When applying this elements to selection they will replace the assigned elements 142 this.replacingElements = { 143 'h1': { 144 'p': true, 145 'h1': true, 146 'h2': true, 147 'h3': true, 148 'h4': true, 149 'h5': true, 150 'h6': true, 151 'pre': true, 152 'blockquote': true 153 } 154 }; 155 this.replacingElements = { 156 'h1' : this.replacingElements.h1, 157 'h2' : this.replacingElements.h1, 158 'h3' : this.replacingElements.h1, 159 'h4' : this.replacingElements.h1, 160 'h5' : this.replacingElements.h1, 161 'h6' : this.replacingElements.h1, 162 'pre' : this.replacingElements.h1, 163 'p' : this.replacingElements.h1, 164 'blockquote' : this.replacingElements.h1 165 }; 166 this.allowedToStealElements = { 167 'h1' : {'textNode': true} 168 }; 169 this.allowedToStealElements = { 170 'h1' : this.allowedToStealElements.h1, 171 'h2' : this.allowedToStealElements.h1, 172 'h3' : this.allowedToStealElements.h1, 173 'h4' : this.allowedToStealElements.h1, 174 'h5' : this.allowedToStealElements.h1, 175 'h6' : this.allowedToStealElements.h1, 176 'p' : this.tagHierarchy.b 177 }; 178 }, 179 180 /** 181 * Class definition of a SelectionTree (relevant for all formatting / markup changes) 182 * TODO: remove this (was moved to range.js) 183 * Structure: 184 * + 185 * |-domobj: <reference to the DOM Object> (NOT jQuery) 186 * |-selection: defines if this node is marked by user [none|partial|full] 187 * |-children: recursive structure like this 188 * @hide 189 */ 190 SelectionTree: function() { 191 this.domobj = {}; 192 this.selection = undefined; 193 this.children = []; 194 }, 195 196 /** 197 * INFO: Method is used for integration with Gentics Aloha, has no use otherwise 198 * Updates the rangeObject according to the current user selection 199 * Method is always called on selection change 200 * @param objectClicked Object that triggered the selectionChange event 201 * @return true when rangeObject was modified, false otherwise 202 * @hide 203 */ 204 onChange: function(objectClicked, event, timeout) { 205 if (this.updateSelectionTimeout) { 206 window.clearTimeout(this.updateSelectionTimeout); 207 } 208 209 // We have to update the selection in a timeout due to an IE 210 // bug that is is caused by selecting some text and then 211 // clicking once inside the selection (which collapses the 212 // selection inside the previous selection). 213 var selection = this; 214 this.updateSelectionTimeout = window.setTimeout(function () { 215 var range = new Aloha.Selection.SelectionRange(true); 216 // We have to work around an IE bug that causes the user 217 // selection to be incorrectly set on the body element 218 // when the updateSelectionTimeout triggers. The 219 // selection corrects itself after waiting a while. 220 if (!range.startContainer || 221 'HTML' === range.startContainer.nodeName || 222 223 'BODY' === range.startContainer.nodeName ) { 224 if (!this.updateSelectionTimeout) { 225 // First wait 5 millis, then 20 millis, 50 millis, 110 millis etc. 226 selection.onChange(objectClicked, event, 10 + (timeout || 5) * 2); 227 } 228 return; 229 } 230 Aloha.Selection._updateSelection(event, range); 231 }, timeout || 5); 232 }, 233 234 /** 235 * prevents the next aloha-selection-changed event from being triggered 236 */ 237 preventSelectionChanged: function () { 238 this.preventSelectionChangedFlag = true; 239 }, 240 241 /** 242 * will return wheter selection change event was prevented or not, and reset the preventSelectionChangedFlag 243 * @return {Boolean} true if aloha-selection-change event was prevented 244 */ 245 isSelectionChangedPrevented: function () { 246 var prevented = this.preventSelectionChangedFlag; 247 this.preventSelectionChangedFlag = false; 248 return prevented; 249 }, 250 251 /** 252 * Checks if the current rangeObject common ancector container is edtiable 253 * @return {Boolean} true if current common ancestor is editable 254 */ 255 isSelectionEditable: function() { 256 return ( this.rangeObject.commonAncestorContainer && 257 jQuery( this.rangeObject.commonAncestorContainer ) 258 .contentEditable() ); 259 }, 260 261 /** 262 * This method checks, if the current rangeObject common ancestor container has a 'data-aloha-floatingmenu-visible' Attribute. 263 * Needed in Floating Menu for exceptional display of floatingmenu. 264 */ 265 isFloatingMenuVisible: function() { 266 var visible = jQuery(Aloha.Selection.rangeObject 267 .commonAncestorContainer).attr('data-aloha-floatingmenu-visible'); 268 if(visible !== 'undefined'){ 269 if (visible === 'true'){ 270 return true; 271 } else { 272 return false; 273 } 274 } 275 return false; 276 }, 277 278 /** 279 * INFO: Method is used for integration with Gentics Aloha, has no use otherwise 280 * Updates the rangeObject according to the current user selection 281 * Method is always called on selection change 282 * @param event jQuery browser event object 283 * @return true when rangeObject was modified, false otherwise 284 * @hide 285 */ 286 updateSelection: function(event) { 287 return this._updateSelection(event, null); 288 }, 289 290 /** 291 * Internal version of updateSelection that adds the range parameter to be 292 * able to work around an IE bug that caused the current user selection 293 * sometimes to be on the body element. 294 * @param {Object} event 295 * @param {Object} range a substitute for the current user selection. if not provided, 296 * the current user selection will be used. 297 * @hide 298 */ 299 _updateSelection: function( event, range ) { 300 if ( event && event.originalEvent 301 && event.originalEvent.stopSelectionUpdate === true ) { 302 return false; 303 } 304 305 if ( typeof range === 'undefined' ) { 306 return false; 307 } 308 309 this.rangeObject = range || new Aloha.Selection.SelectionRange( true ); 310 311 // Only execute the workaround when a valid rangeObject was provided 312 if ( typeof this.rangeObject !== "undefined" && typeof this.rangeObject.startContainer !== "undefined" && this.rangeObject.endContainer !== "undefined") { 313 // workaround for a nasty IE bug that allows the user to select text nodes inside areas with contenteditable "false" 314 if ( (this.rangeObject.startContainer.nodeType === 3 && !jQuery(this.rangeObject.startContainer.parentNode).contentEditable()) 315 || (this.rangeObject.endContainer.nodeType === 3 && !jQuery(this.rangeObject.endContainer.parentNode).contentEditable())) { 316 Aloha.getSelection().removeAllRanges(); 317 return true; 318 } 319 } 320 321 // find the CAC (Common Ancestor Container) and update the selection Tree 322 this.rangeObject.update(); 323 324 // check if aloha-selection-changed event has been prevented 325 if (this.isSelectionChangedPrevented()) { 326 return true; 327 } 328 329 Aloha.trigger('aloha-selection-changed-before', [this.rangeObject, event]); 330 331 // throw the event that the selection has changed. Plugins now have the 332 // chance to react on the currentElements[childCount].children.lengthged selection 333 Aloha.trigger('aloha-selection-changed', [this.rangeObject, event]); 334 335 336 triggerSelectionContextChanged(this.rangeObject, event); 337 338 Aloha.trigger('aloha-selection-changed-after', [this.rangeObject, event]); 339 340 return true; 341 }, 342 343 /** 344 * creates an object with x items containing all relevant dom objects. 345 * Structure: 346 * + 347 * |-domobj: <reference to the DOM Object> (NOT jQuery) 348 * |-selection: defines if this node is marked by user [none|partial|full] 349 * |-children: recursive structure like this ("x.." because it's then shown last in DOM Browsers...) 350 * TODO: remove this (was moved to range.js) 351 * 352 * @param rangeObject "Aloha clean" range object including a commonAncestorContainer 353 * @return obj selection 354 * @hide 355 */ 356 getSelectionTree: function(rangeObject) { 357 if (!rangeObject) { // if called without any parameters, the method acts as getter for this.selectionTree 358 return this.rangeObject.getSelectionTree(); 359 } 360 if (!rangeObject.commonAncestorContainer) { 361 Aloha.Log.error(this, 'the rangeObject is missing the commonAncestorContainer'); 362 return false; 363 } 364 365 this.inselection = false; 366 367 // before getting the selection tree, we do a cleanup 368 if (GENTICS.Utils.Dom.doCleanup({'merge' : true}, rangeObject)) { 369 rangeObject.update(); 370 rangeObject.select(); 371 } 372 373 return this.recursiveGetSelectionTree(rangeObject, rangeObject.commonAncestorContainer); 374 }, 375 376 /** 377 * Recursive inner function for generating the selection tree. 378 * TODO: remove this (was moved to range.js) 379 * @param rangeObject range object 380 * @param currentObject current DOM object for which the selection tree shall be generated 381 * @return array of SelectionTree objects for the children of the current DOM object 382 * @hide 383 */ 384 recursiveGetSelectionTree: function (rangeObject, currentObject) { 385 // get all direct children of the given object 386 var jQueryCurrentObject = jQuery(currentObject), 387 childCount = 0, 388 that = this, 389 currentElements = []; 390 391 jQueryCurrentObject.contents().each(function(index) { 392 var selectionType = 'none', 393 startOffset = false, 394 endOffset = false, 395 collapsedFound = false, 396 i, elementsLength, 397 noneFound = false, 398 partialFound = false, 399 fullFound = false; 400 401 // check for collapsed selections between nodes 402 if (rangeObject.isCollapsed() && currentObject === rangeObject.startContainer && rangeObject.startOffset == index) { 403 // insert an extra selectiontree object for the collapsed selection here 404 currentElements[childCount] = new Aloha.Selection.SelectionTree(); 405 currentElements[childCount].selection = 'collapsed'; 406 currentElements[childCount].domobj = undefined; 407 that.inselection = false; 408 collapsedFound = true; 409 childCount++; 410 } 411 412 if (!that.inselection && !collapsedFound) { 413 // the start of the selection was not yet found, so look for it now 414 // check whether the start of the selection is found here 415 416 // Try to read the nodeType property and return if we do not have permission 417 // ie.: frame document to an external URL 418 var nodeType; 419 try { 420 nodeType = this.nodeType; 421 } 422 catch (e) { 423 return; 424 } 425 426 // check is dependent on the node type 427 switch(nodeType) { 428 case 3: // text node 429 if (this === rangeObject.startContainer) { 430 // the selection starts here 431 that.inselection = true; 432 433 // when the startoffset is > 0, the selection type is only partial 434 selectionType = rangeObject.startOffset > 0 ? 'partial' : 'full'; 435 startOffset = rangeObject.startOffset; 436 endOffset = this.length; 437 } 438 break; 439 case 1: // element node 440 if (this === rangeObject.startContainer && rangeObject.startOffset === 0) { 441 // the selection starts here 442 that.inselection = true; 443 selectionType = 'full'; 444 } 445 if (currentObject === rangeObject.startContainer && rangeObject.startOffset === index) { 446 // the selection starts here 447 that.inselection = true; 448 selectionType = 'full'; 449 } 450 break; 451 } 452 } 453 454 if (that.inselection && !collapsedFound) { 455 if (selectionType == 'none') { 456 selectionType = 'full'; 457 } 458 // we already found the start of the selection, so look for the end of the selection now 459 // check whether the end of the selection is found here 460 461 switch(this.nodeType) { 462 case 3: // text node 463 if (this === rangeObject.endContainer) { 464 // the selection ends here 465 that.inselection = false; 466 467 // check for partial selection here 468 if (rangeObject.endOffset < this.length) { 469 selectionType = 'partial'; 470 } 471 if (startOffset === false) { 472 startOffset = 0; 473 } 474 endOffset = rangeObject.endOffset; 475 } 476 break; 477 case 1: // element node 478 if (this === rangeObject.endContainer && rangeObject.endOffset === 0) { 479 that.inselection = false; 480 } 481 break; 482 } 483 if (currentObject === rangeObject.endContainer && rangeObject.endOffset <= index) { 484 that.inselection = false; 485 selectionType = 'none'; 486 } 487 } 488 489 // create the current selection tree entry 490 currentElements[childCount] = new Aloha.Selection.SelectionTree(); 491 currentElements[childCount].domobj = this; 492 currentElements[childCount].selection = selectionType; 493 if (selectionType == 'partial') { 494 currentElements[childCount].startOffset = startOffset; 495 currentElements[childCount].endOffset = endOffset; 496 } 497 498 // now do the recursion step into the current object 499 currentElements[childCount].children = that.recursiveGetSelectionTree(rangeObject, this); 500 elementsLength = currentElements[childCount].children.length; 501 502 // check whether a selection was found within the children 503 if (elementsLength > 0) { 504 for ( i = 0; i < elementsLength; ++i) { 505 switch(currentElements[childCount].children[i].selection) { 506 case 'none': 507 noneFound = true; 508 break; 509 case 'full': 510 fullFound = true; 511 break; 512 case 'partial': 513 partialFound = true; 514 break; 515 } 516 } 517 518 if (partialFound || (fullFound && noneFound)) { 519 // found at least one 'partial' selection in the children, or both 'full' and 'none', so this element is also 'partial' selected 520 currentElements[childCount].selection = 'partial'; 521 } else if (fullFound && !partialFound && !noneFound) { 522 // only found 'full' selected children, so this element is also 'full' selected 523 currentElements[childCount].selection = 'full'; 524 } 525 } 526 527 childCount++; 528 }); 529 530 // extra check for collapsed selections at the end of the current element 531 if (rangeObject.isCollapsed() 532 && currentObject === rangeObject.startContainer 533 && rangeObject.startOffset == currentObject.childNodes.length) { 534 currentElements[childCount] = new Aloha.Selection.SelectionTree(); 535 currentElements[childCount].selection = 'collapsed'; 536 currentElements[childCount].domobj = undefined; 537 } 538 539 return currentElements; 540 }, 541 542 /** 543 * Get the currently selected range 544 * @return {Aloha.Selection.SelectionRange} currently selected range 545 * @method 546 */ 547 getRangeObject: function() { 548 return this.rangeObject; 549 }, 550 551 /** 552 * method finds out, if a node is within a certain markup or not 553 * @param rangeObj Aloha rangeObject 554 * @param startOrEnd boolean; defines, if start or endContainer should be used: false for start, true for end 555 * @param markupObject jQuery object of the markup to look for 556 * @param tagComparator method, which is used to compare the dom object and the jQuery markup object. the method must accept 2 parameters, the first is the domobj, the second is the jquery object. if no method is specified, the method this.standardTextLevelSemanticsComparator is used 557 * @param limitObject dom object which limits the search are within the dom. normally this will be the active Editable 558 * @return true, if the markup is effective on the range objects start or end node 559 * @hide 560 */ 561 isRangeObjectWithinMarkup: function(rangeObject, startOrEnd, markupObject, tagComparator, limitObject) { 562 var 563 domObj = !startOrEnd?rangeObject.startContainer:rangeObject.endContainer, 564 that = this, 565 parents = jQuery(domObj).parents(), 566 returnVal = false, 567 i = -1; 568 569 // check if a comparison method was passed as parameter ... 570 if (typeof tagComparator !== 'undefined' && typeof tagComparator !== 'function') { 571 Aloha.Log.error(this,'parameter tagComparator is not a function'); 572 } 573 // ... if not use this as standard tag comparison method 574 if (typeof tagComparator === 'undefined') { 575 tagComparator = function(domobj, markupObject) { 576 return that.standardTextLevelSemanticsComparator(domobj, markupObject); // TODO should actually be this.getStandardTagComparator(markupObject) 577 }; 578 } 579 580 if (parents.length > 0) { 581 parents.each(function() { 582 // the limit object was reached (normally the Editable Element) 583 if (this === limitObject) { 584 Aloha.Log.debug(that,'reached limit dom obj'); 585 return false; // break() of jQuery .each(); THIS IS NOT THE FUNCTION RETURN VALUE 586 } 587 if (tagComparator(this, markupObject)) { 588 if (returnVal === false) { 589 returnVal = []; 590 } 591 Aloha.Log.debug(that,'reached object equal to markup'); 592 i++; 593 returnVal[i] = this; 594 return true; // continue() of jQuery .each(); THIS IS NOT THE FUNCTION RETURN VALUE 595 } 596 }); 597 } 598 return returnVal; 599 }, 600 601 /** 602 * standard method, to compare a domobj and a jquery object for sections and grouping content (e.g. p, h1, h2, ul, ....). 603 * is always used when no other tag comparator is passed as parameter 604 * @param domobj domobject to compare with markup 605 * @param markupObject jQuery object of the markup to compare with domobj 606 * @return true if objects are equal and false if not 607 * @hide 608 */ 609 standardSectionsAndGroupingContentComparator: function(domobj, markupObject) { 610 if (domobj.nodeType !== 1) { 611 Aloha.Log.debug(this,'only element nodes (nodeType == 1) can be compared'); 612 return false; 613 } 614 if (!markupObject[0].nodeName) { 615 return false; 616 } 617 var elemMap = Aloha.Selection.replacingElements[domobj.nodeName.toLowerCase()]; 618 return elemMap && elemMap[markupObject[0].nodeName.toLowerCase()]; 619 }, 620 621 /** 622 * standard method, to compare a domobj and a jquery object for their tagName (aka span elements, e.g. b, i, sup, span, ...). 623 * is always used when no other tag comparator is passed as parameter 624 * @param domobj domobject to compare with markup 625 * @param markupObject jQuery object of the markup to compare with domobj 626 * @return true if objects are equal and false if not 627 * @hide 628 */ 629 standardTagNameComparator : function(domobj, markupObject) { 630 if (domobj.nodeType === 1) { 631 if (domobj.nodeName != markupObject[0].nodeName) { 632 return false; 633 } 634 return true; 635 } else { 636 Aloha.Log.debug(this,'only element nodes (nodeType == 1) can be compared'); 637 } 638 return false; 639 }, 640 641 /** 642 * standard method, to compare a domobj and a jquery object for text level semantics (aka span elements, e.g. b, i, sup, span, ...). 643 * is always used when no other tag comparator is passed as parameter 644 * @param domobj domobject to compare with markup 645 * @param markupObject jQuery object of the markup to compare with domobj 646 * @return true if objects are equal and false if not 647 * @hide 648 */ 649 standardTextLevelSemanticsComparator: function(domobj, markupObject) { 650 // only element nodes can be compared 651 if (domobj.nodeType === 1) { 652 if (domobj.nodeName != markupObject[0].nodeName) { 653 return false; 654 } 655 if (!this.standardAttributesComparator(domobj, markupObject)) { 656 return false; 657 } 658 return true; 659 } else { 660 661 Aloha.Log.debug(this,'only element nodes (nodeType == 1) can be compared'); 662 } 663 return false; 664 }, 665 666 667 /** 668 * standard method, to compare attributes of one dom obj and one markup obj (jQuery) 669 * @param domobj domobject to compare with markup 670 * @param markupObject jQuery object of the markup to compare with domobj 671 * @return true if objects are equal and false if not 672 * @hide 673 */ 674 standardAttributesComparator: function(domobj, markupObject) { 675 var classesA = Strings.words((domobj && domobj.className) || ''); 676 var classesB = Strings.words((markupObject.length && markupObject[0].className) || ''); 677 Arrays.sortUnique(classesA); 678 Arrays.sortUnique(classesB); 679 return Arrays.equal(classesA, classesB); 680 }, 681 682 /** 683 * method finds out, if a node is within a certain markup or not 684 * @param rangeObj Aloha rangeObject 685 * @param markupObject jQuery object of the markup to be applied (e.g. created with obj = jQuery('<b></b>'); ) 686 * @param tagComparator method, which is used to compare the dom object and the jQuery markup object. the method must accept 2 parameters, the first is the domobj, the second is the jquery object. if no method is specified, the method this.standardTextLevelSemanticsComparator is used 687 * @return void; TODO: should return true if the markup applied successfully and false if not 688 * @hide 689 */ 690 changeMarkup: function(rangeObject, markupObject, tagComparator) { 691 var 692 tagName = markupObject[0].tagName.toLowerCase(), 693 newCAC, limitObject, 694 backupRangeObject, 695 relevantMarkupObjectsAtSelectionStart = this.isRangeObjectWithinMarkup(rangeObject, false, markupObject, tagComparator, limitObject), 696 relevantMarkupObjectsAtSelectionEnd = this.isRangeObjectWithinMarkup(rangeObject, true, markupObject, tagComparator, limitObject), 697 nextSibling, relevantMarkupObjectAfterSelection, 698 prevSibling, relevantMarkupObjectBeforeSelection, 699 extendedRangeObject; 700 701 // if the element is a replacing element (like p/h1/h2/h3/h4/h5/h6...), which must not wrap each other 702 // use a clone of rangeObject 703 if (this.replacingElements[tagName]) { 704 // backup rangeObject for later selection; 705 backupRangeObject = rangeObject; 706 707 // create a new range object to not modify the orginal 708 rangeObject = new this.SelectionRange(rangeObject); 709 710 // either select the active Editable as new commonAncestorContainer (CAC) or use the body 711 if (Aloha.activeEditable) { 712 newCAC= Aloha.activeEditable.obj.get(0); 713 } else { 714 newCAC = jQuery('body'); 715 } 716 // update rangeObject by setting the newCAC and automatically recalculating the selectionTree 717 rangeObject.update(newCAC); 718 719 // store the information, that the markupObject can be replaced (not must be!!) inside the jQuery markup object 720 markupObject.isReplacingElement = true; 721 } 722 // if the element is NOT a replacing element, then something needs to be selected, otherwise it can not be wrapped 723 // therefor the method can return false, if nothing is selected ( = rangeObject is collapsed) 724 else { 725 if (rangeObject.isCollapsed()) { 726 Aloha.Log.debug(this, 'early returning from applying markup because nothing is currently selected'); 727 return false; 728 } 729 } 730 731 // is Start/End DOM Obj inside the markup to change 732 if (Aloha.activeEditable) { 733 limitObject = Aloha.activeEditable.obj[0]; 734 } else { 735 limitObject = jQuery('body'); 736 } 737 738 if (!markupObject.isReplacingElement && rangeObject.startOffset === 0) { // don't care about replacers, because they never extend 739 if (prevSibling = this.getTextNodeSibling(false, rangeObject.commonAncestorContainer.parentNode, rangeObject.startContainer)) { 740 relevantMarkupObjectBeforeSelection = this.isRangeObjectWithinMarkup({startContainer : prevSibling, startOffset : 0}, false, markupObject, tagComparator, limitObject); 741 } 742 } 743 if (!markupObject.isReplacingElement && (rangeObject.endOffset === rangeObject.endContainer.length)) { // don't care about replacers, because they never extend 744 if (nextSibling = this.getTextNodeSibling(true, rangeObject.commonAncestorContainer.parentNode, rangeObject.endContainer)) { 745 relevantMarkupObjectAfterSelection = this.isRangeObjectWithinMarkup({startContainer: nextSibling, startOffset: 0}, false, markupObject, tagComparator, limitObject); 746 } 747 } 748 749 // decide what to do (expand or reduce markup) 750 // Alternative A: from markup to no-markup: markup will be removed in selection; 751 // reapplied from original markup start to selection start 752 if (!markupObject.isReplacingElement && (relevantMarkupObjectsAtSelectionStart && !relevantMarkupObjectsAtSelectionEnd)) { 753 Aloha.Log.info(this, 'markup 2 non-markup'); 754 this.prepareForRemoval(rangeObject.getSelectionTree(), markupObject, tagComparator); 755 jQuery(relevantMarkupObjectsAtSelectionStart).addClass('preparedForRemoval'); 756 this.insertCroppedMarkups(relevantMarkupObjectsAtSelectionStart, rangeObject, false, tagComparator); 757 } 758 759 // Alternative B: from markup to markup: 760 // remove selected markup (=split existing markup if single, shrink if two different) 761 else if (!markupObject.isReplacingElement && relevantMarkupObjectsAtSelectionStart && relevantMarkupObjectsAtSelectionEnd) { 762 Aloha.Log.info(this, 'markup 2 markup'); 763 this.prepareForRemoval(rangeObject.getSelectionTree(), markupObject, tagComparator); 764 this.splitRelevantMarkupObject(relevantMarkupObjectsAtSelectionStart, relevantMarkupObjectsAtSelectionEnd, rangeObject, tagComparator); 765 } 766 767 768 // Alternative C: from no-markup to markup OR with next2markup: 769 // new markup is wrapped from selection start to end of originalmarkup, original is remove afterwards 770 else if (!markupObject.isReplacingElement && ((!relevantMarkupObjectsAtSelectionStart && relevantMarkupObjectsAtSelectionEnd) || relevantMarkupObjectAfterSelection || relevantMarkupObjectBeforeSelection )) { // 771 Aloha.Log.info(this, 'non-markup 2 markup OR with next2markup'); 772 // move end of rangeObject to end of relevant markups 773 if (relevantMarkupObjectBeforeSelection && relevantMarkupObjectAfterSelection) { 774 extendedRangeObject = new Aloha.Selection.SelectionRange(rangeObject); 775 extendedRangeObject.startContainer = jQuery(relevantMarkupObjectBeforeSelection[ relevantMarkupObjectBeforeSelection.length-1 ]).textNodes()[0]; 776 extendedRangeObject.startOffset = 0; 777 extendedRangeObject.endContainer = jQuery(relevantMarkupObjectAfterSelection[ relevantMarkupObjectAfterSelection.length-1 ]).textNodes().last()[0]; 778 extendedRangeObject.endOffset = extendedRangeObject.endContainer.length; 779 extendedRangeObject.update(); 780 this.applyMarkup(extendedRangeObject.getSelectionTree(), rangeObject, markupObject, tagComparator); 781 Aloha.Log.info(this, 'double extending previous markup(previous and after selection), actually wrapping it ...'); 782 783 } else if (relevantMarkupObjectBeforeSelection && !relevantMarkupObjectAfterSelection && !relevantMarkupObjectsAtSelectionEnd) { 784 this.extendExistingMarkupWithSelection(relevantMarkupObjectBeforeSelection, rangeObject, false, tagComparator); 785 Aloha.Log.info(this, 'extending previous markup'); 786 787 } else if (relevantMarkupObjectBeforeSelection && !relevantMarkupObjectAfterSelection && relevantMarkupObjectsAtSelectionEnd) { 788 extendedRangeObject = new Aloha.Selection.SelectionRange(rangeObject); 789 extendedRangeObject.startContainer = jQuery(relevantMarkupObjectBeforeSelection[ relevantMarkupObjectBeforeSelection.length-1 ]).textNodes()[0]; 790 extendedRangeObject.startOffset = 0; 791 extendedRangeObject.endContainer = jQuery(relevantMarkupObjectsAtSelectionEnd[ relevantMarkupObjectsAtSelectionEnd.length-1 ]).textNodes().last()[0]; 792 extendedRangeObject.endOffset = extendedRangeObject.endContainer.length; 793 extendedRangeObject.update(); 794 this.applyMarkup(extendedRangeObject.getSelectionTree(), rangeObject, markupObject, tagComparator); 795 Aloha.Log.info(this, 'double extending previous markup(previous and relevant at the end), actually wrapping it ...'); 796 797 798 } else if (!relevantMarkupObjectBeforeSelection && relevantMarkupObjectAfterSelection) { 799 this.extendExistingMarkupWithSelection(relevantMarkupObjectAfterSelection, rangeObject, true, tagComparator); 800 Aloha.Log.info(this, 'extending following markup backwards'); 801 802 } else { 803 this.extendExistingMarkupWithSelection(relevantMarkupObjectsAtSelectionEnd, rangeObject, true, tagComparator); 804 } 805 } 806 807 // Alternative D: no-markup to no-markup: easy 808 else if (markupObject.isReplacingElement || (!relevantMarkupObjectsAtSelectionStart && !relevantMarkupObjectsAtSelectionEnd && !relevantMarkupObjectBeforeSelection && !relevantMarkupObjectAfterSelection)) { 809 Aloha.Log.info(this, 'non-markup 2 non-markup'); 810 811 // workaround to keep the caret at the right position if it's an empty element 812 // applyMarkup was not working correctly and has a lot of overhead we don't need in that case 813 if (isCollapsedAndEmptyOrEndBr(rangeObject)) { 814 var newMarkup = markupObject.clone(); 815 816 if (isCollapsedAndEndBr(rangeObject)) { 817 newMarkup[0].appendChild(Engine.createEndBreak()); 818 } 819 820 // setting the focus is needed for mozilla and IE 7 to have a working rangeObject.select() 821 if (Aloha.activeEditable 822 && jQuery.browser.mozilla) { 823 Aloha.activeEditable.obj.focus(); 824 } 825 826 if (Engine.isEditable(rangeObject.startContainer)) { 827 Engine.copyAttributes(rangeObject.startContainer, newMarkup[0]); 828 jQuery(rangeObject.startContainer).after(newMarkup[0]).remove(); 829 } else if (Engine.isEditingHost(rangeObject.startContainer)) { 830 jQuery(rangeObject.startContainer).append(newMarkup[0]); 831 Engine.ensureContainerEditable(newMarkup[0]); 832 } 833 834 backupRangeObject.startContainer = newMarkup[0]; 835 backupRangeObject.endContainer = newMarkup[0]; 836 backupRangeObject.startOffset = 0; 837 backupRangeObject.endOffset = 0; 838 return; 839 } else { 840 this.applyMarkup(rangeObject.getSelectionTree(), rangeObject, markupObject, tagComparator, {setRangeObject2NewMarkup: true}); 841 backupRangeObject.startContainer = rangeObject.startContainer; 842 backupRangeObject.endContainer = rangeObject.endContainer; 843 backupRangeObject.startOffset = rangeObject.startOffset; 844 backupRangeObject.endOffset = rangeObject.endOffset; 845 } 846 } 847 848 if (markupObject.isReplacingElement) { 849 //Check if the startContainer is one of the zapped elements 850 if ( backupRangeObject && 851 backupRangeObject.startContainer.className && 852 backupRangeObject.startContainer.className.indexOf('preparedForRemoval') > -1 ) { 853 //var parentElement = jQuery(backupRangeObject.startContainer).closest(markupObject[0].tagName).get(0); 854 var parentElement = jQuery(backupRangeObject.startContainer).parents(markupObject[0].tagName).get(0); 855 backupRangeObject.startContainer = parentElement; 856 rangeObject.startContainer = parentElement; 857 } 858 //check if the endContainer is one of the zapped elements 859 if (backupRangeObject && 860 backupRangeObject.endContainer.className && 861 backupRangeObject.endContainer.className.indexOf('preparedForRemoval') > -1 ) { 862 //var parentElement = jQuery(backupRangeObject.endContainer).closest(markupObject[0].tagName).get(0); 863 var parentElement = jQuery(backupRangeObject.endContainer).parents(markupObject[0].tagName).get(0); 864 backupRangeObject.endContainer = parentElement; 865 rangeObject.endContainer = parentElement; 866 } 867 } 868 // remove all marked items 869 jQuery('.preparedForRemoval').zap(); 870 871 // recalculate cac and selectionTree 872 873 // update selection 874 if (markupObject.isReplacingElement) { 875 //After the zapping we have to check for wrong offsets 876 if (e5s.Node.ELEMENT_NODE === backupRangeObject.startContainer.nodeType && backupRangeObject.startContainer.childNodes && backupRangeObject.startContainer.childNodes.length < backupRangeObject.startOffset) { 877 backupRangeObject.startOffset = backupRangeObject.startContainer.childNodes.length; 878 rangeObject.startOffset = backupRangeObject.startContainer.childNodes.length; 879 } 880 if (e5s.Node.ELEMENT_NODE === backupRangeObject.endContainer.nodeType && backupRangeObject.endContainer.childNodes && backupRangeObject.endContainer.childNodes.length < backupRangeObject.endOffset) { 881 backupRangeObject.endOffset = backupRangeObject.endContainer.childNodes.length; 882 rangeObject.endOffset = backupRangeObject.endContainer.childNodes.length; 883 } 884 rangeObject.endContainer = backupRangeObject.endContainer; 885 rangeObject.endOffset = backupRangeObject.endOffset; 886 rangeObject.startContainer = backupRangeObject.startContainer; 887 rangeObject.startOffset = backupRangeObject.startOffset; 888 backupRangeObject.update(); 889 backupRangeObject.select(); 890 } else { 891 rangeObject.update(); 892 rangeObject.select(); 893 } 894 }, 895 896 /** 897 * method compares a JS array of domobjects with a range object and decides, if the rangeObject spans the whole markup objects. method is used to decide if a markup2markup selection can be completely remove or if it must be splitted into 2 separate markups 898 * @param relevantMarkupObjectsAtSelectionStart JS Array of dom objects, which are parents to the rangeObject.startContainer 899 * @param relevantMarkupObjectsAtSelectionEnd JS Array of dom objects, which are parents to the rangeObject.endContainer 900 * @param rangeObj Aloha rangeObject 901 * @return true, if rangeObjects and markup objects are identical, false otherwise 902 * @hide 903 */ 904 areMarkupObjectsAsLongAsRangeObject: function(relevantMarkupObjectsAtSelectionStart, relevantMarkupObjectsAtSelectionEnd, rangeObject) { 905 var i, el, textNode, relMarkupEnd, relMarkupStart; 906 907 if (rangeObject.startOffset !== 0) { 908 return false; 909 } 910 911 for (i = 0, relMarkupStart = relevantMarkupObjectsAtSelectionStart.length; i < relMarkupStart; i++) { 912 el = jQuery(relevantMarkupObjectsAtSelectionStart[i]); 913 if (el.textNodes().first()[0] !== rangeObject.startContainer) { 914 return false; 915 } 916 } 917 918 for (i = 0, relMarkupEnd = relevantMarkupObjectsAtSelectionEnd.length; i < relMarkupEnd; i++) { 919 el = jQuery(relevantMarkupObjectsAtSelectionEnd[i]); 920 textNode = el.textNodes().last()[0]; 921 if (textNode !== rangeObject.endContainer || textNode.length != rangeObject.endOffset) { 922 return false; 923 } 924 } 925 926 return true; 927 }, 928 929 /** 930 * method used to remove/split markup from a "markup2markup" selection 931 * @param relevantMarkupObjectsAtSelectionStart JS Array of dom objects, which are parents to the rangeObject.startContainer 932 * @param relevantMarkupObjectsAtSelectionEnd JS Array of dom objects, which are parents to the rangeObject.endContainer 933 * @param rangeObj Aloha rangeObject 934 * @param tagComparator method, which is used to compare the dom object and the jQuery markup object. the method must accept 2 parameters, the first is the domobj, the second is the jquery object. if no method is specified, the method this.standardTextLevelSemanticsComparator is used 935 * @return true (always, since no "false" case is currently known...but might be added) 936 * @hide 937 */ 938 splitRelevantMarkupObject: function(relevantMarkupObjectsAtSelectionStart, relevantMarkupObjectsAtSelectionEnd, rangeObject, tagComparator) { 939 // mark them to be deleted 940 jQuery(relevantMarkupObjectsAtSelectionStart).addClass('preparedForRemoval'); 941 jQuery(relevantMarkupObjectsAtSelectionEnd).addClass('preparedForRemoval'); 942 943 // check if the rangeObject is identical with the relevantMarkupObjects (in this case the markup can simply be removed) 944 if (this.areMarkupObjectsAsLongAsRangeObject(relevantMarkupObjectsAtSelectionStart, relevantMarkupObjectsAtSelectionEnd, rangeObject)) { 945 return true; 946 } 947 948 // find intersection (this can always only be one dom element (namely the highest) because all others will be removed 949 var relevantMarkupObjectAtSelectionStartAndEnd = this.intersectRelevantMarkupObjects(relevantMarkupObjectsAtSelectionStart, relevantMarkupObjectsAtSelectionEnd); 950 951 if (relevantMarkupObjectAtSelectionStartAndEnd) { 952 this.insertCroppedMarkups([relevantMarkupObjectAtSelectionStartAndEnd], rangeObject, false, tagComparator); 953 this.insertCroppedMarkups([relevantMarkupObjectAtSelectionStartAndEnd], rangeObject, true, tagComparator); 954 } else { 955 this.insertCroppedMarkups(relevantMarkupObjectsAtSelectionStart, rangeObject, false, tagComparator); 956 this.insertCroppedMarkups(relevantMarkupObjectsAtSelectionEnd, rangeObject, true, tagComparator); 957 } 958 return true; 959 }, 960 961 /** 962 * method takes two arrays of bottom up dom objects, compares them and returns either the object closest to the root or false 963 * @param relevantMarkupObjectsAtSelectionStart JS Array of dom objects 964 * @param relevantMarkupObjectsAtSelectionEnd JS Array of dom objects 965 * @return dom object closest to the root or false 966 * @hide 967 */ 968 intersectRelevantMarkupObjects: function(relevantMarkupObjectsAtSelectionStart, relevantMarkupObjectsAtSelectionEnd) { 969 var intersection = false, i, elStart, j, elEnd, relMarkupStart, relMarkupEnd; 970 if (!relevantMarkupObjectsAtSelectionStart || !relevantMarkupObjectsAtSelectionEnd) { 971 return intersection; // we can only intersect, if we have to arrays! 972 } 973 relMarkupStart = relevantMarkupObjectsAtSelectionStart.length; 974 relMarkupEnd = relevantMarkupObjectsAtSelectionEnd.length; 975 for (i = 0; i < relMarkupStart; i++) { 976 elStart = relevantMarkupObjectsAtSelectionStart[i]; 977 for (j = 0; j < relMarkupEnd; j++) { 978 elEnd = relevantMarkupObjectsAtSelectionEnd[j]; 979 if (elStart === elEnd) { 980 intersection = elStart; 981 } 982 } 983 } 984 return intersection; 985 }, 986 987 /** 988 * method used to add markup to a nonmarkup2markup selection 989 * @param relevantMarkupObjects JS Array of dom objects effecting either the start or endContainer of a selection (which should be extended) 990 * @param rangeObject Aloha rangeObject the markups should be extended to 991 * @param startOrEnd boolean; defines, if the existing markups should be extended forwards or backwards (is propably redundant and could be found out by comparing start or end container with the markup array dom objects) 992 * @param tagComparator method, which is used to compare the dom object and the jQuery markup object. the method must accept 2 parameters, the first is the domobj, the second is the jquery object. if no method is specified, the method this.standardTextLevelSemanticsComparator is used 993 * @return true 994 * @hide 995 */ 996 extendExistingMarkupWithSelection: function(relevantMarkupObjects, rangeObject, startOrEnd, tagComparator) { 997 var extendMarkupsAtStart, extendMarkupsAtEnd, objects, i, relMarkupLength, el, textnodes, nodeNr; 998 if (!startOrEnd) { // = Start 999 // start part of rangeObject should be used, therefor existing markups are cropped at the end 1000 extendMarkupsAtStart = true; 1001 } 1002 if (startOrEnd) { // = End 1003 // end part of rangeObject should be used, therefor existing markups are cropped at start (beginning) 1004 extendMarkupsAtEnd = true; 1005 } 1006 objects = []; 1007 for( i = 0, relMarkupLength = relevantMarkupObjects.length; i < relMarkupLength; i++){ 1008 objects[i] = new this.SelectionRange(); 1009 el = relevantMarkupObjects[i]; 1010 if (extendMarkupsAtEnd && !extendMarkupsAtStart) { 1011 objects[i].startContainer = rangeObject.startContainer; // jQuery(el).contents()[0]; 1012 objects[i].startOffset = rangeObject.startOffset; 1013 textnodes = jQuery(el).textNodes(true); 1014 1015 nodeNr = textnodes.length - 1; 1016 objects[i].endContainer = textnodes[ nodeNr ]; 1017 objects[i].endOffset = textnodes[ nodeNr ].length; 1018 objects[i].update(); 1019 this.applyMarkup(objects[i].getSelectionTree(), rangeObject, this.getClonedMarkup4Wrapping(el), tagComparator, {setRangeObject2NewMarkup: true}); 1020 } 1021 if (!extendMarkupsAtEnd && extendMarkupsAtStart) { 1022 textnodes = jQuery(el).textNodes(true); 1023 objects[i].startContainer = textnodes[0]; // jQuery(el).contents()[0]; 1024 objects[i].startOffset = 0; 1025 objects[i].endContainer = rangeObject.endContainer; 1026 objects[i].endOffset = rangeObject.endOffset; 1027 objects[i].update(); 1028 this.applyMarkup(objects[i].getSelectionTree(), rangeObject, this.getClonedMarkup4Wrapping(el), tagComparator, {setRangeObject2NewMarkup: true}); 1029 } 1030 } 1031 return true; 1032 }, 1033 1034 /** 1035 * method creates an empty markup jQuery object from a dom object passed as paramter 1036 * @param domobj domobject to be cloned, cleaned and emptied 1037 * @param tagComparator method, which is used to compare the dom object and the jQuery markup object. the method must accept 2 parameters, the first is the domobj, the second is the jquery object. if no method is specified, the method this.standardTextLevelSemanticsComparator is used 1038 * @return jQuery wrapper object to be passed to e.g. this.applyMarkup(...) 1039 * @hide 1040 */ 1041 getClonedMarkup4Wrapping: function(domobj) { 1042 var wrapper = jQuery(domobj.outerHTML).removeClass('preparedForRemoval').empty(); 1043 if (wrapper.attr('class').length === 0) { 1044 wrapper.removeAttr('class'); 1045 } 1046 return wrapper; 1047 }, 1048 1049 /** 1050 * method used to subtract the range object from existing markup. in other words: certain markup is removed from the selections defined by the rangeObject 1051 * @param relevantMarkupObjects JS Array of dom objects effecting either the start or endContainer of a selection (which should be extended) 1052 * @param rangeObject Aloha rangeObject the markups should be removed from 1053 * @param startOrEnd boolean; defines, if the existing markups should be reduced at the beginning of the tag or at the end (is propably redundant and could be found out by comparing start or end container with the markup array dom objects) 1054 * @param tagComparator method, which is used to compare the dom object and the jQuery markup object. the method must accept 2 parameters, the first is the domobj, the second is the jquery object. if no method is specified, the method this.standardTextLevelSemanticsComparator is used 1055 * @return true 1056 * @hide 1057 */ 1058 insertCroppedMarkups: function(relevantMarkupObjects, rangeObject, startOrEnd, tagComparator) { 1059 var cropMarkupsAtEnd,cropMarkupsAtStart,textnodes,objects,i,el,textNodes; 1060 if (!startOrEnd) { // = Start 1061 // start part of rangeObject should be used, therefor existing markups are cropped at the end 1062 cropMarkupsAtEnd = true; 1063 } else { // = End 1064 // end part of rangeObject should be used, therefor existing markups are cropped at start (beginning) 1065 cropMarkupsAtStart = true; 1066 } 1067 objects = []; 1068 for( i = 0; i<relevantMarkupObjects.length; i++){ 1069 objects[i] = new this.SelectionRange(); 1070 el = relevantMarkupObjects[i]; 1071 if (cropMarkupsAtEnd && !cropMarkupsAtStart) { 1072 textNodes = jQuery(el).textNodes(true); 1073 objects[i].startContainer = textNodes[0]; 1074 objects[i].startOffset = 0; 1075 // if the existing markup startContainer & startOffset are equal to the rangeObject startContainer and startOffset, 1076 // then markupobject does not have to be added again, because it would have no content (zero-length) 1077 if (objects[i].startContainer === rangeObject.startContainer && objects[i].startOffset === rangeObject.startOffset) { 1078 continue; 1079 } 1080 if (rangeObject.startOffset === 0) { 1081 objects[i].endContainer = this.getTextNodeSibling(false, el, rangeObject.startContainer); 1082 objects[i].endOffset = objects[i].endContainer.length; 1083 } else { 1084 objects[i].endContainer = rangeObject.startContainer; 1085 objects[i].endOffset = rangeObject.startOffset; 1086 } 1087 1088 objects[i].update(); 1089 1090 this.applyMarkup(objects[i].getSelectionTree(), rangeObject, this.getClonedMarkup4Wrapping(el), tagComparator, {setRangeObject2NextSibling: true}); 1091 } 1092 1093 if (!cropMarkupsAtEnd && cropMarkupsAtStart) { 1094 objects[i].startContainer = rangeObject.endContainer; // jQuery(el).contents()[0]; 1095 objects[i].startOffset = rangeObject.endOffset; 1096 textnodes = jQuery(el).textNodes(true); 1097 objects[i].endContainer = textnodes[ textnodes.length-1 ]; 1098 objects[i].endOffset = textnodes[ textnodes.length-1 ].length; 1099 objects[i].update(); 1100 this.applyMarkup(objects[i].getSelectionTree(), rangeObject, this.getClonedMarkup4Wrapping(el), tagComparator, {setRangeObject2PreviousSibling: true}); 1101 } 1102 } 1103 return true; 1104 }, 1105 1106 /** 1107 * apply a certain markup to the current selection 1108 * @param markupObject jQuery object of the markup to be applied (e.g. created with obj = jQuery('<b></b>'); ) 1109 * @return void 1110 * @hide 1111 */ 1112 changeMarkupOnSelection: function(markupObject) { 1113 var rangeObject = this.getRangeObject(); 1114 1115 // change the markup 1116 this.changeMarkup(rangeObject, markupObject, this.getStandardTagComparator(markupObject)); 1117 1118 // merge text nodes 1119 GENTICS.Utils.Dom.doCleanup({'merge' : true}, rangeObject); 1120 1121 // update the range and select it 1122 rangeObject.update(); 1123 rangeObject.select(); 1124 this.rangeObject = rangeObject; 1125 }, 1126 1127 /** 1128 * apply a certain markup to the selection Tree 1129 * @param selectionTree SelectionTree Object markup should be applied to 1130 * @param rangeObject Aloha rangeObject which will be modified to reflect the dom changes, after the markup was applied (only if activated via options) 1131 * @param markupObject jQuery object of the markup to be applied (e.g. created with obj = jQuery('<b></b>'); ) 1132 * @param tagComparator method, which is used to compare the dom object and the jQuery markup object. the method must accept 2 parameters, the first is the domobj, the second is the jquery object. if no method is specified, the method this.standardTextLevelSemanticsComparator is used 1133 * @param options JS object, with the following boolean properties: setRangeObject2NewMarkup, setRangeObject2NextSibling, setRangeObject2PreviousSibling 1134 * @return void 1135 * @hide 1136 */ 1137 applyMarkup: function(selectionTree, rangeObject, markupObject, tagComparator, options) { 1138 var optimizedSelectionTree, i, el, breakpoint; 1139 1140 options = options ? options : {}; 1141 // first same tags from within fully selected nodes for removal 1142 this.prepareForRemoval(selectionTree, markupObject, tagComparator); 1143 1144 // first let's optimize the selection Tree in useful groups which can be wrapped together 1145 optimizedSelectionTree = this.optimizeSelectionTree4Markup(selectionTree, markupObject, tagComparator); 1146 breakpoint = true; 1147 1148 // now iterate over grouped elements and either recursively dive into object or wrap it as a whole 1149 for ( i = 0; i < optimizedSelectionTree.length; i++) { 1150 el = optimizedSelectionTree[i]; 1151 if (el.wrappable) { 1152 this.wrapMarkupAroundSelectionTree(el.elements, rangeObject, markupObject, tagComparator, options); 1153 } else { 1154 Aloha.Log.debug(this,'dive further into non-wrappable object'); 1155 this.applyMarkup(el.element.children, rangeObject, markupObject, tagComparator, options); 1156 } 1157 } 1158 }, 1159 1160 /** 1161 * returns the type of the given markup (trying to match HTML5) 1162 * @param markupObject jQuery object of the markup to be applied (e.g. created with obj = jQuery('<b></b>'); ) 1163 * @return string name of the markup type 1164 * @hide 1165 */ 1166 getMarkupType: function(markupObject) { 1167 var nn = jQuery(markupObject)[0].nodeName.toLowerCase(); 1168 if (markupObject.outerHtml) { 1169 Aloha.Log.debug(this, 'Node name detected: ' + nn + ' for: ' + markupObject.outerHtml()); 1170 } 1171 if (nn == '#text') {return 'textNode';} 1172 if (this.replacingElements[ nn ]) {return 'sectionOrGroupingContent';} 1173 if (this.tagHierarchy [ nn ]) {return 'textLevelSemantics';} 1174 Aloha.Log.warn(this, 'unknown markup passed to this.getMarkupType(...): ' + markupObject.outerHtml()); 1175 }, 1176 1177 /** 1178 * returns the standard tag comparator for the given markup object 1179 * @param markupObject jQuery object of the markup to be applied (e.g. created with obj = jQuery('<b></b>'); ) 1180 * @return function tagComparator method, which is used to compare the dom object and the jQuery markup object. the method must accept 2 parameters, the first is the domobj, the second is the jquery object. if no method is specified, the method this.standardTextLevelSemanticsComparator is used 1181 * @hide 1182 */ 1183 getStandardTagComparator: function(markupObject) { 1184 var that = this, result; 1185 switch(this.getMarkupType(markupObject)) { 1186 case 'textNode': 1187 result = function(p1, p2) { 1188 return false; 1189 }; 1190 break; 1191 1192 case 'sectionOrGroupingContent': 1193 result = function(domobj, markupObject) { 1194 return that.standardSectionsAndGroupingContentComparator(domobj, markupObject); 1195 }; 1196 break; 1197 1198 case 'textLevelSemantics': 1199 /* falls through */ 1200 default: 1201 result = function(domobj, markupObject) { 1202 return that.standardTextLevelSemanticsComparator(domobj, markupObject); 1203 }; 1204 break; 1205 } 1206 return result; 1207 }, 1208 1209 /** 1210 * searches for fully selected equal markup tags 1211 * @param selectionTree SelectionTree Object markup should be applied to 1212 * @param markupObject jQuery object of the markup to be applied (e.g. created with obj = jQuery('<b></b>'); ) 1213 * @param tagComparator method, which is used to compare the dom object and the jQuery markup object. the method must accept 2 parameters, the first is the domobj, the second is the jquery object. if no method is specified, the method this.standardTextLevelSemanticsComparator is used 1214 * @return void 1215 * @hide 1216 */ 1217 prepareForRemoval: function(selectionTree, markupObject, tagComparator) { 1218 var that = this, i, el; 1219 1220 // check if a comparison method was passed as parameter ... 1221 if (typeof tagComparator !== 'undefined' && typeof tagComparator !== 'function') { 1222 Aloha.Log.error(this,'parameter tagComparator is not a function'); 1223 } 1224 // ... if not use this as standard tag comparison method 1225 if (typeof tagComparator === 'undefined') { 1226 tagComparator = this.getStandardTagComparator(markupObject); 1227 } 1228 for ( i = 0; i<selectionTree.length; i++) { 1229 el = selectionTree[i]; 1230 if (el.domobj && (el.selection == 'full' || (el.selection == 'partial' && markupObject.isReplacingElement))) { 1231 // mark for removal 1232 if (el.domobj.nodeType === 1 && tagComparator(el.domobj, markupObject)) { 1233 Aloha.Log.debug(this, 'Marking for removal: ' + el.domobj.nodeName); 1234 jQuery(el.domobj).addClass('preparedForRemoval'); 1235 } 1236 } 1237 if (el.selection != 'none' && el.children.length > 0) { 1238 this.prepareForRemoval(el.children, markupObject, tagComparator); 1239 } 1240 1241 } 1242 }, 1243 1244 /** 1245 * searches for fully selected equal markup tags 1246 * @param selectionTree SelectionTree Object markup should be applied to 1247 * @param rangeObject Aloha rangeObject the markup will be applied to 1248 * @param markupObject jQuery object of the markup to be applied (e.g. created with obj = jQuery('<b></b>'); ) 1249 * @param tagComparator method, which is used to compare the dom object and the jQuery markup object. the method must accept 2 parameters, the first is the domobj, the second is the jquery object. if no method is specified, the method this.standardTextLevelSemanticsComparator is used 1250 * @param options JS object, with the following boolean properties: setRangeObject2NewMarkup, setRangeObject2NextSibling, setRangeObject2PreviousSibling 1251 * @return void 1252 * @hide 1253 */ 1254 wrapMarkupAroundSelectionTree: function(selectionTree, rangeObject, markupObject, tagComparator, options) { 1255 // first let's find out if theoretically the whole selection can be wrapped with one tag and save it for later use 1256 var objects2wrap = [], // // this will be used later to collect objects 1257 j = -1, // internal counter, 1258 breakpoint = true, 1259 preText = '', 1260 postText = '', 1261 prevOrNext, 1262 textNode2Start, 1263 textnodes, 1264 newMarkup, 1265 i, el, middleText; 1266 1267 1268 1269 Aloha.Log.debug(this,'The formatting <' + markupObject[0].tagName + '> will be wrapped around the selection'); 1270 1271 // now lets iterate over the elements 1272 for (i = 0; i < selectionTree.length; i++) { 1273 el = selectionTree[i]; 1274 1275 // check if markup is allowed inside the elements parent 1276 if (el.domobj && !this.canTag1WrapTag2(el.domobj.parentNode.tagName.toLowerCase(), markupObject[0].tagName.toLowerCase())) { 1277 Aloha.Log.info(this,'Skipping the wrapping of <' + markupObject[0].tagName.toLowerCase() + '> because this tag is not allowed inside <' + el.domobj.parentNode.tagName.toLowerCase() + '>'); 1278 continue; 1279 } 1280 1281 // skip empty text nodes 1282 if (el.domobj && el.domobj.nodeType === 3 && jQuery.trim(el.domobj.nodeValue).length === 0) { 1283 continue; 1284 } 1285 1286 // partial element, can either be a textnode and therefore be wrapped (at least partially) 1287 // or can be a nodeType == 1 (tag) which must be dived into 1288 if (el.domobj && el.selection == 'partial' && !markupObject.isReplacingElement) { 1289 if (el.startOffset !== undefined && el.endOffset === undefined) { 1290 j++; 1291 preText += el.domobj.data.substr(0,el.startOffset); 1292 el.domobj.data = el.domobj.data.substr(el.startOffset, el.domobj.data.length-el.startOffset); 1293 objects2wrap[j] = el.domobj; 1294 } else if (el.endOffset !== undefined && el.startOffset === undefined) { 1295 j++; 1296 postText += el.domobj.data.substr(el.endOffset, el.domobj.data.length-el.endOffset); 1297 el.domobj.data = el.domobj.data.substr(0, el.endOffset); 1298 objects2wrap[j] = el.domobj; 1299 } else if (el.endOffset !== undefined && el.startOffset !== undefined) { 1300 if (el.startOffset == el.endOffset) { // do not wrap empty selections 1301 Aloha.Log.debug(this, 'skipping empty selection'); 1302 continue; 1303 } 1304 j++; 1305 preText += el.domobj.data.substr(0,el.startOffset); 1306 middleText = el.domobj.data.substr(el.startOffset,el.endOffset-el.startOffset); 1307 postText += el.domobj.data.substr(el.endOffset, el.domobj.data.length-el.endOffset); 1308 el.domobj.data = middleText; 1309 objects2wrap[j] = el.domobj; 1310 } else { 1311 // a partially selected item without selectionStart/EndOffset is a nodeType 1 Element on the way to the textnode 1312 Aloha.Log.debug(this, 'diving into object'); 1313 this.applyMarkup(el.children, rangeObject, markupObject, tagComparator, options); 1314 } 1315 1316 } 1317 // fully selected dom elements can be wrapped as whole element 1318 if (el.domobj && (el.selection == 'full' || (el.selection == 'partial' && markupObject.isReplacingElement))) { 1319 j++; 1320 objects2wrap[j] = el.domobj; 1321 } 1322 } 1323 1324 if (objects2wrap.length > 0) { 1325 // wrap collected DOM object with markupObject 1326 objects2wrap = jQuery(objects2wrap); 1327 1328 // make a fix for text nodes in <li>'s in ie 1329 jQuery.each(objects2wrap, function(index, element) { 1330 if (jQuery.browser.msie && element.nodeType == 3 1331 && !element.nextSibling && !element.previousSibling 1332 && element.parentNode 1333 && element.parentNode.nodeName.toLowerCase() == 'li') { 1334 element.data = jQuery.trim(element.data); 1335 } 1336 }); 1337 1338 newMarkup = objects2wrap.wrapAll(markupObject).parent(); 1339 newMarkup.before(preText).after(postText); 1340 1341 if (options.setRangeObject2NewMarkup) { // this is used, when markup is added to normal/normal Text 1342 textnodes = objects2wrap.textNodes(); 1343 1344 if (textnodes.index(rangeObject.startContainer) != -1) { 1345 rangeObject.startOffset = 0; 1346 } 1347 if (textnodes.index(rangeObject.endContainer) != -1) { 1348 rangeObject.endOffset = rangeObject.endContainer.length; 1349 } 1350 breakpoint=true; 1351 } 1352 if (options.setRangeObject2NextSibling){ 1353 prevOrNext = true; 1354 textNode2Start = newMarkup.textNodes(true).last()[0]; 1355 if (objects2wrap.index(rangeObject.startContainer) != -1) { 1356 rangeObject.startContainer = this.getTextNodeSibling(prevOrNext, newMarkup.parent(), textNode2Start); 1357 rangeObject.startOffset = 0; 1358 } 1359 if (objects2wrap.index(rangeObject.endContainer) != -1) { 1360 rangeObject.endContainer = this.getTextNodeSibling(prevOrNext, newMarkup.parent(), textNode2Start); 1361 rangeObject.endOffset = rangeObject.endOffset - textNode2Start.length; 1362 } 1363 } 1364 if (options.setRangeObject2PreviousSibling){ 1365 prevOrNext = false; 1366 textNode2Start = newMarkup.textNodes(true).first()[0]; 1367 if (objects2wrap.index(rangeObject.startContainer) != -1) { 1368 rangeObject.startContainer = this.getTextNodeSibling(prevOrNext, newMarkup.parent(), textNode2Start); 1369 rangeObject.startOffset = 0; 1370 } 1371 if (objects2wrap.index(rangeObject.endContainer) != -1) { 1372 rangeObject.endContainer = this.getTextNodeSibling(prevOrNext, newMarkup.parent(), textNode2Start); 1373 rangeObject.endOffset = rangeObject.endContainer.length; 1374 } 1375 } 1376 } 1377 }, 1378 1379 /** 1380 * takes a text node and return either the next recursive text node sibling or the previous 1381 * @param previousOrNext boolean, false for previous, true for next sibling 1382 * @param commonAncestorContainer dom object to be used as root for the sibling search 1383 * @param currentTextNode dom object of the originating text node 1384 * @return dom object of the sibling text node 1385 * @hide 1386 */ 1387 getTextNodeSibling: function(previousOrNext, commonAncestorContainer, currentTextNode) { 1388 var textNodes = jQuery(commonAncestorContainer).textNodes(true), 1389 newIndex, index; 1390 1391 index = textNodes.index(currentTextNode); 1392 if (index == -1) { // currentTextNode was not found 1393 return false; 1394 } 1395 newIndex = index + (!previousOrNext ? -1 : 1); 1396 return textNodes[newIndex] ? textNodes[newIndex] : false; 1397 }, 1398 1399 /** 1400 * takes a selection tree and groups it into markup wrappable selection trees 1401 * @param selectionTree rangeObject selection tree 1402 * @param markupObject jQuery object of the markup to be applied (e.g. created with obj = jQuery('<b></b>'); ) 1403 * @return JS array of wrappable selection trees 1404 * @hide 1405 */ 1406 optimizeSelectionTree4Markup: function(selectionTree, markupObject, tagComparator) { 1407 var groupMap = [], 1408 outerGroupIndex = 0, 1409 innerGroupIndex = 0, 1410 that = this, 1411 i,j, 1412 endPosition, startPosition; 1413 1414 if (typeof tagComparator === 'undefined') { 1415 tagComparator = function(domobj, markupObject) { 1416 return that.standardTextLevelSemanticsComparator(markupObject); 1417 }; 1418 } 1419 for( i = 0; i<selectionTree.length; i++) { 1420 // we are just interested in selected item, but not in non-selected items 1421 if (selectionTree[i].domobj && selectionTree[i].selection != 'none') { 1422 if (markupObject.isReplacingElement && tagComparator(markupObject[0], jQuery(selectionTree[i].domobj))) { 1423 if (groupMap[outerGroupIndex] !== undefined) { 1424 outerGroupIndex++; 1425 } 1426 groupMap[outerGroupIndex] = {}; 1427 groupMap[outerGroupIndex].wrappable = true; 1428 groupMap[outerGroupIndex].elements = []; 1429 groupMap[outerGroupIndex].elements[innerGroupIndex] = selectionTree[i]; 1430 outerGroupIndex++; 1431 1432 } else 1433 // now check, if the children of our item could be wrapped all together by the markup object 1434 if (this.canMarkupBeApplied2ElementAsWhole([ selectionTree[i] ], markupObject)) { 1435 // if yes, add it to the current group 1436 if (groupMap[outerGroupIndex] === undefined) { 1437 groupMap[outerGroupIndex] = {}; 1438 groupMap[outerGroupIndex].wrappable = true; 1439 groupMap[outerGroupIndex].elements = []; 1440 } 1441 if (markupObject.isReplacingElement) { // && selectionTree[i].domobj.nodeType === 3 1442 /* we found the node to wrap for a replacing element. however there might 1443 * be siblings which should be included as well 1444 * although they are actually not selected. example: 1445 * li 1446 * |-textNode ( .selection = 'none') 1447 * |-textNode (cursor inside, therefor .selection = 'partial') 1448 * |-textNode ( .selection = 'none') 1449 * 1450 * in this case it would be useful to select the previous and following textNodes as well (they might result from a previous DOM manipulation) 1451 * Think about other cases, where the parent is the Editable. In this case we propably only want to select from and until the next <br /> ?? 1452 * .... many possibilities, here I realize the two described cases 1453 */ 1454 1455 // first find start element starting from the current element going backwards until sibling 0 1456 startPosition = i; 1457 for (j = i-1; j >= 0; j--) { 1458 if (this.canMarkupBeApplied2ElementAsWhole([ selectionTree[ j ] ], markupObject) && this.isMarkupAllowedToStealSelectionTreeElement(selectionTree[ j ], markupObject)) { 1459 startPosition = j; 1460 } else { 1461 break; 1462 } 1463 } 1464 1465 // now find the end element starting from the current element going forward until the last sibling 1466 endPosition = i; 1467 for (j = i+1; j < selectionTree.length; j++) { 1468 if (this.canMarkupBeApplied2ElementAsWhole([ selectionTree[ j ] ], markupObject) && this.isMarkupAllowedToStealSelectionTreeElement(selectionTree[ j ], markupObject)) { 1469 endPosition = j; 1470 } else { 1471 break; 1472 } 1473 } 1474 1475 // now add the elements to the groupMap 1476 innerGroupIndex = 0; 1477 for (j = startPosition; j <= endPosition; j++) { 1478 groupMap[outerGroupIndex].elements[innerGroupIndex] = selectionTree[j]; 1479 groupMap[outerGroupIndex].elements[innerGroupIndex].selection = 'full'; 1480 innerGroupIndex++; 1481 } 1482 innerGroupIndex = 0; 1483 } else { 1484 // normal text level semantics object, no siblings need to be selected 1485 groupMap[outerGroupIndex].elements[innerGroupIndex] = selectionTree[i]; 1486 innerGroupIndex++; 1487 } 1488 } else { 1489 // if no, isolate it in its own group 1490 if (groupMap[outerGroupIndex] !== undefined) { 1491 outerGroupIndex++; 1492 } 1493 groupMap[outerGroupIndex] = {}; 1494 groupMap[outerGroupIndex].wrappable = false; 1495 groupMap[outerGroupIndex].element = selectionTree[i]; 1496 innerGroupIndex = 0; 1497 outerGroupIndex++; 1498 } 1499 } 1500 } 1501 return groupMap; 1502 }, 1503 1504 /** 1505 * very tricky method, which decides, if a certain markup (normally a replacing markup element like p, h1, blockquote) 1506 * is allowed to extend the user selection to other dom objects (represented as selectionTreeElement) 1507 * to understand the purpose: if the user selection is collapsed inside e.g. some text, which is currently not 1508 * wrapped by the markup to be applied, and therefor the markup does not have an equal markup to replace, then the DOM 1509 * manipulator has to decide which objects to wrap. real example: 1510 * <div> 1511 * <h1>headline</h1> 1512 * some text blabla bla<br> 1513 * more text HERE THE | CURSOR BLINKING and <b>even more bold text</b> 1514 * </div> 1515 * when the user now wants to apply e.g. a <p> tag, what will be wrapped? it could be useful if the manipulator would actually 1516 * wrap everything inside the div except the <h1>. but for this purpose someone has to decide, if the markup is 1517 * allowed to wrap certain dom elements in this case the question would be, if the <p> is allowed to wrap 1518 * textNodes, <br> and <b> and <h1>. therefore this tricky method should answer the question for those 3 elements 1519 * with true, but for for the <h1> it should return false. and since the method does not know this, there is a configuration 1520 * for this 1521 * 1522 * @param selectionTree rangeObject selection tree element (only one, not an array of) 1523 * @param markupObject lowercase string of the tag to be verified (e.g. "b") 1524 * @return true if the markup is allowed to wrap the selection tree element, false otherwise 1525 * @hide 1526 */ 1527 isMarkupAllowedToStealSelectionTreeElement: function(selectionTreeElement, markupObject) { 1528 if (!selectionTreeElement.domobj) { 1529 return false; 1530 } 1531 var maybeTextNodeName = selectionTreeElement.domobj.nodeName.toLowerCase(), 1532 nodeName = (maybeTextNodeName == '#text') ? 'textNode' : maybeTextNodeName, 1533 markupName = markupObject[0].nodeName.toLowerCase(), 1534 elemMap = this.allowedToStealElements[ markupName ]; 1535 return elemMap && elemMap[nodeName]; 1536 }, 1537 1538 /** 1539 * checks if a selection can be completey wrapped by a certain html tags (helper method for this.optimizeSelectionTree4Markup 1540 * @param selectionTree rangeObject selection tree 1541 * @param markupObject lowercase string of the tag to be verified (e.g. "b") 1542 * @return true if selection can be applied as whole, false otherwise 1543 * @hide 1544 */ 1545 canMarkupBeApplied2ElementAsWhole: function(selectionTree, markupObject) { 1546 var htmlTag, i, el, returnVal; 1547 1548 if (markupObject.jquery) { 1549 htmlTag = markupObject[0].tagName; 1550 } 1551 if (markupObject.tagName) { 1552 htmlTag = markupObject.tagName; 1553 } 1554 1555 returnVal = true; 1556 for ( i = 0; i < selectionTree.length; i++) { 1557 el = selectionTree[i]; 1558 if (el.domobj && (el.selection != "none" || markupObject.isReplacingElement)) { 1559 // Aloha.Log.debug(this, 'Checking, if <' + htmlTag + '> can be applied to ' + el.domobj.nodeName); 1560 if (!this.canTag1WrapTag2(htmlTag, el.domobj.nodeName)) { 1561 return false; 1562 } 1563 if (el.children.length > 0 && !this.canMarkupBeApplied2ElementAsWhole(el.children, markupObject)) { 1564 return false; 1565 } 1566 } 1567 } 1568 return returnVal; 1569 }, 1570 1571 /** 1572 * checks if a tag 1 (first parameter) can wrap tag 2 (second parameter). 1573 * IMPORTANT: the method does not verify, if there have to be other tags in between 1574 * Example: this.canTag1WrapTag2("table", "td") will return true, because the method does not take into account, that there has to be a "tr" in between 1575 1576 * @param t1 string: tagname of outer tag to verify, e.g. "b" 1577 * @param t2 string: tagname of inner tag to verify, e.g. "b" 1578 * @return true if tag 1 can wrap tag 2, false otherwise 1579 * @hide 1580 */ 1581 canTag1WrapTag2: function(t1, t2) { 1582 t1 = (t1 == '#text')?'textNode':t1.toLowerCase(); 1583 t2 = (t2 == '#text')?'textNode':t2.toLowerCase(); 1584 var t1Map = this.tagHierarchy[t1]; 1585 if (!t1Map) { 1586 return true; 1587 } 1588 if (!this.tagHierarchy[t2]) { 1589 return true; 1590 } 1591 return t1Map[t2]; 1592 }, 1593 1594 /** 1595 * Check whether it is allowed to insert the given tag at the start of the 1596 * current selection. This method will check whether the markup effective for 1597 * the start and outside of the editable part (starting with the editable tag 1598 * itself) may wrap the given tag. 1599 * @param tagName {String} name of the tag which shall be inserted 1600 * @return true when it is allowed to insert that tag, false if not 1601 * @hide 1602 */ 1603 mayInsertTag: function (tagName) { 1604 if (typeof this.rangeObject.unmodifiableMarkupAtStart == 'object') { 1605 // iterate over all DOM elements outside of the editable part 1606 for (var i = 0; i < this.rangeObject.unmodifiableMarkupAtStart.length; ++i) { 1607 // check whether an element may not wrap the given 1608 if (!this.canTag1WrapTag2(this.rangeObject.unmodifiableMarkupAtStart[i].nodeName, tagName)) { 1609 // found a DOM element which forbids to insert the given tag, we are done 1610 return false; 1611 } 1612 } 1613 1614 // all of the found DOM elements allow inserting the given tag 1615 return true; 1616 } else { 1617 Aloha.Log.warn(this, 'Unable to determine whether tag ' + tagName + ' may be inserted'); 1618 return true; 1619 } 1620 }, 1621 1622 /** 1623 * String representation 1624 * @return "Aloha.Selection" 1625 * @hide 1626 */ 1627 toString: function() { 1628 return 'Aloha.Selection'; 1629 }, 1630 1631 /** 1632 * @namespace Aloha.Selection 1633 * @class SelectionRange 1634 * @extends GENTICS.Utils.RangeObject 1635 * Constructor for a range object. 1636 * Optionally you can pass in a range object that's properties will be assigned to the new range object. 1637 * @param rangeObject A range object thats properties will be assigned to the new range object. 1638 * @constructor 1639 */ 1640 SelectionRange: GENTICS.Utils.RangeObject.extend({ 1641 _constructor: function(rangeObject){ 1642 this._super(rangeObject); 1643 // If a range object was passed in we apply the values to the new range object 1644 if (rangeObject) { 1645 if (rangeObject.commonAncestorContainer) { 1646 this.commonAncestorContainer = rangeObject.commonAncestorContainer; 1647 } 1648 if (rangeObject.selectionTree) { 1649 this.selectionTree = rangeObject.selectionTree; 1650 } 1651 if (rangeObject.limitObject) { 1652 this.limitObject = rangeObject.limitObject; 1653 } 1654 if (rangeObject.markupEffectiveAtStart) { 1655 this.markupEffectiveAtStart = rangeObject.markupEffectiveAtStart; 1656 } 1657 if (rangeObject.unmodifiableMarkupAtStart) { 1658 this.unmodifiableMarkupAtStart = rangeObject.unmodifiableMarkupAtStart; 1659 } 1660 if (rangeObject.splitObject) { 1661 this.splitObject = rangeObject.splitObject; 1662 } 1663 } 1664 }, 1665 1666 /** 1667 * DOM object of the common ancestor from startContainer and endContainer 1668 * @hide 1669 */ 1670 commonAncestorContainer: undefined, 1671 1672 /** 1673 * The selection tree 1674 * @hide 1675 */ 1676 selectionTree: undefined, 1677 1678 /** 1679 * Array of DOM objects effective for the start container and inside the 1680 * editable part (inside the limit object). relevant for the button status 1681 * @hide 1682 */ 1683 markupEffectiveAtStart: [], 1684 1685 /** 1686 * Array of DOM objects effective for the start container, which lies 1687 * outside of the editable portion (starting with the limit object) 1688 * @hide 1689 */ 1690 unmodifiableMarkupAtStart: [], 1691 1692 /** 1693 * DOM object being the limit for all markup relevant activities 1694 * @hide 1695 */ 1696 limitObject: undefined, 1697 1698 /** 1699 * DOM object being split when enter key gets hit 1700 * @hide 1701 */ 1702 splitObject: undefined, 1703 1704 /** 1705 * Sets the visible selection in the Browser based on the range object. 1706 * If the selection is collapsed, this will result in a blinking cursor, 1707 * otherwise in a text selection. 1708 * @method 1709 */ 1710 select: function() { 1711 // Call Utils' select() 1712 this._super(); 1713 1714 // update the selection 1715 Aloha.Selection.updateSelection(); 1716 }, 1717 1718 /** 1719 * Method to update a range object internally 1720 * @param commonAncestorContainer (DOM Object); optional Parameter; if set, the parameter 1721 * will be used instead of the automatically calculated CAC 1722 * @return void 1723 * @hide 1724 */ 1725 update: function(commonAncestorContainer) { 1726 this.updatelimitObject(); 1727 this.updateMarkupEffectiveAtStart(); 1728 this.updateCommonAncestorContainer(commonAncestorContainer); 1729 1730 // reset the selectiontree (must be recalculated) 1731 this.selectionTree = undefined; 1732 }, 1733 1734 /** 1735 * Get the selection tree for this range 1736 * TODO: remove this (was moved to range.js) 1737 * @return selection tree 1738 * @hide 1739 */ 1740 getSelectionTree: function () { 1741 // if not yet calculated, do this now 1742 if (!this.selectionTree) { 1743 this.selectionTree = Aloha.Selection.getSelectionTree(this); 1744 } 1745 1746 return this.selectionTree; 1747 }, 1748 1749 /** 1750 * TODO: move this to range.js 1751 * Get an array of domobj (in dom tree order) of siblings of the given domobj, which are contained in the selection 1752 * @param domobj dom object to start with 1753 * @return array of siblings of the given domobj, which are also selected 1754 * @hide 1755 */ 1756 1757 getSelectedSiblings: function (domobj) { 1758 var selectionTree = this.getSelectionTree(); 1759 1760 return this.recursionGetSelectedSiblings(domobj, selectionTree); 1761 }, 1762 1763 /** 1764 * TODO: move this to range.js 1765 * Recursive method to find the selected siblings of the given domobj (which should be selected as well) 1766 * @param domobj dom object for which the selected siblings shall be found 1767 * @param selectionTree current level of the selection tree 1768 * @return array of selected siblings of dom objects or false if none found 1769 * @hide 1770 */ 1771 recursionGetSelectedSiblings: function (domobj, selectionTree) { 1772 var selectedSiblings = false, 1773 foundObj = false, 1774 i; 1775 1776 for ( i = 0; i < selectionTree.length; ++i) { 1777 if (selectionTree[i].domobj === domobj) { 1778 foundObj = true; 1779 selectedSiblings = []; 1780 } else if (!foundObj && selectionTree[i].children) { 1781 // do the recursion 1782 selectedSiblings = this.recursionGetSelectedSiblings(domobj, selectionTree[i].children); 1783 if (selectedSiblings !== false) { 1784 break; 1785 } 1786 } else if (foundObj && selectionTree[i].domobj && selectionTree[i].selection != 'collapsed' && selectionTree[i].selection != 'none') { 1787 selectedSiblings.push(selectionTree[i].domobj); 1788 } else if (foundObj && selectionTree[i].selection == 'none') { 1789 break; 1790 } 1791 } 1792 1793 return selectedSiblings; 1794 }, 1795 1796 /** 1797 * TODO: move this to range.js 1798 * Method updates member var markupEffectiveAtStart and splitObject, which is relevant primarily for button status and enter key behaviour 1799 * @return void 1800 * @hide 1801 */ 1802 updateMarkupEffectiveAtStart: function() { 1803 // reset the current markup 1804 this.markupEffectiveAtStart = []; 1805 this.unmodifiableMarkupAtStart = []; 1806 1807 var 1808 parents = this.getStartContainerParents(), 1809 limitFound = false, 1810 splitObjectWasSet, 1811 i, el; 1812 1813 for ( i = 0; i < parents.length; i++) { 1814 el = parents[i]; 1815 if (!limitFound && (el !== this.limitObject)) { 1816 this.markupEffectiveAtStart[ i ] = el; 1817 if (!splitObjectWasSet && GENTICS.Utils.Dom.isSplitObject(el)) { 1818 splitObjectWasSet = true; 1819 this.splitObject = el; 1820 } 1821 } else { 1822 limitFound = true; 1823 this.unmodifiableMarkupAtStart.push(el); 1824 } 1825 } 1826 if (!splitObjectWasSet) { 1827 this.splitObject = false; 1828 } 1829 return; 1830 }, 1831 1832 /** 1833 * TODO: remove this 1834 * Method updates member var markupEffectiveAtStart, which is relevant primarily for button status 1835 * @return void 1836 * @hide 1837 */ 1838 updatelimitObject: function() { 1839 if (Aloha.editables && Aloha.editables.length > 0) { 1840 var parents = this.getStartContainerParents(), 1841 editables = Aloha.editables, 1842 i, el, j, editable; 1843 for ( i = 0; i < parents.length; i++) { 1844 el = parents[i]; 1845 for ( j = 0; j < editables.length; j++) { 1846 editable = editables[j].obj[0]; 1847 if (el === editable) { 1848 this.limitObject = el; 1849 return true; 1850 } 1851 } 1852 } 1853 } 1854 this.limitObject = jQuery('body'); 1855 return true; 1856 }, 1857 1858 /** 1859 * string representation of the range object 1860 * @param verbose set to true for verbose output 1861 * @return string representation of the range object 1862 * @hide 1863 */ 1864 toString: function(verbose) { 1865 if (!verbose) { 1866 return 'Aloha.Selection.SelectionRange'; 1867 } 1868 return 'Aloha.Selection.SelectionRange {start [' + this.startContainer.nodeValue + '] offset ' 1869 + this.startOffset + ', end [' + this.endContainer.nodeValue + '] offset ' + this.endOffset + '}'; 1870 } 1871 1872 }) // SelectionRange 1873 1874 }); // Selection 1875 1876 1877 /** 1878 * This method implements an ugly workaround for a selection problem in ie: 1879 * when the cursor shall be placed at the end of a text node in a li element, that is followed by a nested list, 1880 * the selection would always snap into the first li of the nested list 1881 * therefore, we make sure that the text node ends with a space and place the cursor right before it 1882 */ 1883 function nestedListInIEWorkaround ( range ) { 1884 var nextSibling; 1885 if (jQuery.browser.msie 1886 && range.startContainer === range.endContainer 1887 && range.startOffset === range.endOffset 1888 && range.startContainer.nodeType == 3 1889 && range.startOffset == range.startContainer.data.length 1890 && range.startContainer.nextSibling) { 1891 nextSibling = range.startContainer.nextSibling; 1892 if ('OL' === nextSibling.nodeName || 'UL' === nextSibling.nodeName) { 1893 if (range.startContainer.data[range.startContainer.data.length-1] == ' ') { 1894 range.startOffset = range.endOffset = range.startOffset-1; 1895 } else { 1896 range.startContainer.data = range.startContainer.data + ' '; 1897 } 1898 } 1899 } 1900 } 1901 1902 function correctRange ( range ) { 1903 nestedListInIEWorkaround(range); 1904 return range; 1905 } 1906 1907 /** 1908 * Implements Selection http://html5.org/specs/dom-range.html#selection 1909 * @namespace Aloha 1910 * @class Selection This singleton class always represents the 1911 * current user selection 1912 * @singleton 1913 */ 1914 var AlohaSelection = Class.extend({ 1915 1916 _constructor : function( nativeSelection ) { 1917 1918 this._nativeSelection = nativeSelection; 1919 this.ranges = []; 1920 1921 // will remember if urged to not change the selection 1922 this.preventChange = false; 1923 1924 }, 1925 1926 /** 1927 * Returns the element that contains the start of the selection. Returns null if there's no selection. 1928 * @readonly 1929 * @type Node 1930 */ 1931 anchorNode: null, 1932 1933 /** 1934 * Returns the offset of the start of the selection relative to the element that contains the start 1935 * of the selection. Returns 0 if there's no selection. 1936 * @readonly 1937 * @type int 1938 */ 1939 anchorOffset: 0, 1940 1941 /** 1942 * Returns the element that contains the end of the selection. 1943 * Returns null if there's no selection. 1944 * @readonly 1945 * @type Node 1946 */ 1947 1948 focusNode: null, 1949 1950 /** 1951 * Returns the offset of the end of the selection relative to the element that contains the end 1952 * of the selection. Returns 0 if there's no selection. 1953 * @readonly 1954 * @type int 1955 */ 1956 focusOffset: 0, 1957 1958 /** 1959 * Returns true if there's no selection or if the selection is empty. Otherwise, returns false. 1960 * @readonly 1961 * @type boolean 1962 */ 1963 isCollapsed: false, 1964 1965 /** 1966 * Returns the number of ranges in the selection. 1967 * @readonly 1968 * @type int 1969 */ 1970 rangeCount: 0, 1971 1972 /** 1973 * Replaces the selection with an empty one at the given position. 1974 * @throws a WRONG_DOCUMENT_ERR exception if the given node is in a different document. 1975 * @param parentNode Node of new selection 1976 * @param offest offest of new Selection in parentNode 1977 * @void 1978 */ 1979 collapse: function ( parentNode, offset ) { 1980 this._nativeSelection.collapse( parentNode, offset ); 1981 }, 1982 1983 /** 1984 * Replaces the selection with an empty one at the position of the start of the current selection. 1985 * @throws an INVALID_STATE_ERR exception if there is no selection. 1986 * @void 1987 */ 1988 collapseToStart: function() { 1989 throw "NOT_IMPLEMENTED"; 1990 }, 1991 1992 /** 1993 * @void 1994 */ 1995 extend: function ( parentNode, offset) { 1996 1997 }, 1998 1999 /** 2000 * @param alter DOMString 2001 * @param direction DOMString 2002 * @param granularity DOMString 2003 * @void 2004 */ 2005 modify: function ( alter, direction, granularity ) { 2006 2007 }, 2008 2009 /** 2010 * Replaces the selection with an empty one at the position of the end of the current selection. 2011 * @throws an INVALID_STATE_ERR exception if there is no selection. 2012 * @void 2013 */ 2014 collapseToEnd: function() { 2015 throw "NOT_IMPLEMENTED"; 2016 }, 2017 2018 /** 2019 * Replaces the selection with one that contains all the contents of the given element. 2020 * @throws a WRONG_DOCUMENT_ERR exception if the given node is in a different document. 2021 * @param parentNode Node the Node fully select 2022 * @void 2023 */ 2024 selectAllChildren: function( parentNode ) { 2025 throw "NOT_IMPLEMENTED"; 2026 }, 2027 2028 /** 2029 * Deletes the contents of the selection 2030 */ 2031 deleteFromDocument: function() { 2032 throw "NOT_IMPLEMENTED"; 2033 }, 2034 2035 /** 2036 * NB! 2037 * We have serious problem in IE. 2038 * The range that we get in IE is not the same as the range we had set, 2039 * so even if we normalize it during getRangeAt, in IE, we will be 2040 * correcting the range to the "correct" place, but still not the place 2041 * where it was originally set. 2042 * 2043 * Returns the given range. 2044 * The getRangeAt(index) method returns the indexth range in the list. 2045 * NOTE: Aloha Editor only support 1 range! index can only be 0 2046 * @throws INDEX_SIZE_ERR DOM exception if index is less than zero or 2047 * greater or equal to the value returned by the rangeCount. 2048 * @param index int 2049 * @return Range return the selected range from index 2050 */ 2051 getRangeAt: function ( index ) { 2052 return correctRange( this._nativeSelection.getRangeAt( index ) ); 2053 //if ( index < 0 || this.rangeCount ) { 2054 // throw "INDEX_SIZE_ERR DOM"; 2055 //} 2056 //return this._ranges[index]; 2057 }, 2058 2059 /** 2060 * Adds the given range to the selection. 2061 * The addRange(range) method adds the given range Range object to the list of 2062 * selections, at the end (so the newly added range is the new last range). 2063 * NOTE: Aloha Editor only support 1 range! The added range will replace the 2064 * range at index 0 2065 * see http://html5.org/specs/dom-range.html#selection note about addRange 2066 * @throws an INVALID_NODE_TYPE_ERR exception if the given Range has a boundary point 2067 * node that's not a Text or Element node, and an INVALID_MODIFICATION_ERR exception 2068 * if it has a boundary point node that doesn't descend from a Document. 2069 * @param range Range adds the range to the selection 2070 * @void 2071 */ 2072 addRange: function( range ) { 2073 // set readonly attributes 2074 this._nativeSelection.addRange( range ); 2075 // We will correct the range after rangy has processed the native 2076 // selection range, so that our correction will be the final fix on 2077 // the range according to the guarentee's that Aloha wants to make 2078 this._nativeSelection._ranges[ 0 ] = correctRange( range ); 2079 2080 // make sure, the old Aloha selection will be updated (until all implementations use the new AlohaSelection) 2081 Aloha.Selection.updateSelection(); 2082 }, 2083 2084 /** 2085 * Removes the given range from the selection, if the range was one of the ones in the selection. 2086 * NOTE: Aloha Editor only support 1 range! The added range will replace the 2087 * range at with index 0 2088 * @param range Range removes the range from the selection 2089 * @void 2090 */ 2091 removeRange: function( range ) { 2092 this._nativeSelection.removeRange(); 2093 }, 2094 2095 /** 2096 * Removes all the ranges in the selection. 2097 * @viod 2098 */ 2099 removeAllRanges: function() { 2100 this._nativeSelection.removeAllRanges(); 2101 }, 2102 2103 /** 2104 * INFO: Method is used for integration with Gentics 2105 * Aloha, has no use otherwise Updates the rangeObject 2106 * according to the current user selection Method is 2107 * always called on selection change 2108 * 2109 * @param event 2110 * jQuery browser event object 2111 * @return true when rangeObject was modified, false 2112 * otherwise 2113 * @hide 2114 */ 2115 refresh: function(event) { 2116 2117 }, 2118 2119 /** 2120 * String representation 2121 * 2122 * @return "Aloha.Selection" 2123 * @hide 2124 */ 2125 toString: function() { 2126 return 'Aloha.Selection'; 2127 }, 2128 2129 getRangeCount: function() { 2130 return this._nativeSelection.rangeCount; 2131 } 2132 2133 }); 2134 2135 /** 2136 * A wrapper for the function of the same name in the rangy core-depdency. 2137 * This function should be preferred as it hides the global rangy object. 2138 * For more information look at the following sites: 2139 * http://html5.org/specs/dom-range.html 2140 * @param window optional - specifices the window to get the selection of 2141 */ 2142 Aloha.getSelection = function( target ) { 2143 var target = ( target !== document || target !== window ) ? window : target; 2144 // Aloha.Selection.refresh() 2145 // implement Aloha Selection 2146 // TODO cache 2147 return new AlohaSelection( window.rangy.getSelection( target ) ); 2148 }; 2149 2150 /** 2151 * A wrapper for the function of the same name in the rangy core-depdency. 2152 * This function should be preferred as it hides the global rangy object. 2153 * Please note: when the range object is not needed anymore, 2154 * invoke the detach method on it. It is currently unknown to me why 2155 * this is required, but that's what it says in the rangy specification. 2156 * For more information look at the following sites: 2157 * http://www.w3.org/TR/DOM-Level-2-Traversal-Range/ranges.html 2158 * @param document optional - specifies which document to create the range for 2159 */ 2160 Aloha.createRange = function(givenWindow) { 2161 return window.rangy.createRange(givenWindow); 2162 }; 2163 2164 var selection = new Selection(); 2165 Aloha.Selection = selection; 2166 2167 2168 function isCollapsedAndEmptyOrEndBr(rangeObject) { 2169 var firstChild; 2170 if (rangeObject.startContainer !== rangeObject.endContainer) { 2171 return false; 2172 } 2173 // check whether the container starts in an element node 2174 if (rangeObject.startContainer.nodeType != 1) { 2175 return false; 2176 } 2177 firstChild = rangeObject.startContainer.firstChild; 2178 return (!firstChild 2179 || (!firstChild.nextSibling 2180 && firstChild.nodeName == 'BR')); 2181 } 2182 2183 function isCollapsedAndEndBr(rangeObject) { 2184 if (rangeObject.startContainer !== rangeObject.endContainer) { 2185 return false; 2186 } 2187 if (rangeObject.startContainer.nodeType != 1) { 2188 return false; 2189 } 2190 return Engine.isEndBreak(rangeObject.startContainer); 2191 } 2192 2193 var prevStartContext = null; 2194 var prevEndContext = null; 2195 2196 function makeContextHtml(node, parents) { 2197 var result = [], 2198 parent, 2199 len, 2200 i; 2201 if (1 === node.nodeType && node.nodeName !== 'BODY' && node.nodeName !== 'HTML') { 2202 result.push(node.cloneNode(false).outerHTML); 2203 } else { 2204 result.push('#' + node.nodeType); 2205 } 2206 for (i = 0, len = parents.length; i < len; i++) { 2207 parent = parents[i]; 2208 if (parent.nodeName === 'BODY' || parent.nodeName === 'HTML') { 2209 // Although we limit the ancestors in most cases to the 2210 // active editable, in some cases (copy&paste) the 2211 // parent may be outside. 2212 // On IE7 this means the following code may clone the 2213 // HTML node too, which causes the browser to crash. 2214 // On other browsers, this is just an optimization 2215 // because the body and html elements should probably 2216 // not be considered part of the context of an edit 2217 // operation. 2218 break; 2219 } 2220 result.push(parent.cloneNode(false).outerHTML); 2221 } 2222 return result.join(''); 2223 } 2224 2225 function getChangedContext(node, context) { 2226 var until = Aloha.activeEditable ? Aloha.activeEditable.obj.parent()[0] : null; 2227 var parents = jQuery(node).parentsUntil(until).get(); 2228 var html = makeContextHtml(node, parents); 2229 var equal = ( context 2230 && node === context.node 2231 && Arrays.equal(context.parents, parents) 2232 && html === context.html); 2233 return equal ? null : {node: node, parents: parents, html: html}; 2234 } 2235 2236 function triggerSelectionContextChanged(rangeObject, event) { 2237 var startContainer = rangeObject.startContainer; 2238 var endContainer = rangeObject.endContainer; 2239 if (!startContainer || !endContainer) { 2240 console.warn("aloha/selection", "encountered range object without start or end container"); 2241 return; 2242 } 2243 var startContext = getChangedContext(startContainer, prevStartContext); 2244 var endContext = getChangedContext(endContainer , prevEndContext); 2245 if (!startContext && !endContext) { 2246 return; 2247 } 2248 prevStartContext = startContext; 2249 prevEndContext = endContext; 2250 2251 /** 2252 * @api documented in the guides 2253 */ 2254 PubSub.pub('aloha.selection.context-change', {range: rangeObject, event: event}); 2255 } 2256 2257 return selection; 2258 }); 2259