From a1e90afd98bb0c16623c33ad328eebfa98500ddb Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Fri, 6 Jul 2012 12:21:05 -0700 Subject: [PATCH] find matching preserved nodes by selector (in theory) --- packages/liveui/domutils.js | 94 +++++++++++++++++++++++++++++++++++++ packages/liveui/liveui.js | 91 +++++++++++++++++++++++------------ packages/liveui/package.js | 1 + packages/liveui/patcher.js | 16 +------ 4 files changed, 157 insertions(+), 45 deletions(-) create mode 100644 packages/liveui/domutils.js diff --git a/packages/liveui/domutils.js b/packages/liveui/domutils.js new file mode 100644 index 0000000000..cbc1578b3f --- /dev/null +++ b/packages/liveui/domutils.js @@ -0,0 +1,94 @@ +Meteor.ui = Meteor.ui || {}; + +// returns true if element a properly contains element b +Meteor.ui._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 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. +// +// jQuery dependency to eventually replace with querySelectorAll +// backed up by Sizzle in Old IE. Note that querySelectorAll doesn't +// provide the needed semantics for scoping the selector to contextNode; +// for example, myDiv.querySelectorAll("body *") will match all of myDiv's +// descendents, while $(myDiv).find("body *") won't match any. The latter +// behavior is definitely better, and the way to implement it is to temporarily +// assign an ID to contextNode (if it doesn't have one). +Meteor.ui._findElement = function(contextNode, selector) { + return $(contextNode).find(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. +Meteor.ui._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); + } +}; + +// Like `findElement` but uses a hypothetical LiveRange wrapping start..end +// as the context. +Meteor.ui._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 Meteor.ui._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 = Meteor.ui._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 (Meteor.ui._elementOrder(n, start) > 0) || + (Meteor.ui._elementOrder(end, n) > 0); + }); +}; \ No newline at end of file diff --git a/packages/liveui/liveui.js b/packages/liveui/liveui.js index f0a1e90cc8..c33900725c 100644 --- a/packages/liveui/liveui.js +++ b/packages/liveui/liveui.js @@ -2,7 +2,7 @@ Meteor.ui = Meteor.ui || {}; // TODO: // -// - Match DOM elements in chunks based on "preserve" +// - Perform DOM patching based on "preserve" // - {constant:true} chunk options (function() { @@ -269,7 +269,10 @@ Meteor.ui = Meteor.ui || {}; if (mode === "patch") { // Rendering top level of the current update, with patching - copyChunkState(range, frag); + + var nodeMatches = matchChunks(range, frag); + // XXX use nodeMatches + range.operate(function(start, end) { Sarge.shuck(start, end); @@ -491,23 +494,19 @@ Meteor.ui = Meteor.ui || {}; // Check whether a node is contained in the document. isNodeOnscreen: function (node) { - // http://jsperf.com/is-element-in-the-dom + // Deal with all cases where node is not an element + // node descending from the body first... + if (node === document) + return true; - if (document.compareDocumentPosition) - return document.compareDocumentPosition(node) & 16; - else { - if (node.nodeType !== 1 /* Element */) - /* contains() doesn't work reliably on non-Elements. Fine on - Chrome, not so much on Safari and IE. */ - node = node.parentNode; - if (node.nodeType === 11 /* DocumentFragment */ || - node.nodeType === 9 /* Document */) - /* contains() chokes on DocumentFragments on IE8 */ - return node === document; - /* contains() exists on document on Chrome, but only on - document.body on some other browsers. */ - return document.body.contains(node); - } + if (node.nodeType !== 1 /* Element */) + node = node.parentNode; + if (! (node && node.nodeType === 1)) + return false; + if (node === document.body) + return true; + + return Meteor.ui._elementContains(document.body, node); }, // Internal facility, only used by tests, for holding onto @@ -632,7 +631,7 @@ Meteor.ui = Meteor.ui || {}; var selector = h.selector; if (selector) { var contextNode = range.containerNode(); - var results = $(contextNode).find(selector); + var results = Meteor.ui._findElement(contextNode, selector); if (! _.contains(results, curNode)) continue; } else { @@ -677,12 +676,13 @@ Meteor.ui = Meteor.ui || {}; // Match branch keys and copy chunkState from liveranges in the // interior of oldRange onto matching liveranges in newFrag. - var copyChunkState = function(oldRange, newFrag) { + // Return pairs of matching DOM nodes to preserve. + var matchChunks = function(oldRange, newFrag) { if (! newFrag.firstChild) - return; // allow empty newFrag + return []; // allow empty newFrag - var oldChunks = {}; - var currentPath = []; + var oldChunks = {}; // { path -> range } + var currentPath = []; // list of branch keys (path segments) // visit the interior of outerRange and call // `func(r, path)` on every range with a branch key, @@ -706,25 +706,57 @@ Meteor.ui = Meteor.ui || {}; // collect old chunks keyed by their branch key paths eachKeyedChunk(oldRange, function(r, path) { oldChunks[path] = r; - - // XXX preserve }); + // Run the selectors from preserveMap over the nodes + // in range and create a map { label -> node }. + var collectLabeledNodes = function(range, preserveMap) { + var labeledNodes = {}; + _.each(preserveMap, function(labelFunc, sel) { + var matchingNodes = Meteor.ui._findElementInRange( + range.firstNode(), range.lastNode(), sel); + _.each(matchingNodes, function(n) { + // labelFunc can be a function or a constant, + // the latter for single-match selectors {'.foo': 1} + var pernodeLabel = ( + typeof labelFunc === 'function' ? labelFunc(n) : labelFunc); + var fullLabel = sel+'/'+pernodeLabel; + // in case of duplicates, we ignore the second node (this one). + // eventually, the developer might want to get debug info. + if (! labeledNodes[fullLabel]) + labeledNodes[fullLabel] = n; + }); + }); + return labeledNodes; + }; + + var nodeMatches = []; // [[oldNode, newNode], ...] + // create a temporary range around newFrag in order // to visit it. var tempRange = new Meteor.ui._LiveRange(Meteor.ui._tag, newFrag); // visit new frag eachKeyedChunk(tempRange, function(r, path) { - var oldR = oldChunks[path]; - if (oldR) { + var oldRange = oldChunks[path]; + if (oldRange) { // copy over chunkState - r.chunkState = oldR.chunkState; - oldR.chunkState = null; // don't call offscreen() on old range + r.chunkState = oldRange.chunkState; + oldRange.chunkState = null; // don't call offscreen() on old range // any second occurrence of `path` is ignored (not matched) delete oldChunks[path]; + + var oldLabeledNodes = collectLabeledNodes(oldRange, r.preserve); + var newLabeledNodes = collectLabeledNodes(r, r.preserve); + _.each(newLabeledNodes, function(newNode, label) { + var oldNode = oldLabeledNodes[label]; + if (oldNode) + nodeMatches.push([oldNode, newNode]); + }); } }); tempRange.destroy(); + + return nodeMatches; }; var diffPatch = function(tgtParent, srcParent, tgtBefore, tgtAfter) { @@ -808,5 +840,4 @@ Meteor.ui = Meteor.ui || {}; }; - })(); diff --git a/packages/liveui/package.js b/packages/liveui/package.js index 4893de698f..02387ba287 100644 --- a/packages/liveui/package.js +++ b/packages/liveui/package.js @@ -13,6 +13,7 @@ Package.on_use(function (api) { // you still want the event object normalization that jquery provides?) api.use('jquery'); + api.add_files(['domutils.js'], 'client'); api.add_files(['liveevents_w3c.js', 'liveevents_now3c.js'], 'client'); api.add_files(['liveevents.js'], 'client'); api.add_files(['liverange.js', 'liveui.js', 'innerhtml.js', 'patcher.js'], diff --git a/packages/liveui/patcher.js b/packages/liveui/patcher.js index ea52a8a60e..dff3b4f95b 100644 --- a/packages/liveui/patcher.js +++ b/packages/liveui/patcher.js @@ -92,7 +92,7 @@ Meteor.ui._Patcher.prototype.match = function(tgtNode, srcNode, copyCallback) { var starting = ! lastKeptTgt; var finishing = ! tgt; - var elementContains = Meteor.ui._Patcher._elementContains; + var elementContains = Meteor.ui._elementContains; if (! starting) { // move lastKeptTgt/lastKeptSrc forward and out, @@ -377,17 +377,3 @@ Meteor.ui._Patcher._copyAttributes = function(tgt, src) { } }; - -// returns true if element a properly contains element b -Meteor.ui._Patcher._elementContains = function(a, b) { - if (a.nodeType !== 1 || b.nodeType !== 1) { - return false; - } - 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); - } -};