mirror of
https://github.com/meteor/meteor.git
synced 2026-05-02 03:01:46 -04:00
1128 lines
32 KiB
JavaScript
1128 lines
32 KiB
JavaScript
// TODO
|
|
// - Lazy removal detection
|
|
// - UI hooks (expose, test)
|
|
// - Quick remove/add (mark "leaving" members; needs UI hooks)
|
|
// - Event removal on removal
|
|
// - Event moving on TBODY move
|
|
|
|
var DomBackend = UI.DomBackend;
|
|
|
|
var removeNode = function (n) {
|
|
// if (n.nodeType === 1 &&
|
|
// n.parentNode.$uihooks && n.parentNode.$uihooks.removeElement)
|
|
// n.parentNode.$uihooks.removeElement(n);
|
|
// else
|
|
n.parentNode.removeChild(n);
|
|
};
|
|
|
|
var insertNode = function (n, parent, next) {
|
|
// if (n.nodeType === 1 &&
|
|
// parent.$uihooks && parent.$uihooks.insertElement)
|
|
// parent.$uihooks.insertElement(n, parent, next);
|
|
// else
|
|
// `|| null` because IE throws an error if 'next' is undefined
|
|
parent.insertBefore(n, next || null);
|
|
};
|
|
|
|
var moveNode = function (n, parent, next) {
|
|
// if (n.nodeType === 1 &&
|
|
// parent.$uihooks && parent.$uihooks.moveElement)
|
|
// parent.$uihooks.moveElement(n, parent, next);
|
|
// else
|
|
// `|| null` because IE throws an error if 'next' is undefined
|
|
parent.insertBefore(n, next || null);
|
|
};
|
|
|
|
// A very basic operation like Underscore's `_.extend` that
|
|
// copies `src`'s own, enumerable properties onto `tgt` and
|
|
// returns `tgt`.
|
|
var _extend = function (tgt, src) {
|
|
for (var k in src)
|
|
if (src.hasOwnProperty(k))
|
|
tgt[k] = src[k];
|
|
return tgt;
|
|
};
|
|
|
|
var _contains = function (list, item) {
|
|
if (! list)
|
|
return false;
|
|
for (var i = 0, N = list.length; i < N; i++)
|
|
if (list[i] === item)
|
|
return true;
|
|
return false;
|
|
};
|
|
|
|
var isArray = function (x) {
|
|
return !!((typeof x.length === 'number') &&
|
|
(x.sort || x.splice));
|
|
};
|
|
|
|
// Text nodes consisting of only whitespace
|
|
// are "insignificant" nodes.
|
|
var isSignificantNode = function (n) {
|
|
return ! (n.nodeType === 3 &&
|
|
(! n.nodeValue ||
|
|
/^\s+$/.test(n.nodeValue)));
|
|
};
|
|
|
|
var checkId = function (id) {
|
|
if (typeof id !== 'string')
|
|
throw new Error("id must be a string");
|
|
if (! id)
|
|
throw new Error("id may not be empty");
|
|
};
|
|
|
|
var textExpandosSupported = (function () {
|
|
var tn = document.createTextNode('');
|
|
try {
|
|
tn.blahblah = true;
|
|
return true;
|
|
} catch (e) {
|
|
// IE 8
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
var createMarkerNode = (
|
|
textExpandosSupported ?
|
|
function () { return document.createTextNode(""); } :
|
|
function () { return document.createComment(""); });
|
|
|
|
var rangeParented = function (range) {
|
|
if (! range.isParented) {
|
|
range.isParented = true;
|
|
|
|
if (! range.owner) {
|
|
// top-level (unowned) ranges in an element,
|
|
// keep a pointer to the range on the parent
|
|
// element. This is really just for IE 9+
|
|
// TextNode GC issues, but we can't do reliable
|
|
// feature detection (i.e. bug detection).
|
|
// Note that because we keep a direct pointer to
|
|
// `parentNode.$_uiranges`, it doesn't matter
|
|
// if we are reparented (e.g. wrapped in a TBODY).
|
|
var parentNode = range.parentNode();
|
|
var rangeDict = (
|
|
parentNode.$_uiranges ||
|
|
(parentNode.$_uiranges = {}));
|
|
rangeDict[range._rangeId] = range;
|
|
range._rangeDict = rangeDict;
|
|
|
|
// get jQuery to tell us when this node is removed
|
|
UI.DomBackend2.onRemoveElement(parentNode, function () {
|
|
rangeRemoved(range);
|
|
});
|
|
}
|
|
|
|
// XXX is this a real callback? what about chaining? etc.
|
|
if (range.component.parented) {
|
|
range.component.parented();
|
|
}
|
|
|
|
// recurse on member ranges
|
|
var members = range.members;
|
|
for (var k in members) {
|
|
var mem = members[k];
|
|
if ('dom' in mem)
|
|
rangeParented(mem.dom);
|
|
}
|
|
}
|
|
};
|
|
|
|
var rangeRemoved = function (range) {
|
|
if (! range.isRemoved) {
|
|
range.isRemoved = true;
|
|
|
|
if (range._rangeDict)
|
|
delete range._rangeDict[range._rangeId];
|
|
|
|
// XXX clean up events in $_uievents
|
|
|
|
// notify component of removal
|
|
if (range.component.removed)
|
|
range.component.removed();
|
|
|
|
membersRemoved(range);
|
|
}
|
|
};
|
|
|
|
var nodeRemoved = function (node, viaBackend) {
|
|
if (node.nodeType === 1) { // ELEMENT
|
|
var comps = DomRange.getComponents(node);
|
|
for (var i = 0, N = comps.length; i < N; i++)
|
|
rangeRemoved(comps[i].dom);
|
|
|
|
if (! viaBackend)
|
|
UI.DomBackend2.removeElement(node);
|
|
}
|
|
};
|
|
|
|
var membersRemoved = function (range) {
|
|
var members = range.members;
|
|
for (var k in members) {
|
|
var mem = members[k];
|
|
if ('dom' in mem)
|
|
rangeRemoved(mem.dom);
|
|
else
|
|
nodeRemoved(mem);
|
|
}
|
|
};
|
|
|
|
var nextGuid = 1;
|
|
|
|
var DomRange = function (component) {
|
|
// This code supports IE 8 if `createTextNode` is changed
|
|
// to `createComment`. What we really should do is:
|
|
// - use comments in IE 8
|
|
// - use TextNodes in all other browsers
|
|
// - keep a list of all DomRanges to avoid IE 9+ GC of
|
|
// TextNodes; this will probably help DomRange removal
|
|
// detection too.
|
|
var start = createMarkerNode();
|
|
var end = createMarkerNode();
|
|
var fragment = DomBackend.newFragment([start, end]);
|
|
fragment.$_uiIsOffscreen = true;
|
|
|
|
if (component) {
|
|
this.component = component;
|
|
component.dom = this;
|
|
// must NOT set `this.dom` to anything (even `null`)
|
|
// in this case.
|
|
} else {
|
|
// self-host
|
|
this.component = this;
|
|
this.dom = this;
|
|
}
|
|
|
|
this.start = start;
|
|
this.end = end;
|
|
start.$ui = this.component;
|
|
end.$ui = this.component;
|
|
|
|
this.members = {};
|
|
this.nextMemberId = 1;
|
|
this.owner = null;
|
|
this._rangeId = nextGuid++;
|
|
this._rangeDict = null;
|
|
|
|
this.isParented = false;
|
|
this.isRemoved = false;
|
|
};
|
|
|
|
_extend(DomRange.prototype, {
|
|
getNodes: function () {
|
|
if (! this.parentNode())
|
|
return [];
|
|
|
|
this.refresh();
|
|
|
|
var afterNode = this.end.nextSibling;
|
|
var nodes = [];
|
|
for (var n = this.start;
|
|
n && n !== afterNode;
|
|
n = n.nextSibling)
|
|
nodes.push(n);
|
|
return nodes;
|
|
},
|
|
removeAll: function () {
|
|
if (! this.parentNode())
|
|
return;
|
|
|
|
this.refresh();
|
|
|
|
// leave start and end
|
|
var afterNode = this.end;
|
|
var nodes = [];
|
|
for (var n = this.start.nextSibling;
|
|
n && n !== afterNode;
|
|
n = n.nextSibling) {
|
|
// don't remove yet since then we'd lose nextSibling
|
|
nodes.push(n);
|
|
}
|
|
for (var i = 0, N = nodes.length; i < N; i++)
|
|
removeNode(nodes[i]);
|
|
|
|
membersRemoved(this);
|
|
|
|
this.members = {};
|
|
},
|
|
// (_nextNode is internal)
|
|
add: function (id, newMemberOrArray, beforeId, _nextNode) {
|
|
if (id != null && typeof id !== 'string') {
|
|
if (typeof id !== 'object')
|
|
// a non-object first argument is probably meant
|
|
// as an id, NOT a new member, so complain about it
|
|
// as such.
|
|
throw new Error("id must be a string");
|
|
beforeId = newMemberOrArray;
|
|
newMemberOrArray = id;
|
|
id = null;
|
|
}
|
|
|
|
if (! newMemberOrArray || typeof newMemberOrArray !== 'object')
|
|
throw new Error("Expected component, node, or array");
|
|
|
|
if (isArray(newMemberOrArray)) {
|
|
if (newMemberOrArray.length === 1) {
|
|
newMemberOrArray = newMemberOrArray[0];
|
|
} else {
|
|
if (id != null)
|
|
throw new Error("Can only add one node or one component if id is given");
|
|
var array = newMemberOrArray;
|
|
// calculate `nextNode` once in case it involves a refresh
|
|
_nextNode = this.getInsertionPoint(beforeId);
|
|
for (var i = 0; i < array.length; i++)
|
|
this.add(null, array[i], beforeId, _nextNode);
|
|
return;
|
|
}
|
|
}
|
|
|
|
var parentNode = this.parentNode();
|
|
// Consider ourselves removed (and don't mind) if
|
|
// start marker has no parent.
|
|
if (! parentNode)
|
|
return;
|
|
// because this may call `refresh`, it must be done
|
|
// early, before we add the new member.
|
|
var nextNode = (_nextNode ||
|
|
this.getInsertionPoint(beforeId));
|
|
|
|
var newMember = newMemberOrArray;
|
|
if (id == null) {
|
|
id = this.nextMemberId++;
|
|
} else {
|
|
checkId(id);
|
|
id = ' ' + id;
|
|
}
|
|
|
|
var members = this.members;
|
|
if (members.hasOwnProperty(id)) {
|
|
var oldMember = members[id];
|
|
if ('dom' in oldMember) {
|
|
// range, does it still exist?
|
|
var oldRange = oldMember.dom;
|
|
if (oldRange.start.parentNode !== parentNode) {
|
|
delete members[id];
|
|
oldRange.owner = null;
|
|
rangeRemoved(oldRange);
|
|
} else {
|
|
throw new Error("Member already exists: " + id.slice(1));
|
|
}
|
|
} else {
|
|
// node, does it still exist?
|
|
var oldNode = oldMember;
|
|
if (oldNode.parentNode !== parentNode) {
|
|
nodeRemoved(oldNode);
|
|
delete members[id];
|
|
} else {
|
|
throw new Error("Member already exists: " + id.slice(1));
|
|
}
|
|
}
|
|
}
|
|
|
|
if ('dom' in newMember) {
|
|
if (! newMember.dom)
|
|
throw new Error("Component not built");
|
|
// Range
|
|
var range = newMember.dom;
|
|
range.owner = this.component;
|
|
var nodes = range.getNodes();
|
|
|
|
if (tbodyFixNeeded(nodes, parentNode))
|
|
// may cause a refresh(); important that the
|
|
// member isn't added yet
|
|
parentNode = moveWithOwnersIntoTbody(this);
|
|
|
|
members[id] = newMember;
|
|
for (var i = 0; i < nodes.length; i++)
|
|
insertNode(nodes[i], parentNode, nextNode);
|
|
|
|
if (this.isParented)
|
|
rangeParented(range);
|
|
} else {
|
|
// Node
|
|
if (typeof newMember.nodeType !== 'number')
|
|
throw new Error("Expected Component or Node");
|
|
var node = newMember;
|
|
// can't attach `$ui` to a TextNode in IE 8, so
|
|
// don't bother on any browser.
|
|
if (node.nodeType !== 3)
|
|
node.$ui = this.component;
|
|
|
|
if (tbodyFixNeeded(node, parentNode))
|
|
// may cause a refresh(); important that the
|
|
// member isn't added yet
|
|
parentNode = moveWithOwnersIntoTbody(this);
|
|
|
|
members[id] = newMember;
|
|
insertNode(node, parentNode, nextNode);
|
|
}
|
|
},
|
|
remove: function (id) {
|
|
if (id == null) {
|
|
// remove self
|
|
this.removeAll();
|
|
removeNode(this.start);
|
|
removeNode(this.end);
|
|
this.owner = null;
|
|
rangeRemoved(this);
|
|
return;
|
|
}
|
|
|
|
checkId(id);
|
|
id = ' ' + id;
|
|
var members = this.members;
|
|
var member = (members.hasOwnProperty(id) &&
|
|
members[id]);
|
|
delete members[id];
|
|
|
|
// Don't mind double-remove.
|
|
if (! member)
|
|
return;
|
|
|
|
var parentNode = this.parentNode();
|
|
// Consider ourselves removed (and don't mind) if
|
|
// start marker has no parent.
|
|
if (! parentNode)
|
|
return;
|
|
|
|
if ('dom' in member) {
|
|
// Range
|
|
var range = member.dom;
|
|
range.owner = null;
|
|
// Don't mind if range (specifically its start
|
|
// marker) has been removed already.
|
|
if (range.start.parentNode === parentNode)
|
|
member.dom.remove();
|
|
} else {
|
|
// Node
|
|
var node = member;
|
|
// Don't mind if node has been removed already.
|
|
if (node.parentNode === parentNode)
|
|
removeNode(node);
|
|
}
|
|
},
|
|
moveBefore: function (id, beforeId) {
|
|
var nextNode = this.getInsertionPoint(beforeId);
|
|
checkId(id);
|
|
id = ' ' + id;
|
|
var members = this.members;
|
|
var member =
|
|
(members.hasOwnProperty(id) &&
|
|
members[id]);
|
|
// Don't mind if member doesn't exist.
|
|
if (! member)
|
|
return;
|
|
|
|
var parentNode = this.parentNode();
|
|
// Consider ourselves removed (and don't mind) if
|
|
// start marker has no parent.
|
|
if (! parentNode)
|
|
return;
|
|
|
|
if ('dom' in member) {
|
|
// Range
|
|
var range = member.dom;
|
|
// Don't mind if range (specifically its start marker)
|
|
// has been removed already.
|
|
if (range.start.parentNode === parentNode) {
|
|
range.refresh();
|
|
var nodes = range.getNodes();
|
|
for (var i = 0; i < nodes.length; i++)
|
|
moveNode(nodes[i], parentNode, nextNode);
|
|
}
|
|
} else {
|
|
// Node
|
|
var node = member;
|
|
moveNode(node, parentNode, nextNode);
|
|
}
|
|
},
|
|
get: function (id) {
|
|
checkId(id);
|
|
id = ' ' + id;
|
|
var members = this.members;
|
|
if (members.hasOwnProperty(id))
|
|
return members[id];
|
|
return null;
|
|
},
|
|
parentNode: function () {
|
|
return this.start.parentNode;
|
|
},
|
|
startNode: function () {
|
|
return this.start;
|
|
},
|
|
endNode: function () {
|
|
return this.end;
|
|
},
|
|
eachMember: function (nodeFunc, rangeFunc) {
|
|
var members = this.members;
|
|
var parentNode = this.parentNode();
|
|
for (var k in members) {
|
|
// mem is a component (hosting a Range) or a Node
|
|
var mem = members[k];
|
|
if ('dom' in mem) {
|
|
// Range
|
|
var range = mem.dom;
|
|
if (range.start.parentNode === parentNode) {
|
|
rangeFunc && rangeFunc(range); // still there
|
|
} else {
|
|
range.owner = null;
|
|
delete members[k]; // gone
|
|
rangeRemoved(range);
|
|
}
|
|
} else {
|
|
// Node
|
|
var node = mem;
|
|
if (node.parentNode === parentNode) {
|
|
nodeFunc && nodeFunc(node); // still there
|
|
} else {
|
|
delete members[k]; // gone
|
|
nodeRemoved(node);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
///////////// INTERNALS below this point, pretty much
|
|
|
|
// The purpose of "refreshing" a DomRange is to
|
|
// take into account any element removals or moves
|
|
// that may have occurred, and to "fix" the start
|
|
// and end markers before the entire range is moved
|
|
// or removed so that they bracket the appropriate
|
|
// content.
|
|
//
|
|
// For example, if a DomRange contains a single element
|
|
// node, and this node is moved using jQuery, refreshing
|
|
// the DomRange will look to the element as ground truth
|
|
// and move the start/end markers around the element.
|
|
// A refreshed DomRange's nodes may surround nodes from
|
|
// sibling DomRanges (including their marker nodes)
|
|
// until the sibling DomRange is refreshed.
|
|
//
|
|
// Specifically, `refresh` moves the `start`
|
|
// and `end` nodes to immediate before the first,
|
|
// and after the last, "significant" node the
|
|
// DomRange contains, where a significant node
|
|
// is any node except a whitespace-only text-node.
|
|
// All member ranges are refreshed first. Adjacent
|
|
// insignificant member nodes are included between
|
|
// `start` and `end` as well, but it's possible that
|
|
// other insignificant nodes remain as siblings
|
|
// elsewhere. Nodes with no DomRange owner that are
|
|
// found between this DomRange's nodes are adopted.
|
|
//
|
|
// Performing add/move/remove operations on an "each"
|
|
// shouldn't require refreshing the entire each, just
|
|
// the member in question. (However, adding to the
|
|
// end may require refreshing the whole "each";
|
|
// see `getInsertionPoint`. Adding multiple members
|
|
// at once using `add(array)` is faster.
|
|
refresh: function () {
|
|
var parentNode = this.parentNode();
|
|
if (! parentNode)
|
|
return;
|
|
|
|
// Using `eachMember`, do several things:
|
|
// - Refresh all member ranges
|
|
// - Count our members
|
|
// - If there's only one, get that one
|
|
// - Make a list of member TextNodes, which we
|
|
// can't detect with a `$ui` property because
|
|
// IE 8 doesn't allow user-defined properties
|
|
// on TextNodes.
|
|
var someNode = null;
|
|
var someRange = null;
|
|
var numMembers = 0;
|
|
var textNodes = null;
|
|
this.eachMember(function (node) {
|
|
someNode = node;
|
|
numMembers++;
|
|
if (node.nodeType === 3) {
|
|
textNodes = (textNodes || []);
|
|
textNodes.push(node);
|
|
}
|
|
}, function (range) {
|
|
range.refresh();
|
|
someRange = range;
|
|
numMembers++;
|
|
});
|
|
|
|
var firstNode = null;
|
|
var lastNode = null;
|
|
|
|
if (numMembers === 0) {
|
|
// don't scan for members
|
|
} else if (numMembers === 1) {
|
|
if (someNode) {
|
|
firstNode = someNode;
|
|
lastNode = someNode;
|
|
} else if (someRange) {
|
|
firstNode = someRange.start;
|
|
lastNode = someRange.end;
|
|
}
|
|
} else {
|
|
// This loop is O(childNodes.length), even if our members
|
|
// are already consecutive. This means refreshing just one
|
|
// item in a list is technically order of the total number
|
|
// of siblings, including in other list items.
|
|
//
|
|
// The root cause is we intentionally don't track the
|
|
// DOM order of our members, so finding the first
|
|
// and last in sibling order either involves a scan
|
|
// or a bunch of calls to compareDocumentPosition.
|
|
//
|
|
// Fortunately, the common cases of zero and one members
|
|
// are optimized. Also, the scan is super-fast because
|
|
// no work is done for unknown nodes. It could be possible
|
|
// to optimize this code further if it becomes a problem.
|
|
for (var node = parentNode.firstChild;
|
|
node; node = node.nextSibling) {
|
|
|
|
var nodeOwner;
|
|
if (node.$ui &&
|
|
(nodeOwner = node.$ui.dom) &&
|
|
((nodeOwner === this &&
|
|
node !== this.start &&
|
|
node !== this.end &&
|
|
isSignificantNode(node)) ||
|
|
(nodeOwner !== this &&
|
|
nodeOwner.owner &&
|
|
nodeOwner.owner.dom === this &&
|
|
nodeOwner.start === node))) {
|
|
// found a member range or node
|
|
// (excluding "insignificant" empty text nodes,
|
|
// which won't be moved by, say, jQuery)
|
|
if (firstNode) {
|
|
// if we've already found a member in our
|
|
// scan, see if there are some easy ownerless
|
|
// nodes to "adopt" by scanning backwards.
|
|
for (var n = firstNode.previousSibling;
|
|
n && ! n.$ui;
|
|
n = n.previousSibling) {
|
|
this.members[this.nextMemberId++] = n;
|
|
// can't attach `$ui` to a TextNode in IE 8, so
|
|
// don't bother on any browser.
|
|
if (n.nodeType !== 3)
|
|
n.$ui = this.component;
|
|
}
|
|
}
|
|
if (node.$ui.dom === this) {
|
|
// Node
|
|
firstNode = (firstNode || node);
|
|
lastNode = node;
|
|
} else {
|
|
// Range
|
|
// skip it and include its nodes in
|
|
// firstNode/lastNode.
|
|
firstNode = (firstNode || node);
|
|
node = node.$ui.dom.end;
|
|
lastNode = node;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (firstNode) {
|
|
// some member or significant node was found.
|
|
// expand to include our insigificant member
|
|
// nodes as well.
|
|
for (var n;
|
|
(n = firstNode.previousSibling) &&
|
|
(n.$ui && n.$ui.dom === this ||
|
|
_contains(textNodes, n));)
|
|
firstNode = n;
|
|
for (var n;
|
|
(n = lastNode.nextSibling) &&
|
|
(n.$ui && n.$ui.dom === this ||
|
|
_contains(textNodes, n));)
|
|
lastNode = n;
|
|
// adjust our start/end pointers
|
|
if (firstNode !== this.start)
|
|
insertNode(this.start,
|
|
parentNode, firstNode);
|
|
if (lastNode !== this.end)
|
|
insertNode(this.end, parentNode,
|
|
lastNode.nextSibling);
|
|
}
|
|
},
|
|
getInsertionPoint: function (beforeId) {
|
|
var members = this.members;
|
|
var parentNode = this.parentNode();
|
|
|
|
if (! beforeId) {
|
|
// Refreshing here is necessary if we want to
|
|
// allow elements to move around arbitrarily.
|
|
// If jQuery is used to reorder elements, it could
|
|
// easily make our `end` pointer meaningless,
|
|
// even though all our members continue to make
|
|
// good reference points as long as they are refreshed.
|
|
//
|
|
// However, a refresh is expensive! Let's
|
|
// make the developer manually refresh if
|
|
// elements are being re-ordered externally.
|
|
return this.end;
|
|
}
|
|
|
|
checkId(beforeId);
|
|
beforeId = ' ' + beforeId;
|
|
var mem = members[beforeId];
|
|
|
|
if ('dom' in mem) {
|
|
// Range
|
|
var range = mem.dom;
|
|
if (range.start.parentNode === parentNode) {
|
|
// still there
|
|
range.refresh();
|
|
return range.start;
|
|
} else {
|
|
range.owner = null;
|
|
rangeRemoved(range);
|
|
}
|
|
} else {
|
|
// Node
|
|
var node = mem;
|
|
if (node.parentNode === parentNode)
|
|
return node; // still there
|
|
else
|
|
nodeRemoved(node);
|
|
}
|
|
|
|
// not there anymore
|
|
delete members[beforeId];
|
|
// no good position
|
|
return this.end;
|
|
}
|
|
});
|
|
|
|
DomRange.prototype.elements = function (intoArray) {
|
|
intoArray = (intoArray || []);
|
|
this.eachMember(function (node) {
|
|
if (node.nodeType === 1)
|
|
intoArray.push(node);
|
|
}, function (range) {
|
|
range.elements(intoArray);
|
|
});
|
|
return intoArray;
|
|
};
|
|
|
|
// XXX alias the below as `UI.refresh` and `UI.insert`
|
|
|
|
// In a real-life case where you need a refresh,
|
|
// you probably don't have easy
|
|
// access to the appropriate DomRange or component,
|
|
// just the enclosing element:
|
|
//
|
|
// ```
|
|
// {{#Sortable}}
|
|
// <div>
|
|
// {{#each}}
|
|
// ...
|
|
// ```
|
|
//
|
|
// In this case, Sortable wants to call `refresh`
|
|
// on the div, not the each, so it would use this function.
|
|
DomRange.refresh = function (element) {
|
|
var comps = DomRange.getComponents(element);
|
|
|
|
for (var i = 0, N = comps.length; i < N; i++)
|
|
comps[i].dom.refresh();
|
|
};
|
|
|
|
DomRange.getComponents = function (element) {
|
|
var topLevelComps = [];
|
|
for (var n = element.firstChild;
|
|
n; n = n.nextSibling) {
|
|
if (n.$ui && n === n.$ui.dom.start &&
|
|
! n.$ui.dom.owner)
|
|
topLevelComps.push(n.$ui);
|
|
}
|
|
return topLevelComps;
|
|
};
|
|
|
|
// `parentNode` must be an ELEMENT, not a fragment
|
|
DomRange.insert = function (component, parentNode, nextNode) {
|
|
var range = component.dom;
|
|
if (! range)
|
|
throw new Error("Expected a component with a DomRange");
|
|
var nodes = range.getNodes();
|
|
if (tbodyFixNeeded(nodes, parentNode))
|
|
parentNode = makeOrFindTbody(parentNode, nextNode);
|
|
for (var i = 0; i < nodes.length; i++)
|
|
insertNode(nodes[i], parentNode, nextNode);
|
|
rangeParented(range);
|
|
};
|
|
|
|
DomRange.getContainingComponent = function (element) {
|
|
while (element && ! element.$ui)
|
|
element = element.parentNode;
|
|
return (element && element.$ui) || null;
|
|
};
|
|
|
|
///// TBODY FIX for compatibility with jQuery.
|
|
//
|
|
// Because people might use jQuery from UI hooks, and
|
|
// jQuery is unable to do $(myTable).append(myTR) without
|
|
// adding a TBODY (for historical reasons), we move any DomRange
|
|
// that gains a TR, and its immediately enclosing DomRanges,
|
|
// into a TBODY.
|
|
//
|
|
// See http://www.quora.com/David-Greenspan/Posts/The-Great-TBODY-Debacle
|
|
var tbodyFixNeeded = function (childOrChildren, parent) {
|
|
if (parent.nodeName !== 'TABLE')
|
|
return false;
|
|
|
|
if (isArray(childOrChildren)) {
|
|
var foundTR = false;
|
|
for (var i = 0, N = childOrChildren.length; i < N; i++) {
|
|
var n = childOrChildren[i];
|
|
if (n.nodeType === 1 && n.nodeName === 'TR') {
|
|
foundTR = true;
|
|
break;
|
|
}
|
|
}
|
|
if (! foundTR)
|
|
return false;
|
|
} else {
|
|
var n = childOrChildren;
|
|
if (! (n.nodeType === 1 && n.nodeName === 'TR'))
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
var makeOrFindTbody = function (parent, next) {
|
|
// we have a TABLE > TR situation
|
|
var tbody = parent.getElementsByTagName('tbody')[0];
|
|
if (! tbody) {
|
|
tbody = parent.ownerDocument.createElement("tbody");
|
|
parent.insertBefore(tbody, next || null);
|
|
}
|
|
return tbody;
|
|
};
|
|
|
|
var moveWithOwnersIntoTbody = function (range) {
|
|
while (range.owner)
|
|
range = range.owner.dom;
|
|
|
|
var nodes = range.getNodes(); // causes refresh
|
|
var tbody = makeOrFindTbody(range.parentNode(),
|
|
range.end.nextSibling);
|
|
for (var i = 0; i < nodes.length; i++)
|
|
tbody.appendChild(nodes[i]);
|
|
|
|
// XXX complete the reparenting by moving event
|
|
// HandlerRecs of `range`.
|
|
|
|
return tbody;
|
|
};
|
|
|
|
///// FIND BY SELECTOR
|
|
|
|
DomRange.prototype.contains = function (compOrNode) {
|
|
if (! compOrNode)
|
|
throw new Error("Expected Component or Node");
|
|
|
|
var parentNode = this.parentNode();
|
|
if (! parentNode)
|
|
return false;
|
|
|
|
var range;
|
|
if ('dom' in compOrNode) {
|
|
// Component
|
|
range = compOrNode.dom;
|
|
var pn = range.parentNode();
|
|
if (! pn)
|
|
return false;
|
|
// If parentNode is different, it must be a node
|
|
// we contain.
|
|
if (pn !== parentNode)
|
|
return this.contains(pn);
|
|
if (range === this)
|
|
return false; // don't contain self
|
|
// Ok, `range` is a same-parent range to see if we
|
|
// contain.
|
|
} else {
|
|
// Node
|
|
var node = compOrNode;
|
|
if (! elementContains(parentNode, node))
|
|
return false;
|
|
|
|
while (node.parentNode !== parentNode)
|
|
node = node.parentNode;
|
|
|
|
range = node.$ui && node.$ui.dom;
|
|
}
|
|
|
|
// Now see if `range` is truthy and either `this`
|
|
// or an immediate subrange
|
|
|
|
while (range && range !== this)
|
|
range = range.owner && range.owner.dom;
|
|
|
|
return range === this;
|
|
};
|
|
|
|
DomRange.prototype.$ = function (selector) {
|
|
var self = this;
|
|
|
|
var parentNode = this.parentNode();
|
|
if (! parentNode)
|
|
throw new Error("Can't select in removed DomRange");
|
|
|
|
// Strategy: Find all selector matches under parentNode,
|
|
// then filter out the ones that aren't in this DomRange
|
|
// using upwards pointers ($ui, owner, parentNode). This is
|
|
// asymptotically slow in the presence of O(N) sibling
|
|
// content that is under parentNode but not in our range,
|
|
// so if performance is an issue, the selector should be
|
|
// run on a child element.
|
|
|
|
// Since jQuery can't run selectors on a DocumentFragment,
|
|
// we don't expect findBySelector to work.
|
|
if (parentNode.nodeType === 11 /* DocumentFragment */ ||
|
|
parentNode.$_uiIsOffscreen)
|
|
throw new Error("Can't use $ on an offscreen component");
|
|
|
|
var results = DomBackend.findBySelector(selector, parentNode);
|
|
|
|
// We don't assume `results` has jQuery API; a plain array
|
|
// should do just as well. However, if we do have a jQuery
|
|
// array, we want to end up with one also, so we use
|
|
// `.filter`.
|
|
|
|
|
|
// Function that selects only elements that are actually
|
|
// in this DomRange, rather than simply descending from
|
|
// `parentNode`.
|
|
var filterFunc = function (elem) {
|
|
// handle jQuery's arguments to filter, where the node
|
|
// is in `this` and the index is the first argument.
|
|
if (typeof elem === 'number')
|
|
elem = this;
|
|
|
|
return self.contains(elem);
|
|
};
|
|
|
|
if (! results.filter) {
|
|
// not a jQuery array, and not a browser with
|
|
// Array.prototype.filter (e.g. IE <9)
|
|
var newResults = [];
|
|
for (var i = 0; i < results.length; i++) {
|
|
var x = results[i];
|
|
if (filterFunc(x))
|
|
newResults.push(x);
|
|
}
|
|
results = newResults;
|
|
} else {
|
|
// `results.filter` is either jQuery's or ECMAScript's `filter`
|
|
results = results.filter(filterFunc);
|
|
}
|
|
|
|
return results;
|
|
};
|
|
|
|
|
|
///// EVENTS
|
|
|
|
// List of events to always delegate, never capture.
|
|
// Since jQuery fakes bubbling for certain events in
|
|
// certain browsers (like `submit`), we don't want to
|
|
// get in its way.
|
|
//
|
|
// We could list all known bubbling
|
|
// events here to avoid creating speculative capturers
|
|
// for them, but it would only be an optimization.
|
|
var eventsToDelegate = {
|
|
blur: 1, change: 1, click: 1, focus: 1, focusin: 1,
|
|
focusout: 1, reset: 1, submit: 1
|
|
};
|
|
|
|
var EVENT_MODE_TBD = 0;
|
|
var EVENT_MODE_BUBBLING = 1;
|
|
var EVENT_MODE_CAPTURING = 2;
|
|
|
|
var HandlerRec = function (elem, type, selector, handler, $ui) {
|
|
this.elem = elem;
|
|
this.type = type;
|
|
this.selector = selector;
|
|
this.handler = handler;
|
|
this.$ui = $ui;
|
|
|
|
this.mode = EVENT_MODE_TBD;
|
|
|
|
// It's important that delegatedHandler be a different
|
|
// instance for each handlerRecord, because its identity
|
|
// is used to remove it.
|
|
//
|
|
// It's also important that the closure have access to
|
|
// `this` when it is not called with it set.
|
|
this.delegatedHandler = (function (h) {
|
|
return function (evt) {
|
|
if ((! h.selector) && evt.currentTarget !== evt.target)
|
|
// no selector means only fire on target
|
|
return;
|
|
if (! h.$ui.dom.contains(evt.currentTarget))
|
|
return;
|
|
return h.handler.call(h.$ui, evt);
|
|
};
|
|
})(this);
|
|
|
|
// WHY CAPTURE AND DELEGATE: jQuery can't delegate
|
|
// non-bubbling events, because
|
|
// event capture doesn't work in IE 8. However, there
|
|
// are all sorts of new-fangled non-bubbling events
|
|
// like "play" and "touchenter". We delegate these
|
|
// events using capture in all browsers except IE 8.
|
|
// IE 8 doesn't support these events anyway.
|
|
|
|
var tryCapturing = elem.addEventListener &&
|
|
(! eventsToDelegate.hasOwnProperty(
|
|
DomBackend.parseEventType(type)));
|
|
|
|
if (tryCapturing) {
|
|
this.capturingHandler = (function (h) {
|
|
return function (evt) {
|
|
if (h.mode === EVENT_MODE_TBD) {
|
|
// must be first time we're called.
|
|
if (evt.bubbles) {
|
|
// this type of event bubbles, so don't
|
|
// get called again.
|
|
h.mode = EVENT_MODE_BUBBLING;
|
|
DomBackend.unbindEventCapturer(
|
|
h.elem, h.type, h.capturingHandler);
|
|
return;
|
|
} else {
|
|
// this type of event doesn't bubble,
|
|
// so unbind the delegation, preventing
|
|
// it from ever firing.
|
|
h.mode = EVENT_MODE_CAPTURING;
|
|
DomBackend.undelegateEvents(
|
|
h.elem, h.type, h.delegatedHandler);
|
|
}
|
|
}
|
|
|
|
h.delegatedHandler(evt);
|
|
};
|
|
})(this);
|
|
|
|
} else {
|
|
this.mode = EVENT_MODE_BUBBLING;
|
|
}
|
|
};
|
|
|
|
HandlerRec.prototype.bind = function () {
|
|
// `this.mode` may be EVENT_MODE_TBD, in which case we bind both. in
|
|
// this case, 'capturingHandler' is in charge of detecting the
|
|
// correct mode and turning off one or the other handlers.
|
|
if (this.mode !== EVENT_MODE_BUBBLING) {
|
|
DomBackend.bindEventCapturer(
|
|
this.elem, this.type,
|
|
this.capturingHandler);
|
|
}
|
|
|
|
if (this.mode !== EVENT_MODE_CAPTURING)
|
|
DomBackend.delegateEvents(
|
|
this.elem, this.type,
|
|
this.selector || '*', this.delegatedHandler);
|
|
};
|
|
|
|
HandlerRec.prototype.unbind = function () {
|
|
if (this.mode !== EVENT_MODE_BUBBLING)
|
|
DomBackend.unbindEventCapturer(this.elem, this.type,
|
|
this.capturingHandler);
|
|
|
|
if (this.mode !== EVENT_MODE_CAPTURING)
|
|
DomBackend.undelegateEvents(this.elem, this.type,
|
|
this.delegatedHandler);
|
|
};
|
|
|
|
|
|
// XXX could write the form of arguments for this function
|
|
// in several different ways, including simply as an event map.
|
|
DomRange.prototype.on = function (events, selector, handler) {
|
|
var parentNode = this.parentNode();
|
|
if (! parentNode)
|
|
// if we're not in the DOM, silently fail.
|
|
return;
|
|
// haven't been added yet; error
|
|
if (parentNode.$_uiIsOffscreen)
|
|
throw new Error("Can't bind events before DomRange is inserted");
|
|
|
|
var eventTypes = [];
|
|
events.replace(/[^ /]+/g, function (e) {
|
|
eventTypes.push(e);
|
|
});
|
|
|
|
if (! handler && (typeof selector === 'function')) {
|
|
// omitted `selector`
|
|
handler = selector;
|
|
selector = null;
|
|
} else if (! selector) {
|
|
// take `""` to `null`
|
|
selector = null;
|
|
}
|
|
|
|
for (var i = 0, N = eventTypes.length; i < N; i++) {
|
|
var type = eventTypes[i];
|
|
|
|
var eventDict = parentNode.$_uievents;
|
|
if (! eventDict)
|
|
eventDict = (parentNode.$_uievents = {});
|
|
|
|
var info = eventDict[type];
|
|
if (! info) {
|
|
info = eventDict[type] = {};
|
|
info.handlers = [];
|
|
}
|
|
var handlerList = info.handlers;
|
|
var handlerRec = new HandlerRec(
|
|
parentNode, type, selector, handler, this.component);
|
|
handlerRec.bind();
|
|
handlerList.push(handlerRec);
|
|
// move handlers of enclosing ranges to end
|
|
for (var r = (this.owner && this.owner.dom);
|
|
r; r = (r.owner && r.owner.dom)) {
|
|
// r is an enclosing DomRange
|
|
for (var j = 0, Nj = handlerList.length;
|
|
j < Nj; j++) {
|
|
var h = handlerList[j];
|
|
if (h.$ui && h.$ui.dom === r) {
|
|
h.unbind();
|
|
h.bind();
|
|
handlerList.splice(j, 1); // remove handlerList[j]
|
|
handlerList.push(h);
|
|
j--; // account for removed handler
|
|
Nj--; // don't visit appended handlers
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Returns true if element a contains node b and is not node b.
|
|
var elementContains = function (a, b) {
|
|
if (a.nodeType !== 1) // ELEMENT
|
|
return false;
|
|
if (a === b)
|
|
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 functions but seems to get contains() wrong.
|
|
// IE can't handle b being a text node. We work around this
|
|
// by doing a direct parent test now.
|
|
b = b.parentNode;
|
|
if (! (b && b.nodeType === 1)) // ELEMENT
|
|
return false;
|
|
if (a === b)
|
|
return true;
|
|
|
|
return a.contains(b);
|
|
}
|
|
};
|
|
|
|
|
|
UI.DomRange = DomRange; |