Skip to content

Commit

Permalink
Expands the <content> element to remember logical DOM
Browse files Browse the repository at this point in the history
It's now capable of rerendering if logical DOM changes.

Notes:

* lightChildren, if present, indicates the logical child views. lightParent will be set for those elements.
* lightChildren is an array, because that seemed like the "simplest thing that could possibly work". We could expose linked list APIs instead (firstChild, nextSibling, etc) if we wanted.
* addLightChild/removeLightChild are a convenience, but not recommended for adding large number of items because they automatically call distributeContent(). Another option would be to schedule it and do it lazily.
  • Loading branch information
John Messerly committed Dec 11, 2014
1 parent 43f456e commit 86bc783
Show file tree
Hide file tree
Showing 5 changed files with 669 additions and 27 deletions.
3 changes: 0 additions & 3 deletions polymer-core.html
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,6 @@
}
this.listenListeners();
this.listenKeyPresses();
if (this._useContent) {
this.distributeContent();
}
this.takeAttributes();
}

Expand Down
304 changes: 280 additions & 24 deletions src/features/content.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,16 @@
Code distributed by Google as part of the polymer project is also
subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
-->
<link rel="import" href="ready.html">
<script>
(function() {

/**
Implements a pared down version of ShadowDOM's scoping, which is easy to
polyfill across browsers.
*/

Base.addFeature({

Expand All @@ -27,38 +36,285 @@
},

poolContent: function() {
// pool the light dom
var pool = document.createDocumentFragment();
while (this.firstChild) {
pool.appendChild(this.firstChild);
}
this.contentPool = pool;
// capture lightChildren to help reify dom scoping
this.lightChildren =
Array.prototype.slice.call(this.contentPool.childNodes, 0);
saveLightChildrenIfNeeded(this);
// create our lite ShadowRoot document fragment
// this is where the <template> contents will be stamped
var root = document.createDocumentFragment();
// add a pointer back from the lite ShadowRoot to this node.
root.host = this;
// initialize the `root` pointers: `root` is guarenteed to always be
// available, and be either `this` or `this.contentRoot`. By contrast,
// `contentRoot` is only set if _useContent is true.
this.contentRoot = root;
this.root = root;
// TODO(jmesserly): ad-hoc signal for `ShadowDOM-lite-enhanced` root
root.isShadowRoot = true;
},

distributeContent: function() {
var content, pool = this.contentPool;
// replace <content> with nodes teleported from pool
while (content = this.querySelector('content')) {
var select = content.getAttribute('select');
var frag = pool;
if (select) {
frag = document.createDocumentFragment();
// TODO(sjmiles): diverges from ShadowDOM spec behavior: ShadowDOM
// only selects top level nodes from pool. Iterate children and match
// manually instead.
var nodes = pool.querySelectorAll(select);
for (var i=0, l=nodes.length; i<l; i++) {
frag.appendChild(nodes[i]);
// sanity check to guard against uninitialized state
if (!this.contentRoot) {
throw Error('poolContent() must be called before distributeContent()');
}
// reset distributions
this._resetLightTree(this.contentRoot);
// compute which nodes should be distributed where
// TODO(jmesserly): this is simplified because we assume a single
// ShadowRoot per host and no `<shadow>`.
this._poolDistribution(this.contentRoot, this._poolPopulation());
// update the real DOM to be the composed tree
this._composeTree(this);
},

// TODO(jmesserly): these methods will perform in O(N^2) where N is the
// number of times they are called. That is because each call does
// `distibuteContent` and the work it needs to do increases with each
// subsequent call. An alternative approach would be to schedule the work,
// and do it asynchronously, which would give us O(N) performance because
// we'd do it once per frame in the worst case.
addLightChild: function(node, opt_index) {
saveLightChildrenIfNeeded(this);
if (opt_index === undefined) {
this.lightChildren.push(node);
} else {
this.lightChildren.splice(opt_index, 0, node);
}
this.distributeContent();
},

removeLightChild: function(node) {
saveLightChildrenIfNeeded(this);
var index = this.lightChildren.indexOf(node);
if (index < 0) {
throw Error('The node to be removed is not a light child of this node');
}
this.lightChildren.splice(index, 1);
this.distributeContent();
},

// This is a polyfill for Element.prototype.matches, which is sometimes
// still prefixed. Alternatively we could just polyfill it somewhere.
// Note that the arguments are reversed from what you might expect.
elementMatches: function(selector, node) {
if (node === undefined) node = this;
return matchesSelector.call(node, selector);
},

_poolPopulation: function() {
// Gather the pool of nodes that should be distributed. We will combine
// these with the "content root" to arrive at the composed tree.
var pool = [];
var children = getLightChildren(this);
for (var i = 0; i < children.length; i++) {
var child = children[i];
if (isInsertionPoint(child)) {
pool.push.apply(pool, child._distributedNodes);
} else {
pool.push(child);
}
}
return pool;
},

// Many of the following methods are all conceptually static, but they are
// included here as "protected" methods to allow overriding.

_resetLightTree: function(node) {
var children = getLightChildren(node);
for (var i = 0; i < children.length; i++) {
var child = children[i];
if (isInsertionPoint(child)) {
child._distributedNodes = [];
} else if (child._destinationInsertionPoints) {
child._destinationInsertionPoints = undefined;
}
this._resetLightTree(child);
}
},

_poolDistribution: function(node, pool) {
if (node.localName == 'content') {
// distribute nodes from the pool that this selector matches
var content = node;
var anyDistributed = false;
for (var i = 0; i < pool.length; i++) {
var node = pool[i];
// skip nodes that were already used
if (!node) continue;
// distribute this node if it matches
if (this._matchesContentSelect(node, content)) {
distributeNodeInto(node, content);
// remove this node from the pool
pool[i] = undefined;
// since at least one node matched, we won't need fallback content
anyDistributed = true;
}
}
// content self-destructs
content.parentNode.replaceChild(frag, content);
// Fallback content if nothing was distributed here
if (!anyDistributed) {
var children = getLightChildren(content);
for (var i = 0; i < children.length; i++) {
distributeNodeInto(children[i], content);
}
}
return;
}
}
// recursively distribute.
var children = getLightChildren(node);
for (var i = 0; i < children.length; i++) {
this._poolDistribution(children[i], pool);
}
},

_composeTree: function(node) {
var children = this._composeNode(node);
for (var i = 0; i < children.length; i++) {
var child = children[i];
// If the child has a content root, let it compose itself.
if (!child.contentRoot) {
this._composeTree(child);
}
}
this._updateChildNodes(node, children);
},

_composeNode: function(node) {
var children = [];
var lightChildren = getLightChildren(node.contentRoot || node);
for (var i = 0; i < lightChildren.length; i++) {
var child = lightChildren[i];
if (isInsertionPoint(child)) {
var distributedNodes = child._distributedNodes;
for (var j = 0; j < distributedNodes.length; j++) {
var distributedNode = distributedNodes[j];
if (isFinalDestination(child, distributedNode)) {
children.push(distributedNode);
}
}
} else {
children.push(child);
}
}
return children;
},

_updateChildNodes: function(node, children) {
// Add the children that need to be added. Walk the list backwards so we can
// use insertBefore easily.
for (var i = children.length - 1, nextNode = null; i >= 0; i--) {
var child = children[i];
// if the node is in the wrong place, move it.
if (child.parentNode != node || child.nextSibling != nextNode) {
insertBefore(node, child, nextNode);
}
nextNode = child;
}
// We just added nodes in order, starting from the end, so anything before
// the first node is gone and should be removed.
var first = children[0];
var child = node.firstChild;
while (child && child != first) {
var nextNode = child.nextSibling;
node.removeChild(child);
child = nextNode;
}
},

_matchesContentSelect: function(node, contentElement) {
var select = contentElement.getAttribute('select');
// no selector matches all nodes (including text)
if (!select) return true;
select = select.trim();
// same thing if it had only whitespace
if (!select) return true;
// selectors can only match Elements
if (!(node instanceof Element)) return false;
// only valid selectors can match:
// TypeSelector
// *
// ClassSelector
// IDSelector
// AttributeSelector
// negation
var validSelectors = /^(:not\()?[*.#[a-zA-Z_|]/;
if (!validSelectors.test(select)) return false;
try {
return this.elementMatches(select, node);
} catch (ex) {
// Invalid selector.
return false;
}
},
});

function distributeNodeInto(child, insertionPoint) {
insertionPoint._distributedNodes.push(child);
var points = child._destinationInsertionPoints;
if (!points) {
child._destinationInsertionPoints = [insertionPoint];
} else {
points.push(insertionPoint);
}
}

function isFinalDestination(insertionPoint, node) {
var points = node._destinationInsertionPoints;
return points && points[points.length - 1] === insertionPoint;
}

function isInsertionPoint(node) {
// TODO(jmesserly): we could add back 'shadow' support here.
return node.localName == 'content';
}

function getLightChildren(node) {
var children = node.lightChildren;
return children ? children : node.childNodes;
}

function insertBefore(parentNode, newChild, refChild) {
// remove child from its old parent first
remove(newChild);
// make sure we never lose logical DOM information:
// if the parentNode doesn't have lightChildren, save that information now.
saveLightChildrenIfNeeded(parentNode);
// insert it into the real DOM
parentNode.insertBefore(newChild, refChild);
}

function remove(node) {
var parentNode = node.parentNode;
if (!parentNode) return;
// make sure we never lose logical DOM information:
// if the parentNode doesn't have lightChildren, save that information now.
saveLightChildrenIfNeeded(parentNode);
// remove it from the real DOM
parentNode.removeChild(node);
}

function saveLightChildrenIfNeeded(node) {
// Capture the list of light children. It's important to do this before we
// start transforming the DOM into "rendered" state.
//
// Children may be added to this list dynamically. It will be treated as the
// source of truth for the light children of the element. This element's
// actual children will be treated as the rendered state once lightChildren
// is populated.
if (!node.lightChildren) {
var children = [];
for (var child = node.firstChild; child; child = child.nextSibling) {
children.push(child);
child.lightParent = node;
}
node.lightChildren = children;
}
}

var proto = Element.prototype;
var matchesSelector = proto.matches || proto.matchesSelector ||
proto.mozMatchesSelector || proto.msMatchesSelector ||
proto.oMatchesSelector || proto.webkitMatchesSelector;

})();
</script>
5 changes: 5 additions & 0 deletions src/features/ready.html
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@

_ready: function() {
this._readied = true;
// TODO(jmesserly): this is a hook to allow content.html to be called
// before "ready". This needs to be factored better.
if (this._useContent) {
this.distributeContent();
}
this.ready();
},

Expand Down
1 change: 1 addition & 0 deletions test/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
WCT.loadSuites([
'unit/base.html',
'unit/ready.html',
'unit/content.html'
]);
</script>
</body>
Expand Down
Loading

0 comments on commit 86bc783

Please sign in to comment.