From 3dbccc131cfb2cc3a888703b4fa695d370df77ff Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Fri, 27 Jul 2012 15:32:51 -0700 Subject: [PATCH] move liveui/domutils utilities into DomUtils --- packages/domutils/domutils.js | 166 +++++++++++++++++++++++++++++- packages/liveui/domutils.js | 23 ++++- packages/liveui/livedocument.js | 4 +- packages/liveui/liveevents_w3c.js | 2 +- packages/liveui/liveui.js | 10 +- packages/liveui/patcher.js | 3 +- 6 files changed, 192 insertions(+), 16 deletions(-) diff --git a/packages/domutils/domutils.js b/packages/domutils/domutils.js index e4eef364ec..3da6d8c03d 100644 --- a/packages/domutils/domutils.js +++ b/packages/domutils/domutils.js @@ -152,4 +152,168 @@ DomUtils = {}; return container; }; -})(); \ No newline at end of file + // Returns true if element a properly contains element b. + // Only works on element nodes (e.g. not text nodes). + DomUtils.elementContains = function(a, b) { + // Note: Some special-casing would be required to implement this method + // where a and b aren't necessarily elements, e.g. b is a text node, + // because contains() doesn't seem to work reliably on some browsers + // including IE. + if (a.nodeType !== 1 || b.nodeType !== 1) { + return false; // a and b are not both elements + } + if (a.compareDocumentPosition) { + return a.compareDocumentPosition(b) & 0x10; + } else { + // Should be only old IE and maybe other old browsers here. + // Modern Safari has both methods but seems to get contains() wrong. + return a !== b && a.contains(b); + } + }; + + // Returns an array containing the children of contextNode that + // match `selector`. Unlike querySelectorAll, `selector` is + // interpreted as if the document were rooted at `contextNode` -- + // the only nodes that can be used to match components of the + // selector are the descendents of `contextNode`. `contextNode` + // itself is not included (it can't be used to match a component of + // the selector, and it can never be included in the returned + // array.) + // + // `contextNode` may be either a node, a document, or a DocumentFragment. + DomUtils.findElement = function(contextNode, selector) { + // Eventually, we will remove the dependency on jQuery ($) and + // implement this in terms of querySelectorAll on modern browsers + // and Sizzle in old IE. We'll use jQuery's trick for scoped + // querySelectorAll which involves temporarily assigning an ID to + // contextNode (if it doesn't have one) and prepending the ID to + // the selector. + if (contextNode.nodeType === 11 /* DocumentFragment */) { + // Sizzle doesn't work on a DocumentFragment, but it does work on + // a descendent of one. + var frag = contextNode; + var container = DomUtils.fragmentToContainer(frag); + var results = $(container).find(selector); + // put nodes back into frag + while (container.firstChild) + frag.appendChild(container.firstChild); + return results; + } else { + return $(contextNode).find(selector); + } + }; + + // Like `findElement` but searches the nodes from `start` to `end` + // inclusive. `start` and `end` must be siblings, and they participate + // in the search (they can be used to match selector components, and + // they can appear in the returned results). It's as if the parent of + // `start` and `end` serves as contextNode, but matches from children + // that aren't between `start` and `end` (inclusive) are ignored. + // + // If `selector` involves sibling selectors, child index selectors, or + // the like, the results are undefined. + DomUtils.findElementInRange = function(start, end, selector) { + end = (end || start); + + var container = start.parentNode; + if (! container) { + if (start === end && (start.nodeType === 9 /* Document */ || + start.nodeType === 11 /* DocumentFragment */)) + return DomUtils.findElement(start, selector); + throw new Error("Can't find element in range on detached node"); + } + if (end.parentNode !== container) + throw new Error("Bad range"); + + // narrow the range to exclude top-level non-elements (which can't be + // or contain matches) by moving the `start` pointer forward and `end` + // backward. + while (start !== end && start.nodeType !== 1) + start = start.nextSibling; + while (start !== end && end.nodeType !== 1) + end = end.previousSibling; + if (start.nodeType !== 1) + return []; // no top-level elements! start === end and it's not an element + + // resultsPlus includes matches that are contained by the range's + // parent, but are outside of start..end, i.e. are descended from + // (or are) a different sibling. + var resultsPlus = DomUtils.findElement(container, selector); + + // Filter the list of nodes to remove nodes that occur before start + // or after end. + return _.reject(resultsPlus, function(n) { + // reject node if (n,start) are in order or (end,n) are in order + return (DomUtils.elementOrder(n, start) > 0) || + (DomUtils.elementOrder(end, n) > 0); + }); + }; + + + // Returns 0 if the nodes are the same or either one contains the other; + // otherwise, 1 if a comes before b, or else -1 if b comes before a in + // document order. + // Requires: `a` and `b` are element nodes in the same document tree. + DomUtils.elementOrder = function(a, b) { + // See http://ejohn.org/blog/comparing-document-position/ + if (a === b) + return 0; + if (a.compareDocumentPosition) { + var n = a.compareDocumentPosition(b); + return ((n & 0x18) ? 0 : ((n & 0x4) ? 1 : -1)); + } else { + // Only old IE is known to not have compareDocumentPosition (though Safari + // originally lacked it). Thankfully, IE gives us a way of comparing elements + // via the "sourceIndex" property. + if (a.contains(b) || b.contains(a)) + return 0; + return (a.sourceIndex < b.sourceIndex ? 1 : -1); + } + }; + + // Wrap `frag` as necessary to prepare it for insertion in + // `container`. For example, if `frag` has TR nodes at top level, + // and `container` is a TABLE, then it's necessary to wrap `frag` in + // a TBODY to avoid IE quirks. + // + // `frag` is a DocumentFragment and will be modified in + // place. `container` is a DOM element. + DomUtils.wrapFragmentForContainer = function(frag, container) { + if (container && container.nodeName === "TABLE" && + _.any(frag.childNodes, + function(n) { return n.nodeName === "TR"; })) { + // Avoid putting a TR directly in a TABLE without an + // intervening TBODY, because it doesn't work in IE. We do + // the same thing on all browsers for ease of testing + // and debugging. + var tbody = document.createElement("TBODY"); + tbody.appendChild(frag); + frag.appendChild(tbody); + } + }; + + // Return true if `node` is part of the global DOM document. Like + // elementContains(document, node), except (1) it works for any node + // (eg, text nodes), not just elements; (2) it works around browser + // quirks that would otherwise come up when passing 'document' as + // the first argument to elementContains. + // + // Returns true if node === document. + DomUtils.isInDocument = function (node) { + // Deal with all cases where node is not an element + // node descending from the body first... + if (node === document) + return true; + + if (node.nodeType !== 1 /* Element */) + node = node.parentNode; + if (! (node && node.nodeType === 1)) + return false; + if (node === document.body) + return true; + + return DomUtils.elementContains(document.body, node); + }; + + +})(); diff --git a/packages/liveui/domutils.js b/packages/liveui/domutils.js index 0c8c460f48..16cc1479f4 100644 --- a/packages/liveui/domutils.js +++ b/packages/liveui/domutils.js @@ -21,7 +21,7 @@ Meteor.ui._elementContains = function(a, b) { // Returns an array of element nodes matching `selector`, where // the selector is interpreted as rooted at `contextNode`. // This means that all nodes that participate in the selector -// must be descendents on contextNode. +// must be descendents of contextNode. // // jQuery dependency to eventually replace with querySelectorAll // backed up by Sizzle in Old IE. Note that querySelectorAll doesn't @@ -46,9 +46,10 @@ Meteor.ui._findElement = function(contextNode, selector) { } }; -// Requires: `a` and `b` are element nodes in the same document tree. // Returns 0 if the nodes are the same or either one contains the other; -// otherwise, 1 if (a,b) are in order and -1 if they are in the opposite order. +// otherwise, 1 if a comes before b, or else -1 if b comes before a in +// document order. +// Requires: `a` and `b` are element nodes in the same document tree. Meteor.ui._elementOrder = function(a, b) { // See http://ejohn.org/blog/comparing-document-position/ if (a === b) @@ -66,8 +67,15 @@ Meteor.ui._elementOrder = function(a, b) { } }; -// Like `findElement` but uses a hypothetical LiveRange wrapping start..end -// as the context. +// Like `findElement` but searches the nodes from `start` to `end` +// inclusive. `start` and `end` must be siblings, and they participate +// in the search (they can be used to match selector components, and +// they can appear in the returned results). It's as if the parent of +// `start` and `end` serves as contextNode, but matches from children +// that aren't between `start` and `end` (inclusive) are ignored. +// +// If `selector` involves sibling selectors, child index selectors, or +// the like, the results are undefined. Meteor.ui._findElementInRange = function(start, end, selector) { end = (end || start); @@ -122,6 +130,11 @@ Meteor.ui._isNodeOnscreen = function (node) { return Meteor.ui._elementContains(document.body, node); }; +// Wraps the contents of `frag`, a DocumentFragment, if necessary +// to insert the fragment into `container`, a DOM element. +// For example, if `frag` has TR nodes as children and container +// is a TABLE, the children of `frag` will be wrapped with a +// TBODY in place to work around IE quirks. Meteor.ui._wrapFragmentForContainer = function(frag, container) { if (container && container.nodeName === "TABLE" && _.any(frag.childNodes, diff --git a/packages/liveui/livedocument.js b/packages/liveui/livedocument.js index 790b1b226e..4262af18b2 100644 --- a/packages/liveui/livedocument.js +++ b/packages/liveui/livedocument.js @@ -126,7 +126,7 @@ Meteor.ui._doc = Meteor.ui._doc || {}; var next = comment.nextSibling; - Meteor.ui._wrapFragmentForContainer(subFrag, comment.parentNode); + DomUtils.wrapFragmentForContainer(subFrag, comment.parentNode); comment.parentNode.replaceChild(subFrag, comment); return next; @@ -182,7 +182,7 @@ Meteor.ui._doc = Meteor.ui._doc || {}; throw new Error("Double-GCed range: "+range.id); if (! (node.parentNode && - (Meteor.ui._isNodeOnscreen(node) || + (DomUtils.isInDocument(node) || Meteor.ui._doc._isNodeHeld(node)))) { // range is offscreen! // kill all ranges in this fragment or detached DOM tree, diff --git a/packages/liveui/liveevents_w3c.js b/packages/liveui/liveevents_w3c.js index d251536bb6..c7d3442ac7 100644 --- a/packages/liveui/liveevents_w3c.js +++ b/packages/liveui/liveevents_w3c.js @@ -128,7 +128,7 @@ Meteor.ui._event._loadW3CImpl = function() { // relatedTarget is present and a descendent). (! event.relatedTarget || (event.currentTarget !== event.relatedTarget && - ! Meteor.ui._elementContains( + ! DomUtils.elementContains( event.currentTarget, event.relatedTarget)))) { if (event.type === 'mouseover'){ sendUIEvent('mouseenter', event.currentTarget, false); diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js index f7a045bcd8..839d46ad00 100644 --- a/packages/liveui/liveui.js +++ b/packages/liveui/liveui.js @@ -61,7 +61,7 @@ Meteor.ui = Meteor.ui || {}; Meteor.ui._inRender = false; } - Meteor.ui._wrapFragmentForContainer(frag, range.containerNode()); + DomUtils.wrapFragmentForContainer(frag, range.containerNode()); // Perform patching var nodeMatches = matchChunks(range, frag); @@ -540,7 +540,7 @@ Meteor.ui = Meteor.ui || {}; var node = range.firstNode(); if (node.parentNode && - (Meteor.ui._isNodeOnscreen(node) || Sarge.isNodeHeld(node))) + (DomUtils.isInDocument(node) || Sarge.isNodeHeld(node))) return false; while (node.parentNode) @@ -673,7 +673,7 @@ Meteor.ui = Meteor.ui || {}; var selector = h.selector; if (selector) { var contextNode = range.containerNode(); - var results = Meteor.ui._findElement(contextNode, selector); + var results = DomUtils.findElement(contextNode, selector); if (! _.contains(results, curNode)) continue; } else { @@ -757,7 +757,7 @@ Meteor.ui = Meteor.ui || {}; var collectLabeledNodes = function(range, preserveMap) { var labeledNodes = {}; _.each(preserveMap, function(labelFunc, sel) { - var matchingNodes = Meteor.ui._findElementInRange( + var matchingNodes = DomUtils.findElementInRange( range.firstNode(), range.lastNode(), sel); _.each(matchingNodes, function(n) { // labelFunc can be a function or a constant, @@ -857,7 +857,7 @@ Meteor.ui = Meteor.ui || {}; if (pair) { var tgt = pair[0]; if (! lastTgtMatch || - Meteor.ui._elementOrder(lastTgtMatch, tgt) > 0) { + DomUtils.elementOrder(lastTgtMatch, tgt) > 0) { if (pair.rangeMatch) { // range match! for constant chunk if (patcher.match(pair[0], pair[1], null, true)) { diff --git a/packages/liveui/patcher.js b/packages/liveui/patcher.js index 5ef3bcc534..ec22f67270 100644 --- a/packages/liveui/patcher.js +++ b/packages/liveui/patcher.js @@ -93,7 +93,6 @@ Meteor.ui._Patcher.prototype.match = function( var starting = ! lastKeptTgt; var finishing = ! tgt; - var elementContains = Meteor.ui._elementContains; if (! starting) { // move lastKeptTgt/lastKeptSrc forward and out, @@ -101,7 +100,7 @@ Meteor.ui._Patcher.prototype.match = function( // replacing as we go. If tgt/src is falsy, we make it to the // top level. while (lastKeptTgt.parentNode !== this.tgtParent && - ! (tgt && elementContains(lastKeptTgt.parentNode, tgt))) { + ! (tgt && DomUtils.elementContains(lastKeptTgt.parentNode, tgt))) { // Last-kept nodes are inside parents that are not // parents of the newly matched nodes. Must finish // replacing their contents and back out.