mirror of
https://github.com/meteor/meteor.git
synced 2026-05-02 03:01:46 -04:00
309 lines
11 KiB
JavaScript
309 lines
11 KiB
JavaScript
Meteor.ui = Meteor.ui || {};
|
|
|
|
|
|
// tgtParentOrRange can be anything with firstNode() and lastNode()
|
|
// methods; need not be a functioning LiveRange
|
|
Meteor.ui._Patcher = function(tgtParentOrRange, srcParent) {
|
|
if (typeof tgtParentOrRange.firstNode === "function") {
|
|
this.beforeTgt = tgtParentOrRange.firstNode().previousSibling;
|
|
this.afterTgt = tgtParentOrRange.lastNode().nextSibling;
|
|
this.tgtParent = tgtParentOrRange.firstNode().parentNode;
|
|
if (! this.tgtParent)
|
|
throw new Error("Can't patch liverange with no parent ndoe");
|
|
} else {
|
|
this.tgtParent = tgtParentOrRange;
|
|
}
|
|
this.srcParent = srcParent;
|
|
|
|
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.
|
|
Meteor.ui._Patcher.prototype.match = function(tgtNode, srcNode, copyCallback) {
|
|
|
|
// 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;
|
|
var elementContains = Meteor.ui._Patcher._elementContains;
|
|
|
|
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 && 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 <c> in (<a><b/><c/></a>, <a><b/></a><a><c/></a>)
|
|
// after matching <b>, 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 <b/> node
|
|
// on the left, which is not sibling of <c/> or of an ancestor
|
|
// of <c/> on the right. If the example were reversed,
|
|
// lastKeptTgt would be the first <a> node, which is an
|
|
// ancestor of <c/> on the left rather than a sibling of an
|
|
// ancestor.
|
|
if (! finishing &&
|
|
(elementContains(lastKeptSrc, src) ||
|
|
! (lastKeptSrc.parentNode === this.srcParent ||
|
|
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.
|
|
// Look at tags of parents until we hit parent of last-kept,
|
|
// which we know is ok.
|
|
for(var a=tgt, b=src;
|
|
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
|
|
}
|
|
|
|
// move tgt and src backwards and out, replacing as we go
|
|
while (true) {
|
|
Meteor.ui._Patcher._copyAttributes(tgt, src);
|
|
if (copyCallback)
|
|
copyCallback(tgt, src);
|
|
|
|
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;
|
|
};
|
|
|
|
Meteor.ui._Patcher.prototype.finish = function() {
|
|
return this.match(null, null);
|
|
};
|
|
|
|
// Replaces the siblings between beforeTgt and afterTgt (exclusive on both
|
|
// sides) with the siblings between beforeSrc and afterSrc (exclusive on both
|
|
// sides). Falsy values indicate start or end of siblings as appropriate.
|
|
//
|
|
// Precondition: beforeTgt and afterTgt have same parent; either may be falsy,
|
|
// but not both, unless optTgtParent is provided. Same with beforeSrc/afterSrc.
|
|
Meteor.ui._Patcher.prototype._replaceNodes = function(
|
|
beforeTgt, afterTgt, beforeSrc, afterSrc, optTgtParent, optSrcParent)
|
|
{
|
|
var tgtParent = optTgtParent || (beforeTgt || afterTgt).parentNode;
|
|
var srcParent = optSrcParent || (beforeSrc || afterSrc).parentNode;
|
|
|
|
// deal with case where top level is a range
|
|
if (tgtParent === this.tgtParent) {
|
|
beforeTgt = beforeTgt || this.beforeTgt;
|
|
afterTgt = afterTgt || this.afterTgt;
|
|
}
|
|
if (srcParent === this.srcParent) {
|
|
beforeSrc = beforeSrc || this.beforeSrc;
|
|
afterSrc = afterSrc || this.afterSrc;
|
|
}
|
|
|
|
|
|
// remove old children
|
|
var n;
|
|
while ((n = beforeTgt ? beforeTgt.nextSibling : tgtParent.firstChild)
|
|
&& n !== afterTgt) {
|
|
tgtParent.removeChild(n);
|
|
}
|
|
|
|
// add new children
|
|
var m;
|
|
while ((m = beforeSrc ? beforeSrc.nextSibling : srcParent.firstChild)
|
|
&& m !== afterSrc) {
|
|
tgtParent.insertBefore(m, afterTgt || null);
|
|
}
|
|
};
|
|
|
|
|
|
Meteor.ui._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);
|
|
|
|
// clear current attributes
|
|
|
|
if (tgt.style.cssText)
|
|
tgt.style.cssText = '';
|
|
|
|
if (tgt.nodeName === "INPUT") {
|
|
if (typeof tgt.checked === "boolean")
|
|
tgt.checked = false;
|
|
}
|
|
|
|
for(var i=tgtAttrs.length-1; i>=0; i--) {
|
|
var attr = tgtAttrs[i];
|
|
if (! attr.specified)
|
|
continue;
|
|
var name = attr.name;
|
|
if (! tgtAttrs[name])
|
|
continue; // catches weird "propdescname" in IE 8
|
|
if (name === "id" || name === "type")
|
|
continue;
|
|
// never delete value attribute, only overwrite the property
|
|
if (name === "value")
|
|
continue;
|
|
var possibleExpando = tgt[name];
|
|
if (possibleExpando && typeof possibleExpando === "object")
|
|
continue; // for object properties that surface attributes only in IE
|
|
tgt.removeAttributeNode(attr);
|
|
}
|
|
|
|
// copy over src's attributes
|
|
|
|
if (tgt.mergeAttributes) {
|
|
// IE code path:
|
|
|
|
tgt.mergeAttributes(src);
|
|
if (typeof tgt.checked !== "undefined" ||
|
|
typeof src.checked !== "undefined")
|
|
tgt.checked = src.checked;
|
|
if (src.nodeName === "INPUT" && src.type === "text") {
|
|
if (! target_focused)
|
|
tgt.value = src.value;
|
|
}
|
|
if (src.name)
|
|
tgt.name = src.name;
|
|
|
|
} else {
|
|
// non-IE code path:
|
|
|
|
for(var i=0, L=srcAttrs.length; i<L; i++) {
|
|
var srcA = srcAttrs.item(i);
|
|
if (srcA.specified) {
|
|
var name = srcA.name.toLowerCase();
|
|
var value = String(srcA.value);
|
|
if (name === "type") {
|
|
// can't change type of INPUT in IE; don't support it
|
|
} else if (name === "checked") {
|
|
tgt.checked = tgt.defaultChecked = (value && value !== "false");
|
|
tgt.setAttribute("checked", "checked");
|
|
} else if (name === "style") {
|
|
tgt.style.cssText = src.style.cssText;
|
|
} else if (name === "class") {
|
|
tgt.className = src.className;
|
|
} else if (name === "value") {
|
|
// don't set attribute, just overwrite property
|
|
if (! target_focused)
|
|
tgt.value = src.value;
|
|
} else {
|
|
tgt.setAttribute(name, value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
};
|
|
|
|
// 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);
|
|
}
|
|
};
|
|
|