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