From 4f52054cd95612f4bafe6cf7a916bbb8580193ea Mon Sep 17 00:00:00 2001 From: David Greenspan Date: Thu, 2 Aug 2012 19:39:00 -0700 Subject: [PATCH] patching works in Spark! --- packages/liveui/liveui_tests.js | 148 ---------- packages/spark/package.js | 2 +- packages/spark/patch.js | 478 ++++++++++++++++++++++++++++++++ packages/spark/spark.js | 56 +++- packages/spark/spark_tests.js | 180 ++++++++++++ 5 files changed, 707 insertions(+), 157 deletions(-) create mode 100644 packages/spark/patch.js diff --git a/packages/liveui/liveui_tests.js b/packages/liveui/liveui_tests.js index 66d22c449f..1ba76bf8f9 100644 --- a/packages/liveui/liveui_tests.js +++ b/packages/liveui/liveui_tests.js @@ -121,154 +121,6 @@ Tinytest.add("liveui - tables", function(test) { }); -Tinytest.add("liveui - preserved nodes (diff/patch)", function(test) { - - var rand; - - var randomNodeList = function(optParentTag, depth) { - var atTopLevel = ! optParentTag; - var len = rand.nextIntBetween(atTopLevel ? 1 : 0, 6); - var buf = []; - for(var i=0; i'); - nodeListToHtml(n.children, is_after, buf); - buf.push(''); - } - } - }); - return optBuf ? null : buf.join(''); - }; - - var fillInElementIdentities = function(list, parent, is_after) { - var elementsInList = _.filter( - list, - function(x) { - return (is_after ? x.existsAfter : x.existsBefore) && x.tagName; - }); - var elementsInDom = _.filter(parent.childNodes, - function(x) { return x.nodeType === 1; }); - test.equal(elementsInList.length, elementsInDom.length); - for(var i=0; i"); - fillInElementIdentities(structure, frag.node()); - var labeledNodes = collectLabeledNodeData(structure); - R.set(true); - Meteor.flush(); - test.equal(frag.html(), nodeListToHtml(structure, true) || ""); - _.each(labeledNodes, function(x) { - test.isTrue(isSameElements(x.parents, getParentChain(x.node))); - }); - - frag.release(); - Meteor.flush(); - test.equal(R.numListeners(), 0); - } - -}); Tinytest.add("liveui - copied attributes", function(test) { // make sure attributes are correctly changed (i.e. copied) diff --git a/packages/spark/package.js b/packages/spark/package.js index 9a4e32c50a..210de09728 100644 --- a/packages/spark/package.js +++ b/packages/spark/package.js @@ -13,7 +13,7 @@ Package.on_use(function (api) { // you still want the event object normalization that jquery provides?) api.use('jquery'); - api.add_files(['spark.js', 'convenience.js'], 'client'); + api.add_files(['spark.js', 'patch.js', 'convenience.js'], 'client'); /* api.add_files(['liveevents_w3c.js', 'liveevents_now3c.js'], 'client'); api.add_files(['liveevents.js'], 'client'); diff --git a/packages/spark/patch.js b/packages/spark/patch.js new file mode 100644 index 0000000000..647e275d15 --- /dev/null +++ b/packages/spark/patch.js @@ -0,0 +1,478 @@ + +Spark._patch = function(tgtParent, srcParent, tgtBefore, tgtAfter, preservations) { + + var copyFunc = function(t, s) { + LiveRange.transplant_tag(Spark._TAG, t, s); + }; + + var patcher = new Spark._Patcher( + tgtParent, srcParent, tgtBefore, tgtAfter); + + + var visitNodes = function(parent, before, after, func) { + for(var n = before ? before.nextSibling : parent.firstChild; + n && n !== after; + n = n.nextSibling) { + if (func(n) !== false && n.firstChild) + visitNodes(n, null, null, func); + } + }; + + var lastTgtMatch = null; + + visitNodes(srcParent, null, null, function(src) { + // XXX inefficient to scan for match for every node! + // We could at least skip non-element nodes, except for "range matches" + // used for constant chunks, which may begin on a non-element. + // But really this shouldn't be a linear search. + var pres = _.find(preservations, function (p) { + // find preserved region starting at `src`, if any + return p.type === 'region' && p.newRange.firstNode() === src; + }) || _.find(preservations, function (p) { + // else, find preservation of `src` + return p.type === 'node' && p.to === src; + }); + + if (pres) { + var tgt = (pres.type === 'region' ? pres.fromStart : pres.from); + if (! lastTgtMatch || + DomUtils.elementOrder(lastTgtMatch, tgt) > 0) { + if (pres.type === 'region') { + // preserved region for constant landmark + if (patcher.match(pres.fromStart, pres.newRange.firstNode(), null, true)) { + patcher.skipToSiblings(pres.fromEnd, pres.newRange.lastNode()); + // without knowing or caring what DOM nodes are in pres.newRange, + // transplant the range data to pres.fromStart and pres.fromEnd + // (including references to enclosing ranges). + LiveRange.transplant_range( + pres.fromStart, pres.fromEnd, pres.newRange); + } + } else if (pres.type === 'node') { + if (patcher.match(tgt, src, copyFunc)) { + // match succeeded + lastTgtMatch = tgt; + if (tgt.firstChild || src.firstChild) { + // Don't patch contents of TEXTAREA tag, + // which are only the initial contents but + // may affect the tag's .value in IE. + if (tgt.nodeName !== "TEXTAREA") { + // recurse! + Spark._patch(tgt, src, null, null, preservations); + } + } + return false; // tell visitNodes not to recurse + } + } + } + } + return true; + }); + + patcher.finish(); + +}; + + +// A Patcher manages the controlled replacement of a region of the DOM. +// The target region is changed in place to match the source region. +// +// The target region consists of the children of tgtParent, extending from +// the child after tgtBefore to the child before tgtAfter. A null +// or absent tgtBefore or tgtAfter represents the beginning or end +// of tgtParent's children. The source region consists of all children +// of srcParent, which may be a DocumentFragment. +// +// To use a new Patcher, call `match` zero or more times followed by +// `finish`. +// +// A match is a correspondence between an old node in the target region +// and a new node in the source region that will replace it. Based on +// this correspondence, the target node is preserved and the attributes +// and children of the source node are copied over it. The `match` +// method declares such a correspondence. A Patcher that makes no matches, +// for example, just removes the target nodes and inserts the source nodes +// in their place. +// +// Constructor: +Spark._Patcher = function(tgtParent, srcParent, tgtBefore, tgtAfter) { + this.tgtParent = tgtParent; + this.srcParent = srcParent; + + this.tgtBefore = tgtBefore; + this.tgtAfter = tgtAfter; + + this.lastKeptTgtNode = null; + this.lastKeptSrcNode = null; +}; + + +// Advances the patching process up to tgtNode in the target tree, +// and srcNode in the source tree. tgtNode will be preserved, with +// the attributes of srcNode copied over it, in essence identifying +// the two nodes with each other. The same treatment is given to +// any parents of the nodes that are newly implicated as corresponding. +// In the process of traversing from the last matched nodes to these +// ones, all nodes "in between" in the target document, at any level, +// are removed, and all nodes "in between" in the source document +// are copied over to their appropriate positions. +// +// For example, if match() is called only once, and then finish() +// is called, the effect is to preserve tgtNode, its children, +// and its ancestors (parent chain), while swapping out all its +// siblings and the siblings of its ancestors, so that the target +// tree is mutated to look like the source tree did. +// +// The caller is responsible for ensuring the precondition that +// subsequent tgtNodes and subsequent srcNodes are strictly "in order." +// The ordering referred to here is a partial order in which A comes +// before B if their tags would be disjoint in HTML, i.e. the end of +// A comes before the beginning of B. Put another way, there is some +// ancestor of A and some ancestor of B that have the same parent, +// are different, and are in order. +// +// There are other requirements for two nodes to be "matched," +// but match() can detect them and exit gracefully returning false. +// For example, the tag-names must be the same, and the tag-names +// of their parents. More subtly, it may be impossible to match +// the parents of tgtNode or srcNode because they have been +// previously matched. If we are to match a series of P tags +// that are each inside one DIV, for example, is it the same DIV +// or not? If the source and target disagree, we will have to +// reparent one of the Ps. Users should not be moving identified +// nodes, but we want to still be correct (fall back on replacement) +// if they do. +// +// If false is returned, the match was impossible, but patching +// can continue and will still be otherwise correct. The next call +// to match() must still obey the order constraint, as the patcher +// internally only moves forwards and patches as it goes. +// +// copyCallback is called on every new matched (tgt, src) pair +// right after copying attributes. It's a good time to transplant +// liveranges and patch children. +Spark._Patcher.prototype.match = function( + tgtNode, srcNode, copyCallback, onlyAdvance) { + + // last nodes "kept" (matched/identified with each other) + var lastKeptTgt = this.lastKeptTgtNode; + var lastKeptSrc = this.lastKeptSrcNode; + // nodes to match and keep, this time around + var tgt = tgtNode; + var src = srcNode; + + if ((! tgt) != (! src)) { + return false; // truthinesses don't match + } + + var starting = ! lastKeptTgt; + var finishing = ! tgt; + + if (! starting) { + // move lastKeptTgt/lastKeptSrc forward and out, + // until they are siblings of tgt/src or of an ancestor of tgt/src, + // replacing as we go. If tgt/src is falsy, we make it to the + // top level. + while (lastKeptTgt.parentNode !== this.tgtParent && + ! (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. + this._replaceNodes(lastKeptTgt, null, lastKeptSrc, null); + lastKeptTgt = lastKeptTgt.parentNode; + lastKeptSrc = lastKeptSrc.parentNode; + } + + // update instance vars; there's no going back inside these nodes + this.lastKeptTgtNode = lastKeptTgt; + this.lastKeptSrcNode = lastKeptSrc; + + // Make sure same number of levels of "moving up" are + // appropriate for src as well, i.e. we aren't trying + // to match in (, ) + // after matching , or vice versa. In other words, + // if tag names and depths match, but identities of parents + // are inconsistent relative to previous matches, we catch it + // here. In the example, lastKeptTgt would be the node + // on the left, which is not sibling of or of an ancestor + // of on the right. If the example were reversed, + // lastKeptTgt would be the first node, which is an + // ancestor of on the left rather than a sibling of an + // ancestor. + if (! finishing && + (DomUtils.elementContains(lastKeptSrc, src) || + ! (lastKeptSrc.parentNode === this.srcParent || + DomUtils.elementContains(lastKeptSrc.parentNode, src)))) { + return false; + } + } + + if (finishing) { + this._replaceNodes(lastKeptTgt, null, lastKeptSrc, null, + this.tgtParent, this.srcParent); + } else { + // Compare tag names and depths to make sure we can match nodes... + if (! onlyAdvance) { + if (tgt.nodeName !== src.nodeName) + return false; + } + + // Look at tags of parents until we hit parent of last-kept, + // which we know is ok. + for(var a=tgt.parentNode, b=src.parentNode; + a !== (starting ? this.tgtParent : lastKeptTgt.parentNode); + a = a.parentNode, b = b.parentNode) { + if (b === (starting ? this.srcParent : lastKeptSrc.parentNode)) + return false; // src is shallower, b hit top first + if (a.nodeName !== b.nodeName) + return false; // tag names don't match + } + if (b !== (starting ? this.srcParent : lastKeptSrc.parentNode)) { + return false; // src is deeper, b didn't hit top when a did + } + + var firstIter = true; + // move tgt and src backwards and out, replacing as we go + while (true) { + if (! (firstIter && onlyAdvance)) { + Spark._Patcher._copyAttributes(tgt, src); + if (copyCallback) + copyCallback(tgt, src); + } + + firstIter = false; + + if ((starting ? this.tgtParent : lastKeptTgt.parentNode) + === tgt.parentNode) { + // we've worked our way up to the same level as the last-kept nodes + this._replaceNodes(lastKeptTgt, tgt, lastKeptSrc, src); + break; + } else { + this._replaceNodes(null, tgt, null, src); + // move up to keep (match) parents as well + tgt = tgt.parentNode; + src = src.parentNode; + } + } + } + + this.lastKeptTgtNode = tgtNode; + this.lastKeptSrcNode = srcNode; + + return true; +}; + +// After a match, skip ahead to later siblings of the last kept nodes, +// without performing any replacements. +Spark._Patcher.prototype.skipToSiblings = function(tgt, src) { + var lastTgt = this.lastKeptTgtNode; + var lastSrc = this.lastKeptSrcNode; + + if (! (lastTgt && lastTgt.parentNode === tgt.parentNode)) + return false; + + if (! (lastSrc && lastSrc.parentNode === src.parentNode)) + return false; + + this.lastKeptTgtNode = tgt; + this.lastKeptSrcNode = src; + + return true; +}; + +// Completes patching assuming no more matches. +// +// Patchers are single-use, so no more methods can be called +// on the Patcher. +Spark._Patcher.prototype.finish = function() { + return this.match(null, null); +}; + +// Replaces the siblings between tgtBefore and tgtAfter (exclusive on both +// sides) with the siblings between srcBefore and srcAfter (exclusive on both +// sides). Falsy values indicate start or end of siblings as appropriate. +// +// Precondition: tgtBefore and tgtAfter have same parent; either may be falsy, +// but not both, unless optTgtParent is provided. Same with srcBefore/srcAfter. +Spark._Patcher.prototype._replaceNodes = function( + tgtBefore, tgtAfter, srcBefore, srcAfter, optTgtParent, optSrcParent) +{ + var tgtParent = optTgtParent || (tgtBefore || tgtAfter).parentNode; + var srcParent = optSrcParent || (srcBefore || srcAfter).parentNode; + + // deal with case where top level is a range + if (tgtParent === this.tgtParent) { + tgtBefore = tgtBefore || this.tgtBefore; + tgtAfter = tgtAfter || this.tgtAfter; + } + if (srcParent === this.srcParent) { + srcBefore = srcBefore || this.srcBefore; + srcAfter = srcAfter || this.srcAfter; + } + + + // remove old children + var n; + while ((n = tgtBefore ? tgtBefore.nextSibling : tgtParent.firstChild) + && n !== tgtAfter) { + tgtParent.removeChild(n); + } + + // add new children + var m; + while ((m = srcBefore ? srcBefore.nextSibling : srcParent.firstChild) + && m !== srcAfter) { + tgtParent.insertBefore(m, tgtAfter || null); + } +}; + +// Copy HTML attributes of node `src` onto node `tgt`. +// +// The effect we are trying to achieve is best expresed in terms of +// HTML. Whatever HTML generated `tgt`, we want to mutate the DOM element +// so that it is as if it were the HTML that generated `src`. +// We want to preserve JavaScript properties in general (tgt.foo), +// while syncing the HTML attributes (tgt.getAttribute("foo")). +// +// This is complicated by form controls and the fact that old IE +// can't keep the difference straight between properties and attributes. +Spark._Patcher._copyAttributes = function(tgt, src) { + var srcAttrs = src.attributes; + var tgtAttrs = tgt.attributes; + + // Determine whether tgt has focus; works in all browsers + // as of FF3, Safari4 + var target_focused = (tgt === document.activeElement); + + // Is this a control with a user-mutated "value" property? + var has_user_value = ( + (tgt.nodeName === "INPUT" && + (tgt.type === "text")) || + tgt.nodeName === "TEXTAREA"); + + ///// Clear current attributes + + if (tgt.style.cssText) + tgt.style.cssText = ''; + + var isRadio = false; + if (tgt.nodeName === "INPUT") { + // Record for later whether this is a radio button. + isRadio = (tgt.type === 'radio'); + // Clearing the attributes of a checkbox won't necessarily + // uncheck it, eg in FF12, so we uncheck explicitly. + if (typeof tgt.checked === "boolean") + tgt.checked = false; + } + + for(var i=tgtAttrs.length-1; i>=0; i--) { + var attr = tgtAttrs[i]; + // In old IE, attributes that are possible on a node + // but not actually present will show up in this loop + // with specified=false. All other browsers support + // 'specified' (because it's part of the spec) and + // set it to true. + if (! attr.specified) + continue; + var name = attr.name; + // Filter out attributes that are indexable by number + // but not by name. This kills the weird "propdescname" + // attribute in IE 8. + if (! tgtAttrs[name]) + continue; + // Some properties don't mutate well, and we simply + // don't try to patch them. For example, you can't + // change a control's type in IE. + if (name === "id" || name === "type") + continue; + // Removing a radio button's "name" property and restoring + // it is harmless in most browsers but breaks in IE 7. + // It seems unlikely enough that a radio button will + // sometimes have a group and sometimes not. + if (isRadio && name === "name") + continue; + // Never delete the "value" attribute. It's more effective + // to simply overwrite it in the next phase. + if (name === "value") + continue; + // Removing 'src' (e.g. in an iframe) can only be bad. + if (name === "src") + continue; + + // We want to patch any HTML attributes that were specified in the + // source, but preserve DOM properties set programmatically. + // Old IE makes this difficult by exposing properties as attributes. + // Expando properties will even appear in innerHTML, though not if the + // value is an object rather than a primitive. + // + // We use a heuristic to determine if we are looking at a programmatic + // property (an expando) rather than a DOM attribute. + // + // Losing jQuery's expando (whose value is a number) is very bad, + // because it points to event handlers that only jQuery can detach, + // and only if the expando is in place. + var possibleExpando = tgt[name]; + if (possibleExpando && + (typeof possibleExpando === "object" || + /^jQuery/.test(name))) + continue; // for object properties that surface attributes only in IE + tgt.removeAttributeNode(attr); + } + + ///// Copy over src's attributes + + if (tgt.mergeAttributes) { + // IE code path: + // + // Only IE (all versions) has mergeAttributes. + // It's probably a good bit faster in old IE than + // iterating over all the attributes, and the treatment + // of form controls is sufficiently different in IE from + // other browsers that we keep the special cases separate. + + tgt.mergeAttributes(src); + + if (typeof tgt.checked !== "undefined" || + typeof src.checked !== "undefined") + tgt.checked = src.checked; + + if (src.name) + tgt.name = src.name; + + } else { + // Non-IE code path: + + for(var i=0, L=srcAttrs.length; i node + + var visitLabeledNodes = function (range, func) { + var nodes = DomUtils.findAllInRange( + range.firstNode(), range.lastNode(), selector); + _.each(nodes, function (n) { + var label = nodeLabeler(n); + label && func(n, label); + }); + }; + + visitLabeledNodes(from, function (n, label) { + fromNodesByLabel[label] = n; + }); + + visitLabeledNodes(to, function (n, label) { + var match = fromNodesByLabel[label]; + if (match) { + preservations.push({ type: "node", from: match, to: n }); + fromNodesByLabel[label] = null; + } + }); + }); }); + + return preservations; }; // Look for landmarks in oldRange that match landmarks in @@ -575,7 +608,7 @@ var moveLandmarks = function (oldRange, newRange) { }); }; -// Replace the contents of `range` with the fragment `frag`. Return +// Replace the contents of `range` with the fragment `frag`. Finalize // the old contents of `range`. If the old contents had any landmarks // that match landmarks in `frag`, move the landmarks over and perform // any node or region preservations that they request. @@ -585,8 +618,15 @@ var replaceContentsPreservingLandmarks = function (range, frag) { moveLandmarks(range, tempRange); tempRange.destroy(); - // XXX should patch (using preservations) - return range.replace_contents(frag); + // patch (using preservations) + range.operate(function (start, end) { + // XXX this will destroy all liveranges, including ones + // inside constant regions whose DOM nodes we are going + // to preserve untouched + Spark.finalize(start, end); + Spark._patch(start.parentNode, frag, start.previousSibling, + end.nextSibling, preservations); + }); }; // Find all the landmarks in `range` and let them know that they are diff --git a/packages/spark/spark_tests.js b/packages/spark/spark_tests.js index ef4394e79d..1d3c520ebd 100644 --- a/packages/spark/spark_tests.js +++ b/packages/spark/spark_tests.js @@ -1158,4 +1158,184 @@ Tinytest.add("spark - labeled landmarks", function (test) { }); +var legacyLabels = { + '*[id], #[name]': function(n) { + var label = null; + + if (n.nodeType === 1) { + if (n.id) { + label = '#'+n.id; + } else if (n.getAttribute("name")) { + label = n.getAttribute("name"); + // Radio button special case: radio buttons + // in a group all have the same name. Their value + // determines their identity. + // Checkboxes with the same name and different + // values are also sometimes used in apps, so + // we treat them similarly. + if (n.nodeName === 'INPUT' && + (n.type === 'radio' || n.type === 'checkbox') && + n.value) + label = label + ':' + n.value; + } + } + + return label; + } +}; + + +Tinytest.add("spark - preserved nodes (diff/patch)", function(test) { + + var rand; + + var randomNodeList = function(optParentTag, depth) { + var atTopLevel = ! optParentTag; + var len = rand.nextIntBetween(atTopLevel ? 1 : 0, 6); + var buf = []; + for(var i=0; i'); + nodeListToHtml(n.children, is_after, buf); + buf.push(''); + } + } + }); + return optBuf ? null : buf.join(''); + }; + + var fillInElementIdentities = function(list, parent, is_after) { + var elementsInList = _.filter( + list, + function(x) { + return (is_after ? x.existsAfter : x.existsBefore) && x.tagName; + }); + var elementsInDom = _.filter(parent.childNodes, + function(x) { return x.nodeType === 1; }); + test.equal(elementsInList.length, elementsInDom.length); + for(var i=0; i"); + fillInElementIdentities(structure, frag.node()); + var labeledNodes = collectLabeledNodeData(structure); + R.set(true); + Meteor.flush(); + test.equal(frag.html(), nodeListToHtml(structure, true) || ""); + _.each(labeledNodes, function(x) { + test.isTrue(isSameElements(x.parents, getParentChain(x.node))); + }); + + frag.release(); + Meteor.flush(); + test.equal(R.numListeners(), 0); + } + +}); + + + })();