1 /** 2 * Copyright (C) 2012-2013 KO GmbH <copyright@kogmbh.com> 3 * 4 * @licstart 5 * This file is part of WebODF. 6 * 7 * WebODF is free software: you can redistribute it and/or modify it 8 * under the terms of the GNU Affero General Public License (GNU AGPL) 9 * as published by the Free Software Foundation, either version 3 of 10 * the License, or (at your option) any later version. 11 * 12 * WebODF is distributed in the hope that it will be useful, but 13 * 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 WebODF. If not, see <http://www.gnu.org/licenses/>. 19 * @licend 20 * 21 * @source: http://www.webodf.org/ 22 * @source: https://github.com/kogmbh/WebODF/ 23 */ 24 25 /*global Node, core, ops, runtime, NodeFilter, Range*/ 26 27 (function () { 28 "use strict"; 29 var /**@type{!{rangeBCRIgnoresElementBCR: boolean, unscaledRangeClientRects: boolean, elementBCRIgnoresBodyScroll: !boolean}}*/ 30 browserQuirks; 31 32 /** 33 * Detect various browser quirks 34 * unscaledRangeClientRects - Firefox doesn't apply parent css transforms to any range client rectangles 35 * rangeBCRIgnoresElementBCR - Internet explorer returns 0 client rects for an empty element that has fixed dimensions 36 * elementBCRIgnoresBodyScroll - iOS safari returns false client rects for an element that do not correlate with a scrolled body 37 * @return {!{unscaledRangeClientRects: !boolean, rangeBCRIgnoresElementBCR: !boolean, elementBCRIgnoresBodyScroll: !boolean}} 38 */ 39 function getBrowserQuirks() { 40 var range, 41 directBoundingRect, 42 rangeBoundingRect, 43 testContainer, 44 testElement, 45 detectedQuirks, 46 window, 47 document, 48 docElement, 49 body, 50 docOverflow, 51 bodyOverflow, 52 bodyHeight, 53 bodyScroll; 54 55 if (browserQuirks === undefined) { 56 window = runtime.getWindow(); 57 document = window && window.document; 58 docElement = document.documentElement; 59 body = document.body; 60 browserQuirks = { 61 rangeBCRIgnoresElementBCR: false, 62 unscaledRangeClientRects: false, 63 elementBCRIgnoresBodyScroll: false 64 }; 65 if (document) { 66 testContainer = document.createElement("div"); 67 testContainer.style.position = "absolute"; 68 testContainer.style.left = "-99999px"; 69 testContainer.style.transform = "scale(2)"; 70 testContainer.style["-webkit-transform"] = "scale(2)"; 71 72 testElement = document.createElement("div"); 73 testContainer.appendChild(testElement); 74 body.appendChild(testContainer); 75 range = document.createRange(); 76 range.selectNode(testElement); 77 // Internet explorer (v10 and others?) will omit the element's own client rect from 78 // the returned client rects list for the range 79 browserQuirks.rangeBCRIgnoresElementBCR = range.getClientRects().length === 0; 80 81 testElement.appendChild(document.createTextNode("Rect transform test")); 82 directBoundingRect = testElement.getBoundingClientRect(); 83 rangeBoundingRect = range.getBoundingClientRect(); 84 // Firefox doesn't apply parent css transforms to any range client rectangles 85 // See https://bugzilla.mozilla.org/show_bug.cgi?id=863618 86 // Depending on the browser, client rects can sometimes have sub-pixel rounding effects, so 87 // add some wiggle room for this. The scale is 200%, so there is no issues with false positives here 88 browserQuirks.unscaledRangeClientRects = Math.abs(directBoundingRect.height - rangeBoundingRect.height) > 2; 89 90 testContainer.style.transform = ""; 91 testContainer.style["-webkit-transform"] = ""; 92 // Backup current values for documentElement and body's overflows, body height, and body scroll. 93 docOverflow = docElement.style.overflow; 94 bodyOverflow = body.style.overflow; 95 bodyHeight = body.style.height; 96 bodyScroll = body.scrollTop; 97 // Set new values for the backed up properties 98 docElement.style.overflow = "visible"; 99 body.style.overflow = "visible"; 100 body.style.height = "200%"; 101 body.scrollTop = body.scrollHeight; 102 // After extending the body's height to twice and scrolling by that amount, 103 // if the element's new BCR is not the same as the range's BCR, then 104 // Houston we have a Quirk! This problem has been seen on iOS7, which 105 // seems to report the correct BCR for a range but ignores body scroll 106 // effects on an element... 107 browserQuirks.elementBCRIgnoresBodyScroll = (range.getBoundingClientRect().top !== testElement.getBoundingClientRect().top); 108 // Restore backed up property values 109 body.scrollTop = bodyScroll; 110 body.style.height = bodyHeight; 111 body.style.overflow = bodyOverflow; 112 docElement.style.overflow = docOverflow; 113 114 range.detach(); 115 body.removeChild(testContainer); 116 detectedQuirks = Object.keys(browserQuirks).map( 117 /** 118 * @param {!string} quirk 119 * @return {!string} 120 */ 121 function (quirk) { 122 return quirk + ":" + String(browserQuirks[quirk]); 123 } 124 ).join(", "); 125 runtime.log("Detected browser quirks - " + detectedQuirks); 126 } 127 } 128 return browserQuirks; 129 } 130 131 /** 132 * Return the first child element with the given namespace and name. 133 * If the parent is null, or if there is no child with the given name and 134 * namespace, null is returned. 135 * @param {?Element} parent 136 * @param {!string} ns 137 * @param {!string} name 138 * @return {?Element} 139 */ 140 function getDirectChild(parent, ns, name) { 141 var node = parent ? parent.firstElementChild : null; 142 while (node) { 143 if (node.localName === name && node.namespaceURI === ns) { 144 return /**@type{!Element}*/(node); 145 } 146 node = node.nextElementSibling; 147 } 148 return null; 149 } 150 151 /** 152 * A collection of Dom utilities 153 * @constructor 154 */ 155 core.DomUtilsImpl = function DomUtilsImpl() { 156 var /**@type{?Range}*/ 157 sharedRange = null; 158 159 /** 160 * @param {!Document} doc 161 * @return {!Range} 162 */ 163 function getSharedRange(doc) { 164 var range; 165 if (sharedRange) { 166 range = sharedRange; 167 } else { 168 sharedRange = range = /**@type{!Range}*/(doc.createRange()); 169 } 170 return range; 171 } 172 173 /** 174 * Find the inner-most child point that is equivalent 175 * to the provided container and offset. 176 * @param {!Node} container 177 * @param {!number} offset 178 * @return {{container: Node, offset: !number}} 179 */ 180 function findStablePoint(container, offset) { 181 var c = container; 182 if (offset < c.childNodes.length) { 183 c = c.childNodes.item(offset); 184 offset = 0; 185 while (c.firstChild) { 186 c = c.firstChild; 187 } 188 } else { 189 while (c.lastChild) { 190 c = c.lastChild; 191 offset = c.nodeType === Node.TEXT_NODE 192 ? c.textContent.length 193 : c.childNodes.length; 194 } 195 } 196 return {container: c, offset: offset}; 197 } 198 199 /** 200 * Gets the unfiltered DOM 'offset' of a node within a container that may not be it's direct parent. 201 * @param {!Node} node 202 * @param {!Node} container 203 * @return {!number} 204 */ 205 function getPositionInContainingNode(node, container) { 206 var offset = 0, 207 n; 208 while (node.parentNode !== container) { 209 runtime.assert(node.parentNode !== null, "parent is null"); 210 node = /**@type{!Node}*/(node.parentNode); 211 } 212 n = container.firstChild; 213 while (n !== node) { 214 offset += 1; 215 n = n.nextSibling; 216 } 217 return offset; 218 } 219 220 /** 221 * If either the start or end boundaries of a range start within a text 222 * node, this function will split these text nodes and reset the range 223 * boundaries to select the new nodes. The end result is that there are 224 * no partially contained text nodes within the resulting range. 225 * E.g., the text node with selection: 226 * "A|BCD|E" 227 * would be split into 3 text nodes, with the range modified to maintain 228 * only the completely selected text node: 229 * "A" "|BCD|" "E" 230 * @param {!Range} range 231 * @return {!Array.<!Node>} Return a list of nodes modified as a result 232 * of this split operation. These are often 233 * processed through 234 * DomUtils.normalizeTextNodes after all 235 * processing has been complete. 236 */ 237 function splitBoundaries(range) { 238 var modifiedNodes = [], 239 originalEndContainer, 240 resetToContainerLength, 241 end, 242 splitStart, 243 node, 244 text, 245 offset; 246 247 if (range.startContainer.nodeType === Node.TEXT_NODE 248 || range.endContainer.nodeType === Node.TEXT_NODE) { 249 originalEndContainer = range.endContainer; 250 resetToContainerLength = range.endContainer.nodeType !== Node.TEXT_NODE ? 251 range.endOffset === range.endContainer.childNodes.length : false; 252 253 end = findStablePoint(range.endContainer, range.endOffset); 254 if (end.container === originalEndContainer) { 255 originalEndContainer = null; 256 } 257 // Stable points need to be found to ensure splitting the text 258 // node doesn't inadvertently modify the other end of the range 259 range.setEnd(end.container, end.offset); 260 261 // Must split end first to stop the start point from being lost 262 node = range.endContainer; 263 if (range.endOffset !== 0 && node.nodeType === Node.TEXT_NODE) { 264 text = /**@type{!Text}*/(node); 265 if (range.endOffset !== text.length) { 266 modifiedNodes.push(text.splitText(range.endOffset)); 267 modifiedNodes.push(text); 268 // The end doesn't need to be reset as endContainer & 269 // endOffset are still valid after the modification 270 } 271 } 272 273 node = range.startContainer; 274 if (range.startOffset !== 0 && node.nodeType === Node.TEXT_NODE) { 275 text = /**@type{!Text}*/(node); 276 if (range.startOffset !== text.length) { 277 splitStart = text.splitText(range.startOffset); 278 modifiedNodes.push(text); 279 modifiedNodes.push(splitStart); 280 range.setStart(splitStart, 0); 281 } 282 } 283 284 if (originalEndContainer !== null) { 285 node = range.endContainer; 286 while (node.parentNode && node.parentNode !== originalEndContainer) { 287 node = node.parentNode; 288 } 289 if (resetToContainerLength) { 290 offset = originalEndContainer.childNodes.length; 291 } else { 292 offset = getPositionInContainingNode(node, originalEndContainer); 293 } 294 range.setEnd(originalEndContainer, offset); 295 } 296 } 297 return modifiedNodes; 298 } 299 this.splitBoundaries = splitBoundaries; 300 301 /** 302 * Returns true if the container range completely contains the insideRange. 303 * Aligned boundaries are counted as inclusion 304 * @param {!Range} container 305 * @param {!Range} insideRange 306 * @return {boolean} 307 */ 308 function containsRange(container, insideRange) { 309 return container.compareBoundaryPoints(Range.START_TO_START, insideRange) <= 0 310 && container.compareBoundaryPoints(Range.END_TO_END, insideRange) >= 0; 311 } 312 this.containsRange = containsRange; 313 314 /** 315 * Returns true if there is any intersection between range1 and range2 316 * @param {!Range} range1 317 * @param {!Range} range2 318 * @return {boolean} 319 */ 320 function rangesIntersect(range1, range2) { 321 return range1.compareBoundaryPoints(Range.END_TO_START, range2) <= 0 322 && range1.compareBoundaryPoints(Range.START_TO_END, range2) >= 0; 323 } 324 this.rangesIntersect = rangesIntersect; 325 326 /** 327 * Returns the intersection of two ranges. If there is no intersection, this 328 * will return undefined. 329 * 330 * @param {!Range} range1 331 * @param {!Range} range2 332 * @return {!Range|undefined} 333 */ 334 function rangeIntersection(range1, range2) { 335 var newRange; 336 337 if (rangesIntersect(range1, range2)) { 338 newRange = /**@type{!Range}*/(range1.cloneRange()); 339 if (range1.compareBoundaryPoints(Range.START_TO_START, range2) === -1) { 340 // If range1's start is before range2's start, use range2's start 341 newRange.setStart(range2.startContainer, range2.startOffset); 342 } 343 344 if (range1.compareBoundaryPoints(Range.END_TO_END, range2) === 1) { 345 // if range1's end is after range2's end, use range2's end 346 newRange.setEnd(range2.endContainer, range2.endOffset); 347 } 348 } 349 return newRange; 350 } 351 this.rangeIntersection = rangeIntersection; 352 353 /** 354 * Returns the maximum available offset for the node. If this is a text 355 * node, this will be node.length, or for an element node, childNodes.length 356 * @param {!Node} node 357 * @return {!number} 358 */ 359 function maximumOffset(node) { 360 return node.nodeType === Node.TEXT_NODE ? /**@type{!Text}*/(node).length : node.childNodes.length; 361 } 362 363 /** 364 * Checks all nodes between the tree walker's current node and the defined 365 * root. If any nodes are rejected, the tree walker is moved to the 366 * highest rejected node below the root. Note, the root is excluded from 367 * this check. 368 * 369 * This logic is similar to PositionIterator.moveToAcceptedNode 370 * @param {!TreeWalker} walker 371 * @param {!Node} root 372 * @param {!function(!Node) : number} nodeFilter 373 * 374 * @return {!Node} Returns the current node the walker is on 375 */ 376 function moveToNonRejectedNode(walker, root, nodeFilter) { 377 var node = walker.currentNode; 378 379 // Ensure currentNode is not within a rejected subtree by crawling each parent node 380 // up to the root and verifying it is either accepted or skipped by the nodeFilter. 381 // NOTE: The root is deliberately not checked as it is the container iteration happens within. 382 if (node !== root) { 383 node = node.parentNode; 384 while (node && node !== root) { 385 if (nodeFilter(node) === NodeFilter.FILTER_REJECT) { 386 walker.currentNode = node; 387 } 388 node = node.parentNode; 389 } 390 } 391 return walker.currentNode; 392 } 393 394 /** 395 * Fetches all nodes within a supplied range that pass the required filter 396 * @param {!Range} range 397 * @param {!function(!Node) : number} nodeFilter 398 * @param {!number} whatToShow 399 * @return {!Array.<!Node>} 400 */ 401 /*jslint bitwise:true*/ 402 function getNodesInRange(range, nodeFilter, whatToShow) { 403 var document = range.startContainer.ownerDocument, 404 elements = [], 405 rangeRoot = range.commonAncestorContainer, 406 root = /**@type{!Node}*/(rangeRoot.nodeType === Node.TEXT_NODE ? rangeRoot.parentNode : rangeRoot), 407 treeWalker = document.createTreeWalker(root, whatToShow, nodeFilter, false), 408 currentNode, 409 lastNodeInRange, 410 endNodeCompareFlags, 411 comparePositionResult; 412 413 if (range.endContainer.childNodes[range.endOffset - 1]) { 414 // This is the last node completely contained in the range 415 lastNodeInRange = /**@type{!Node}*/(range.endContainer.childNodes[range.endOffset - 1]); 416 // Accept anything preceding or contained by this node. 417 endNodeCompareFlags = Node.DOCUMENT_POSITION_PRECEDING | Node.DOCUMENT_POSITION_CONTAINED_BY; 418 } else { 419 // Either no child nodes (e.g., TEXT_NODE) or endOffset = 0 420 lastNodeInRange = /**@type{!Node}*/(range.endContainer); 421 // Don't accept things contained within this node, as the range ends before this node's children. 422 // This is the last node touching the range though, so the node is still accepted into the results. 423 endNodeCompareFlags = Node.DOCUMENT_POSITION_PRECEDING; 424 } 425 426 if (range.startContainer.childNodes[range.startOffset]) { 427 // The range starts within startContainer, so this child node is the first node in the range 428 currentNode = /**@type{!Node}*/(range.startContainer.childNodes[range.startOffset]); 429 treeWalker.currentNode = currentNode; 430 } else if (range.startOffset === maximumOffset(range.startContainer)) { 431 // This condition will be true if the range starts beyond the last position of a node 432 // E.g., (text, text.length) or (div, div.childNodes.length) 433 currentNode = /**@type{!Node}*/(range.startContainer); 434 treeWalker.currentNode = currentNode; 435 // In this case, move to the last child (if the node has children) 436 treeWalker.lastChild(); // May return null if the current node has no children 437 // And navigate onto the next node in sequence 438 currentNode = treeWalker.nextNode(); 439 } else { 440 // This will only be hit for a text node that is partially overlapped by the range start 441 currentNode = /**@type{!Node}*/(range.startContainer); 442 treeWalker.currentNode = currentNode; 443 } 444 445 if (currentNode) { 446 // If the treeWalker hit the end of the sequence in the treeWalker.nextNode line just above, 447 // currentNode will be null. 448 currentNode = moveToNonRejectedNode(treeWalker, root, nodeFilter); 449 switch (nodeFilter(/**@type{!Node}*/(currentNode))) { 450 case NodeFilter.FILTER_REJECT: 451 // If started on a rejected node, calling nextNode will incorrectly 452 // dive down into the rejected node's children. Instead, advance to 453 // the next sibling or parent node's sibling and resume walking from 454 // there. 455 currentNode = treeWalker.nextSibling(); 456 while (!currentNode && treeWalker.parentNode()) { 457 currentNode = treeWalker.nextSibling(); 458 } 459 break; 460 case NodeFilter.FILTER_SKIP: 461 // Immediately advance to the next node without giving an opportunity for the current one to 462 // be stored. 463 currentNode = treeWalker.nextNode(); 464 break; 465 default: 466 // case NodeFilter.FILTER_ACCEPT: 467 // Proceed into the following loop. The current node will be stored at the end of the loop 468 // if it is contained within the requested range. 469 break; 470 } 471 472 while (currentNode) { 473 comparePositionResult = lastNodeInRange.compareDocumentPosition(currentNode); 474 if (comparePositionResult !== 0 && (comparePositionResult & endNodeCompareFlags) === 0) { 475 // comparePositionResult === 0 if currentNode === lastNodeInRange. This is considered within the range 476 // comparePositionResult & endNodeCompareFlags would be non-zero if n precedes lastNodeInRange 477 // If either of these statements are false, currentNode is past the end of the range 478 break; 479 } 480 elements.push(currentNode); 481 currentNode = treeWalker.nextNode(); 482 } 483 } 484 485 return elements; 486 } 487 /*jslint bitwise:false*/ 488 this.getNodesInRange = getNodesInRange; 489 490 /** 491 * Merges the content of node with nextNode. 492 * If node is an empty text node, it will be removed in any case. 493 * If nextNode is an empty text node, it will be only removed if node is a text node. 494 * @param {!Node} node 495 * @param {!Node} nextNode 496 * @return {?Node} merged text node or null if there is no text node as result 497 */ 498 function mergeTextNodes(node, nextNode) { 499 var mergedNode = null, text, nextText; 500 501 if (node.nodeType === Node.TEXT_NODE) { 502 text = /**@type{!Text}*/(node); 503 if (text.length === 0) { 504 text.parentNode.removeChild(text); 505 if (nextNode.nodeType === Node.TEXT_NODE) { 506 mergedNode = nextNode; 507 } 508 } else { 509 if (nextNode.nodeType === Node.TEXT_NODE) { 510 // in chrome it is important to add nextNode to node. 511 // doing it the other way around causes random 512 // whitespace to appear 513 nextText = /**@type{!Text}*/(nextNode); 514 text.appendData(nextText.data); 515 nextNode.parentNode.removeChild(nextNode); 516 } 517 mergedNode = node; 518 } 519 } 520 521 return mergedNode; 522 } 523 524 /** 525 * Attempts to normalize the node with any surrounding text nodes. No 526 * actions are performed if the node is undefined, has no siblings, or 527 * is not a text node 528 * @param {Node} node 529 * @return {undefined} 530 */ 531 function normalizeTextNodes(node) { 532 if (node && node.nextSibling) { 533 node = mergeTextNodes(node, node.nextSibling); 534 } 535 if (node && node.previousSibling) { 536 mergeTextNodes(node.previousSibling, node); 537 } 538 } 539 this.normalizeTextNodes = normalizeTextNodes; 540 541 /** 542 * Checks if the provided limits fully encompass the passed in node 543 * @param {!Range|{startContainer: Node, startOffset: !number, endContainer: Node, endOffset: !number}} limits 544 * @param {!Node} node 545 * @return {boolean} Returns true if the node is fully contained within 546 * the range 547 */ 548 function rangeContainsNode(limits, node) { 549 var range = node.ownerDocument.createRange(), 550 nodeRange = node.ownerDocument.createRange(), 551 result; 552 553 range.setStart(limits.startContainer, limits.startOffset); 554 range.setEnd(limits.endContainer, limits.endOffset); 555 nodeRange.selectNodeContents(node); 556 557 result = containsRange(range, nodeRange); 558 559 range.detach(); 560 nodeRange.detach(); 561 return result; 562 } 563 this.rangeContainsNode = rangeContainsNode; 564 565 /** 566 * Merge all child nodes into the targetNode's parent and remove the targetNode entirely 567 * @param {!Node} targetNode 568 * @return {!Node} parent of targetNode 569 */ 570 function mergeIntoParent(targetNode) { 571 var parent = targetNode.parentNode; 572 while (targetNode.firstChild) { 573 parent.insertBefore(targetNode.firstChild, targetNode); 574 } 575 parent.removeChild(targetNode); 576 return parent; 577 } 578 this.mergeIntoParent = mergeIntoParent; 579 580 /** 581 * Removes all unwanted nodes from targetNode includes itself. 582 * The nodeFilter defines which nodes should be removed (NodeFilter.FILTER_REJECT), 583 * should be skipped including the subtree (NodeFilter.FILTER_SKIP) or should be kept 584 * and their subtree checked further (NodeFilter.FILTER_ACCEPT). 585 * @param {!Node} targetNode 586 * @param {!function(!Node) : !number} nodeFilter 587 * @return {?Node} parent of targetNode 588 */ 589 function removeUnwantedNodes(targetNode, nodeFilter) { 590 var parent = targetNode.parentNode, 591 node = targetNode.firstChild, 592 filterResult = nodeFilter(targetNode), 593 next; 594 595 if (filterResult === NodeFilter.FILTER_SKIP) { 596 return parent; 597 } 598 599 while (node) { 600 next = node.nextSibling; 601 removeUnwantedNodes(node, nodeFilter); 602 node = next; 603 } 604 if (parent && (filterResult === NodeFilter.FILTER_REJECT)) { 605 mergeIntoParent(targetNode); 606 } 607 return parent; 608 } 609 this.removeUnwantedNodes = removeUnwantedNodes; 610 611 /** 612 * Removes all child nodes from the given node. 613 * To be used instead of e.g. `node.innerHTML = "";` 614 * @param {!Node} node 615 * @return {undefined} 616 */ 617 this.removeAllChildNodes = function (node) { 618 while (node.firstChild) { 619 node.removeChild(node.firstChild); 620 } 621 }; 622 623 /** 624 * Get an array of nodes below the specified node with the specific namespace and tag name. 625 * 626 * Use this function instead of node.getElementsByTagNameNS when modifications are going to be made 627 * to the document content during iteration. For read-only uses, node.getElementsByTagNameNS will perform 628 * faster and use less memory. See https://github.com/kogmbh/WebODF/issues/736 for further discussion. 629 * 630 * @param {!Element|!Document} node 631 * @param {!string} namespace 632 * @param {!string} tagName 633 * @return {!Array.<!Element>} 634 */ 635 function getElementsByTagNameNS(node, namespace, tagName) { 636 var e = [], list, i, l; 637 list = node.getElementsByTagNameNS(namespace, tagName); 638 e.length = l = list.length; 639 for (i = 0; i < l; i += 1) { 640 e[i] = /**@type{!Element}*/(list.item(i)); 641 } 642 return e; 643 } 644 this.getElementsByTagNameNS = getElementsByTagNameNS; 645 646 /** 647 * Get an array of nodes below the specified node with the specific name tag name. 648 * 649 * Use this function instead of node.getElementsByTagName when modifications are going to be made 650 * to the document content during iteration. For read-only uses, node.getElementsByTagName will perform 651 * faster and use less memory. See https://github.com/kogmbh/WebODF/issues/736 for further discussion. 652 * 653 * @param {!Element|!Document} node 654 * @param {!string} tagName 655 * @return {!Array.<!Element>} 656 */ 657 function getElementsByTagName(node, tagName) { 658 var e = [], list, i, l; 659 list = node.getElementsByTagName(tagName); 660 e.length = l = list.length; 661 for (i = 0; i < l; i += 1) { 662 e[i] = /**@type{!Element}*/(list.item(i)); 663 } 664 return e; 665 } 666 this.getElementsByTagName = getElementsByTagName; 667 668 /** 669 * Whether a node contains another node 670 * Wrapper around Node.contains 671 * http://www.w3.org/TR/domcore/#dom-node-contains 672 * @param {!Node} parent The node that should contain the other node 673 * @param {?Node} descendant The node to test presence of 674 * @return {!boolean} 675 */ 676 function containsNode(parent, descendant) { 677 return parent === descendant 678 // the casts to Element are a workaround due to a different 679 // contains() definition in the Closure Compiler externs file. 680 || /**@type{!Element}*/(parent).contains(/**@type{!Element}*/(descendant)); 681 } 682 this.containsNode = containsNode; 683 684 /** 685 * Whether a node contains another node 686 * @param {!Node} parent The node that should contain the other node 687 * @param {?Node} descendant The node to test presence of 688 * @return {!boolean} 689 */ 690 /*jslint bitwise:true*/ 691 function containsNodeForBrokenWebKit(parent, descendant) { 692 // the contains function is not reliable on safari/webkit so use 693 // compareDocumentPosition instead 694 return parent === descendant || 695 Boolean(parent.compareDocumentPosition(descendant) & Node.DOCUMENT_POSITION_CONTAINED_BY); 696 } 697 /*jslint bitwise:false*/ 698 699 /** 700 * Return a number > 0 when point 1 precedes point 2. Return 0 if the points 701 * are equal. Return < 0 when point 2 precedes point 1. 702 * @param {!Node} c1 container of point 1 703 * @param {!number} o1 offset in unfiltered DOM world of point 1 704 * @param {!Node} c2 container of point 2 705 * @param {!number} o2 offset in unfiltered DOM world of point 2 706 * @return {!number} 707 */ 708 function comparePoints(c1, o1, c2, o2) { 709 if (c1 === c2) { 710 return o2 - o1; 711 } 712 var comparison = c1.compareDocumentPosition(c2); 713 if (comparison === 2) { // DOCUMENT_POSITION_PRECEDING 714 comparison = -1; 715 } else if (comparison === 4) { // DOCUMENT_POSITION_FOLLOWING 716 comparison = 1; 717 } else if (comparison === 10) { // DOCUMENT_POSITION_CONTAINS 718 // c0 contains c2 719 o1 = getPositionInContainingNode(c1, c2); 720 comparison = (o1 < o2) ? 1 : -1; 721 } else { // DOCUMENT_POSITION_CONTAINED_BY 722 o2 = getPositionInContainingNode(c2, c1); 723 comparison = (o2 < o1) ? -1 : 1; 724 } 725 return comparison; 726 } 727 this.comparePoints = comparePoints; 728 729 /** 730 * Scale the supplied number by the specified zoom transformation if the 731 * bowser does not transform range client rectangles correctly. 732 * In firefox, the span rectangle will be affected by the zoom, but the 733 * range is not. In most all other browsers, the range number is 734 * affected zoom. 735 * 736 * See http://dev.w3.org/csswg/cssom-view/#extensions-to-the-range-interface 737 * Section 10, getClientRects, 738 * "The transforms that apply to the ancestors are applied." 739 * @param {!number} inputNumber An input number to be scaled. This is 740 * expected to be the difference between 741 * a property on two range-sourced client 742 * rectangles (e.g., rect1.top - rect2.top) 743 * @param {!number} zoomLevel Current canvas zoom level 744 * @return {!number} 745 */ 746 function adaptRangeDifferenceToZoomLevel(inputNumber, zoomLevel) { 747 if (getBrowserQuirks().unscaledRangeClientRects) { 748 return inputNumber; 749 } 750 return inputNumber / zoomLevel; 751 } 752 this.adaptRangeDifferenceToZoomLevel = adaptRangeDifferenceToZoomLevel; 753 754 /** 755 * Translate a given child client rectangle to be relative to the parent's rectangle. 756 * Adapt to the provided zoom level as per adaptRangeDifferenceToZoomLevel. 757 * 758 * IMPORTANT: due to browser quirks, any element bounding client rect used with this function 759 * MUST be retrieved using DomUtils.getBoundingClientRect. 760 * 761 * @param {!ClientRect|!Object.<!string, !number>} child 762 * @param {!ClientRect|!Object.<!string, !number>} parent 763 * @param {!number} zoomLevel 764 * @return {!ClientRect|{top: !number, left: !number, bottom: !number, right: !number, width: !number, height: !number}} 765 */ 766 this.translateRect = function(child, parent, zoomLevel) { 767 return { 768 top: adaptRangeDifferenceToZoomLevel(child.top - parent.top, zoomLevel), 769 left: adaptRangeDifferenceToZoomLevel(child.left - parent.left, zoomLevel), 770 bottom: adaptRangeDifferenceToZoomLevel(child.bottom - parent.top, zoomLevel), 771 right: adaptRangeDifferenceToZoomLevel(child.right - parent.left, zoomLevel), 772 width: adaptRangeDifferenceToZoomLevel(child.width, zoomLevel), 773 height: adaptRangeDifferenceToZoomLevel(child.height, zoomLevel) 774 }; 775 }; 776 777 /** 778 * Get the bounding client rect for the specified node. 779 * This function attempts to cope with various browser quirks, ideally 780 * returning a rectangle that can be used in conjunction with rectangles 781 * retrieved from ranges. 782 * 783 * Range & element client rectangles can only be mixed if both are 784 * transformed in the same way. 785 * See https://bugzilla.mozilla.org/show_bug.cgi?id=863618 786 * @param {!Node} node 787 * @return {?ClientRect} 788 */ 789 function getBoundingClientRect(node) { 790 var doc = /**@type{!Document}*/(node.ownerDocument), 791 quirks = getBrowserQuirks(), 792 range, 793 element, 794 rect, 795 body = doc.body; 796 797 if (quirks.unscaledRangeClientRects === false 798 || quirks.rangeBCRIgnoresElementBCR) { 799 if (node.nodeType === Node.ELEMENT_NODE) { 800 element = /**@type{!Element}*/(node); 801 rect = element.getBoundingClientRect(); 802 if (quirks.elementBCRIgnoresBodyScroll) { 803 return /**@type{?ClientRect}*/({ 804 left: rect.left + body.scrollLeft, 805 right: rect.right + body.scrollLeft, 806 top: rect.top + body.scrollTop, 807 bottom: rect.bottom + body.scrollTop, 808 width: rect.width, 809 height: rect.height 810 }); 811 } 812 return rect; 813 } 814 } 815 range = getSharedRange(doc); 816 range.selectNode(node); 817 return range.getBoundingClientRect(); 818 } 819 this.getBoundingClientRect = getBoundingClientRect; 820 821 /** 822 * Takes a flat object which is a key-value 823 * map of strings, and populates/modifies 824 * the node with child elements which have 825 * the key name as the node name (namespace 826 * prefix required in the key name) 827 * and the value as the text content. 828 * Example: mapKeyValObjOntoNode(node, {"dc:creator": "Bob"}, nsResolver); 829 * If a namespace prefix is unresolved with the 830 * nsResolver, that key will be ignored and not written to the node. 831 * @param {!Element} node 832 * @param {!Object.<!string, !string>} properties 833 * @param {!function(!string):?string} nsResolver 834 */ 835 function mapKeyValObjOntoNode(node, properties, nsResolver) { 836 Object.keys(properties).forEach(function (key) { 837 var parts = key.split(":"), 838 prefix = parts[0], 839 localName = parts[1], 840 ns = nsResolver(prefix), 841 value = properties[key], 842 element; 843 844 // Ignore if the prefix is unsupported, 845 // otherwise set the textContent of the 846 // element to the value. 847 if (ns) { 848 element = /**@type{!Element|undefined}*/(node.getElementsByTagNameNS(ns, localName)[0]); 849 if (!element) { 850 element = node.ownerDocument.createElementNS(ns, key); 851 node.appendChild(element); 852 } 853 element.textContent = value; 854 } else { 855 runtime.log("Key ignored: " + key); 856 } 857 }); 858 } 859 this.mapKeyValObjOntoNode = mapKeyValObjOntoNode; 860 861 /** 862 * Takes an array of strings, which is a listing of 863 * properties to be removed (namespace required), 864 * and deletes the corresponding top-level child elements 865 * that represent those properties, from the 866 * supplied node. 867 * Example: removeKeyElementsFromNode(node, ["dc:creator"], nsResolver); 868 * If a namespace is not resolved with the nsResolver, 869 * that key element will be not removed. 870 * If a key element does not exist, it will be ignored. 871 * @param {!Element} node 872 * @param {!Array.<!string>} propertyNames 873 * @param {!function(!string):?string} nsResolver 874 */ 875 function removeKeyElementsFromNode(node, propertyNames, nsResolver) { 876 propertyNames.forEach(function (propertyName) { 877 var parts = propertyName.split(":"), 878 prefix = parts[0], 879 localName = parts[1], 880 ns = nsResolver(prefix), 881 element; 882 883 // Ignore if the prefix is unsupported, 884 // otherwise delete the element if found 885 if (ns) { 886 element = /**@type{!Element|undefined}*/(node.getElementsByTagNameNS(ns, localName)[0]); 887 if (element) { 888 element.parentNode.removeChild(element); 889 } else { 890 runtime.log("Element for " + propertyName + " not found."); 891 } 892 } else { 893 runtime.log("Property Name ignored: " + propertyName); 894 } 895 }); 896 } 897 this.removeKeyElementsFromNode = removeKeyElementsFromNode; 898 899 /** 900 * Looks at an element's direct children, and generates an object which is a 901 * flat key-value map from the child's ns:localName to it's text content. 902 * Only those children that have a resolvable prefixed name will be taken into 903 * account for generating this map. 904 * @param {!Element} node 905 * @param {function(!string):?string} prefixResolver 906 * @return {!Object.<!string,!string>} 907 */ 908 function getKeyValRepresentationOfNode(node, prefixResolver) { 909 var properties = {}, 910 currentSibling = node.firstElementChild, 911 prefix; 912 913 while (currentSibling) { 914 prefix = prefixResolver(currentSibling.namespaceURI); 915 if (prefix) { 916 properties[prefix + ':' + currentSibling.localName] = currentSibling.textContent; 917 } 918 currentSibling = currentSibling.nextElementSibling; 919 } 920 921 return properties; 922 } 923 this.getKeyValRepresentationOfNode = getKeyValRepresentationOfNode; 924 925 /** 926 * Maps attributes and elements in the properties object over top of the node. 927 * Supports recursion and deep mapping. 928 * 929 * Supported value types are: 930 * - string (mapped to an attribute string on node) 931 * - number (mapped to an attribute string on node) 932 * - object (deep mapped to a new child node on node) 933 * 934 * @param {!Element} node 935 * @param {!Object.<string,*>} properties 936 * @param {!function(!string):?string} nsResolver 937 */ 938 function mapObjOntoNode(node, properties, nsResolver) { 939 Object.keys(properties).forEach(function(key) { 940 var parts = key.split(":"), 941 prefix = parts[0], 942 localName = parts[1], 943 ns = nsResolver(prefix), 944 value = properties[key], 945 valueType = typeof value, 946 element; 947 948 if (valueType === "object") { 949 // Only create the destination sub-element if there are values to populate it with 950 if (Object.keys(/**@type{!Object}*/(value)).length) { 951 if (ns) { 952 element = /**@type{!Element|undefined}*/(node.getElementsByTagNameNS(ns, localName)[0]) 953 || node.ownerDocument.createElementNS(ns, key); 954 } else { 955 element = /**@type{!Element|undefined}*/(node.getElementsByTagName(localName)[0]) 956 || node.ownerDocument.createElement(key); 957 } 958 node.appendChild(element); 959 mapObjOntoNode(element, /**@type{!Object}*/(value), nsResolver); 960 } 961 } else if (ns) { 962 runtime.assert(valueType === "number" || valueType === "string", 963 "attempting to map unsupported type '" + valueType + "' (key: " + key + ")"); 964 node.setAttributeNS(ns, key, String(value)); 965 // If the prefix is unknown or unsupported, simply ignore it for now 966 } 967 }); 968 } 969 this.mapObjOntoNode = mapObjOntoNode; 970 971 /** 972 * Clones an event object. 973 * IE10 destructs event objects once the event handler is done: 974 * "The event object is only available during an event; that is, you can use it in event handlers but not in other code" 975 * (from http://msdn.microsoft.com/en-us/library/ie/aa703876(v=vs.85).aspx) 976 * This method can be used to create a copy of the event object, to work around that. 977 * @param {!Event} event 978 * @return {!Event} 979 */ 980 function cloneEvent(event) { 981 var e = Object.create(null); 982 983 // copy over all direct properties 984 Object.keys(event.constructor.prototype).forEach(function (x) { 985 e[x] = event[x]; 986 }); 987 // only now set the prototype (might set properties read-only) 988 e.prototype = event.constructor.prototype; 989 990 return /**@type{!Event}*/(e); 991 } 992 this.cloneEvent = cloneEvent; 993 994 this.getDirectChild = getDirectChild; 995 996 /** 997 * @param {!core.DomUtilsImpl} self 998 */ 999 function init(self) { 1000 var appVersion, webKitOrSafari, ie, 1001 /**@type{?Window}*/ 1002 window = runtime.getWindow(); 1003 1004 if (window === null) { 1005 return; 1006 } 1007 1008 appVersion = window.navigator.appVersion.toLowerCase(); 1009 webKitOrSafari = appVersion.indexOf('chrome') === -1 1010 && (appVersion.indexOf('applewebkit') !== -1 1011 || appVersion.indexOf('safari') !== -1); 1012 // See http://connect.microsoft.com/IE/feedback/details/780874/node-contains-is-incorrect 1013 // Also, IE cleverly removed the MSIE tag without fixing the bug we're attempting to sniff here... 1014 // http://msdn.microsoft.com/en-us/library/ie/bg182625%28v=vs.110%29.aspx 1015 ie = appVersion.indexOf('msie') !== -1 || appVersion.indexOf('trident') !== -1; 1016 if (webKitOrSafari || ie) { 1017 self.containsNode = containsNodeForBrokenWebKit; 1018 } 1019 } 1020 init(this); 1021 }; 1022 1023 /** 1024 * @type {!core.DomUtilsImpl} 1025 */ 1026 core.DomUtils = new core.DomUtilsImpl(); 1027 }()); 1028