UNPKG

can

Version:

MIT-licensed, client-side, JavaScript framework that makes building rich web applications easy.

811 lines (756 loc) 26 kB
steal('can/util', 'can/view/elements.js', 'can/view', 'can/view/node_lists', 'can/view/parser', 'can/util/array/diff.js', function (can, elements, view, nodeLists, parser, diff) { elements = elements || can.view.elements; nodeLists = nodeLists || can.view.NodeLists; parser = parser || can.view.parser; // ## live.js // // The live module provides live binding for computes // and can.List. // // Currently, it's API is designed for `can/view/render`, but // it could easily be used for other purposes. // ### Helper methods // // #### setup // // `setup(HTMLElement, bind(data), unbind(data)) -> data` // // Calls bind right away, but will call unbind // if the element is "destroyed" (removed from the DOM). var setup = function (el, bind, unbind) { // Removing an element can call teardown which // unregister the nodeList which calls teardown var tornDown = false, teardown = function () { if (!tornDown) { tornDown = true; unbind(data); can.unbind.call(el, 'removed', teardown); } return true; }, data = { teardownCheck: function (parent) { return parent ? false : teardown(); } }; can.bind.call(el, 'removed', teardown); bind(data); return data; }, getChildNodes = function(node){ var childNodes = node.childNodes; if("length" in childNodes) { return childNodes; } else { var cur = node.firstChild; var nodes = []; while(cur) { nodes.push(cur); cur = cur.nextSibling; } return nodes; } }, // #### listen // Calls setup, but presets bind and unbind to // operate on a compute listen = function (el, compute, change) { return setup(el, function () { compute.computeInstance.bind('change', change); }, function (data) { compute.computeInstance.unbind('change', change); if (data.nodeList) { nodeLists.unregister(data.nodeList); } }); }, // #### getAttributeParts // Breaks up a string like foo='bar' into ["foo","'bar'""] getAttributeParts = function (newVal) { var attrs = {}, attr; parser.parseAttrs(newVal,{ attrStart: function(name){ attrs[name] = ""; attr = name; }, attrValue: function(value){ attrs[attr] += value; }, attrEnd: function(){} }); return attrs; }, splice = [].splice, isNode = function(obj){ return obj && obj.nodeType; }, addTextNodeIfNoChildren = function(frag){ if(!frag.firstChild) { frag.appendChild(frag.ownerDocument.createTextNode("")); } }, getLiveFragment = function(itemHTML){ var gotText = typeof itemHTML === "string", // and convert it into elements. itemFrag = can.frag(itemHTML); // Add those elements to the mappings. return gotText ? can.view.hookup(itemFrag) : itemFrag; }, // a helper function that renders something and adds its nodeLists to newNodeLists // in the right way for both stache and mustache. renderAndAddToNodeLists = function(newNodeLists, parentNodeList, render, context, args){ var itemNodeList = []; if(parentNodeList) { // Pass in true so itemNodeList doesn't get added to the // parentNodeList's replacements array. #2332. nodeLists.register(itemNodeList,null, true, true); itemNodeList.parentList = parentNodeList; itemNodeList.expression = "#each SUBEXPRESSION"; } var itemHTML = render.apply(context, args.concat([itemNodeList])), itemFrag = getLiveFragment(itemHTML); var childNodes = can.makeArray(getChildNodes(itemFrag)); if(parentNodeList) { nodeLists.update(itemNodeList, childNodes); newNodeLists.push(itemNodeList); } else { newNodeLists.push(nodeLists.register(childNodes)); } return itemFrag; }, removeFromNodeList = function(masterNodeList, index, length){ var removedMappings = masterNodeList.splice(index + 1, length), itemsToRemove = []; can.each(removedMappings, function (nodeList) { // Unregister to free up event bindings. var nodesToRemove = nodeLists.unregister(nodeList); // add items that we will remove all at once [].push.apply(itemsToRemove, nodesToRemove); }); return itemsToRemove; }, addFalseyIfEmpty = function(list, falseyRender, masterNodeList, nodeList){ if(falseyRender && list.length === 0){ // there are no items ... we should render the falsey template var falseyNodeLists = []; var falseyFrag = renderAndAddToNodeLists(falseyNodeLists, nodeList, falseyRender, list, [list]); elements.after([masterNodeList[0]], falseyFrag); masterNodeList.push(falseyNodeLists[0]); } }, childMutationCallbacks = {}; /** * @property {Object} can.view.live * @parent can.view.static * @release 2.0.4 * * Setup live-binding between the DOM and a compute manually. * * @option {Object} An object with the live-binding methods: * [can.view.live.html], [can.view.live.list], [can.view.live.text], * [can.view.live.attr] and [can.view.live.attrs]. * * @body * * ## Use * * `can.view.live` is an object with utlitiy methods for setting up * live-binding in relation to different parts of the DOM and DOM elements. For * example, to make an `<h2>`'s text stay live with * a compute: * * var text = can.compute("Hello World"); * var textNode = $("h2").text(" ")[0].childNodes[0]; * can.view.live.text(textNode, text); * * */ var live = { registerChildMutationCallback: function(tag, callback){ if(callback) { childMutationCallbacks[tag] = callback; } else { return childMutationCallbacks[tag]; } }, callChildMutationCallback: function(el) { var callback = el && childMutationCallbacks[el.nodeName.toLowerCase()]; if(callback) { callback(el); } }, /** * @function can.view.live.list * @parent can.view.live * @release 2.0.4 * * Live binds a compute's [can.List] incrementally. * * * @param {HTMLElement} el An html element to replace with the live-section. * * @param {can.compute|can.List} list A [can.List] or [can.compute] whose value is a [can.List]. * * @param {function(this:*,*,index):String} render(index, index) A function that when called with * the incremental item to render and the index of the item in the list. * * @param {Object} context The `this` the `render` function will be called with. * * @param {HTMLElement} [parentNode] An overwritable parentNode if `el`'s parent is * a documentFragment. * * ## Use * * `can.view.live.list` is used to setup incremental live-binding. * * // a compute that change's it's list * var todos = can.compute(function(){ * return new Todo.List({page: can.route.attr("page")}) * }) * * var placeholder = document.createTextNode(" ") * $("ul#todos").append(placeholder) * * * * can.view.live.list( * placeholder, * todos, * function(todo, index){ * return "<li>"+todo.attr("name")+"</li>" * }) * */ list: function (el, compute, render, context, parentNode, nodeList, falseyRender) { // A nodeList of all elements this live-list manages. // This is here so that if this live list is within another section // that section is able to remove the items in this list. var masterNodeList = nodeList || [el], // A mapping of items to their indicies' indexMap = [], // True once all previous events have been fired afterPreviousEvents = false, // Indicates that we should not be responding to changes in the list. // It's possible that the compute change causes this list behavior to be torn down. // However that same "change" dispatch will eventually fire the updateList handler because // the list of "change" handlers is copied when dispatching starts. // A 'perfect' fix would be to use linked lists for event handlers. isTornDown = false, // Called when items are added to the list. add = function (ev, items, index) { if (!afterPreviousEvents) { return; } // Collect new html and mappings var frag = text.ownerDocument.createDocumentFragment(), newNodeLists = [], newIndicies = []; // For each new item, can.each(items, function (item, key) { var itemIndex = can.compute(key + index), itemFrag = renderAndAddToNodeLists(newNodeLists, nodeList, render, context, [item, itemIndex]); // Hookup the fragment (which sets up child live-bindings) and // add it to the collection of all added elements. frag.appendChild(itemFrag); // track indicies; newIndicies.push(itemIndex); }); // The position of elements is always after the initial text placeholder node var masterListIndex = index+1; // remove falsey if there's something there if(!indexMap.length) { // remove all leftover things var falseyItemsToRemove = removeFromNodeList(masterNodeList, 0, masterNodeList.length - 1); can.remove(can.$(falseyItemsToRemove)); } // Check if we are adding items at the end if (!masterNodeList[masterListIndex]) { elements.after(masterListIndex === 1 ? [text] : [nodeLists.last(masterNodeList[masterListIndex - 1])], frag); } else { // Add elements before the next index's first element. var el = nodeLists.first(masterNodeList[masterListIndex]); can.insertBefore(el.parentNode, frag, el); } splice.apply(masterNodeList, [ masterListIndex, 0 ].concat(newNodeLists)); // update indices after insert point splice.apply(indexMap, [ index, 0 ].concat(newIndicies)); for (var i = index + newIndicies.length, len = indexMap.length; i < len; i++) { indexMap[i](i); } if(ev.callChildMutationCallback !== false) { live.callChildMutationCallback(text.parentNode); } }, // Called when an item is set with .attr set = function(ev, newVal, index) { remove({}, { length: 1 }, index, true); add({}, [newVal], index); }, // Called when items are removed or when the bindings are torn down. remove = function (ev, items, index, duringTeardown, fullTeardown) { if (!afterPreviousEvents) { return; } // If this is because an element was removed, we should // check to make sure the live elements are still in the page. // If we did this during a teardown, it would cause an infinite loop. if (!duringTeardown && data.teardownCheck(text.parentNode)) { return; } if(index < 0) { index = indexMap.length + index; } var itemsToRemove = removeFromNodeList(masterNodeList, index, items.length); // update indices after remove point indexMap.splice(index, items.length); for (var i = index, len = indexMap.length; i < len; i++) { indexMap[i](i); } // don't remove elements during teardown. Something else will probably be doing that. if(!fullTeardown) { // adds the falsey section if the list is empty addFalseyIfEmpty(list, falseyRender, masterNodeList, nodeList); can.remove(can.$(itemsToRemove)); if(ev.callChildMutationCallback !== false) { live.callChildMutationCallback(text.parentNode); } } else { nodeLists.unregister(masterNodeList); } }, move = function (ev, item, newIndex, currentIndex) { if (!afterPreviousEvents) { return; } // The position of elements is always after the initial text // placeholder node newIndex = newIndex + 1; currentIndex = currentIndex + 1; var referenceNodeList = masterNodeList[newIndex]; var movedElements = can.frag( nodeLists.flatten(masterNodeList[currentIndex]) ); var referenceElement; // If we're moving forward in the list, we want to be placed before // the item AFTER the target index since removing the item from // the currentIndex drops the referenceItem's index. If there is no // nextSibling, insertBefore acts like appendChild. if (currentIndex < newIndex) { referenceElement = nodeLists.last(referenceNodeList).nextSibling; } else { referenceElement = nodeLists.first(referenceNodeList); } var parentNode = masterNodeList[0].parentNode; // Move the DOM nodes into the proper location parentNode.insertBefore(movedElements, referenceElement); // Now, do the same for the masterNodeList. We need to keep it // in sync with the DOM. // Save a reference to the "node" that we're manually moving var temp = masterNodeList[currentIndex]; // Remove the movedItem from the masterNodeList [].splice.apply(masterNodeList, [currentIndex, 1]); // Move the movedItem to the correct index in the masterNodeList [].splice.apply(masterNodeList, [newIndex, 0, temp]); // Convert back to a zero-based array index newIndex = newIndex - 1; currentIndex = currentIndex - 1; // Grab the index compute from the `indexMap` var indexCompute = indexMap[currentIndex]; // Remove the index compute from the `indexMap` [].splice.apply(indexMap, [currentIndex, 1]); // Move the index compute to the correct index in the `indexMap` [].splice.apply(indexMap, [newIndex, 0, indexCompute]); var i = Math.min(currentIndex, newIndex); var len = indexMap.length; for (i, len; i < len; i++) { indexMap[i](i); } if(ev.callChildMutationCallback !== false) { live.callChildMutationCallback(text.parentNode); } }, // A text node placeholder text = el.ownerDocument.createTextNode(''), // The current list. list, // Called when the list is replaced with a new list or the binding is torn-down. teardownList = function (fullTeardown) { // there might be no list right away, and the list might be a plain // array if (list && list.unbind) { list.unbind('add', add) .unbind('set', set) .unbind('remove', remove) .unbind('move', move); } // use remove to clean stuff up for us remove({callChildMutationCallback: !!fullTeardown}, { length: masterNodeList.length - 1 }, 0, true, fullTeardown); }, // Called when the list is replaced or setup. updateList = function (ev, newList, oldList) { if(isTornDown) { return; } afterPreviousEvents = true; if(newList && oldList) { list = newList || []; var patches = diff(oldList, newList); if ( oldList.unbind ) { oldList.unbind('add', add) .unbind('set', set) .unbind('remove', remove) .unbind('move', move); } for(var i = 0, patchLen = patches.length; i < patchLen; i++) { var patch = patches[i]; if(patch.deleteCount) { remove({callChildMutationCallback: false}, { length: patch.deleteCount }, patch.index, true); } if(patch.insert.length) { add({callChildMutationCallback: false}, patch.insert, patch.index); } } } else { if(oldList) { teardownList(); } list = newList || []; add({callChildMutationCallback: false}, list, 0); addFalseyIfEmpty(list, falseyRender, masterNodeList, nodeList); } live.callChildMutationCallback(text.parentNode); afterPreviousEvents = false; // list might be a plain array if (list.bind) { list.bind('add', add) .bind('set', set) .bind('remove', remove) .bind('move', move); } can.batch.afterPreviousEvents(function(){ afterPreviousEvents = true; }); }; parentNode = elements.getParentNode(el, parentNode); // Setup binding and teardown to add and remove events var data = setup(parentNode, function () { // TODO: for stache, binding on the compute is not necessary. if (can.isFunction(compute)) { compute.bind('change', updateList); } }, function () { if (can.isFunction(compute)) { compute.unbind('change', updateList); } teardownList(true); }); if(!nodeList) { live.replace(masterNodeList, text, data.teardownCheck); } else { elements.replace(masterNodeList, text); nodeLists.update(masterNodeList, [text]); nodeList.unregistered = function(){ data.teardownCheck(); isTornDown = true; }; } // run the list setup updateList({}, can.isFunction(compute) ? compute() : compute); }, /** * @function can.view.live.html * @parent can.view.live * @release 2.0.4 * * Live binds a compute's value to a collection of elements. * * * @param {HTMLElement} el An html element to replace with the live-section. * * @param {can.compute} compute A [can.compute] whose value is HTML. * * @param {HTMLElement} [parentNode] An overwritable parentNode if `el`'s parent is * a documentFragment. * * ## Use * * `can.view.live.html` is used to setup incremental live-binding. * * // a compute that change's it's list * var greeting = can.compute(function(){ * return "Welcome <i>"+me.attr("name")+"</i>" * }); * * var placeholder = document.createTextNode(" "); * $("#greeting").append(placeholder); * * can.view.live.html( placeholder, greeting ); * */ html: function (el, compute, parentNode, nodeList) { var data; parentNode = elements.getParentNode(el, parentNode); data = listen(parentNode, compute, function (ev, newVal, oldVal) { // TODO: remove teardownCheck in 2.1 var attached = nodeLists.first(nodes).parentNode; // update the nodes in the DOM with the new rendered value if (attached) { makeAndPut(newVal); } var pn = nodeLists.first(nodes).parentNode; data.teardownCheck(pn); live.callChildMutationCallback(pn); }); var nodes = nodeList || [el], makeAndPut = function (val) { var isFunction = typeof val === "function", aNode = isNode(val), frag = can.frag(isFunction ? "" : val), oldNodes = can.makeArray(nodes); // Add a placeholder textNode if necessary. addTextNodeIfNoChildren(frag); if(!aNode && !isFunction){ frag = can.view.hookup(frag, parentNode); } // We need to mark each node as belonging to the node list. oldNodes = nodeLists.update(nodes, getChildNodes(frag)); if(isFunction) { val(frag.firstChild); } elements.replace(oldNodes, frag); }; data.nodeList = nodes; // register the span so nodeLists knows the parentNodeList if(!nodeList) { nodeLists.register(nodes, data.teardownCheck); } else { nodeList.unregistered = data.teardownCheck; } makeAndPut(compute()); }, /** * @function can.view.live.replace * @parent can.view.live * @release 2.0.4 * * Replaces one element with some content while keeping [can.view.live.nodeLists nodeLists] data * correct. * * @param {Array.<HTMLElement>} nodes An array of elements. There should typically be one element. * @param {String|HTMLElement|DocumentFragment} val The content that should replace * `nodes`. If a string is passed, it will be [can.view.hookup hookedup]. * * @param {function} [teardown] A callback if these elements are torn down. */ replace: function (nodes, val, teardown) { var oldNodes = nodes.slice(0), frag = can.frag(val); nodeLists.register(nodes, teardown); if (typeof val === 'string') { // if it was a string, check for hookups frag = can.view.hookup(frag, nodes[0].parentNode); } // We need to mark each node as belonging to the node list. nodeLists.update(nodes, getChildNodes(frag)); elements.replace(oldNodes, frag); return nodes; }, /** * @function can.view.live.text * @parent can.view.live * @release 2.0.4 * * Replaces one element with some content while keeping [can.view.live.nodeLists nodeLists] data * correct. */ text: function (el, compute, parentNode, nodeList) { var parent = elements.getParentNode(el, parentNode); // setup listening right away so we don't have to re-calculate value var data = listen(parent, compute, function (ev, newVal, oldVal) { // Sometimes this is 'unknown' in IE and will throw an exception if it is /* jshint ignore:start */ if (typeof node.nodeValue !== 'unknown') { node.nodeValue = can.view.toStr(newVal); } /* jshint ignore:end */ // TODO: remove in 2.1 data.teardownCheck(node.parentNode); }); // The text node that will be updated var node = el.ownerDocument.createTextNode(can.view.toStr(compute())); if(nodeList) { nodeList.unregistered = data.teardownCheck; data.nodeList = nodeList; nodeLists.update(nodeList, [node]); elements.replace([el], node); } else { // Replace the placeholder with the live node and do the nodeLists thing. // Add that node to nodeList so we can remove it when the parent element is removed from the page data.nodeList = live.replace([el], node, data.teardownCheck); } }, setAttributes: function(el, newVal) { var attrs = getAttributeParts(newVal); for(var name in attrs) { can.attr.set(el, name, attrs[name]); } }, /** * @function can.view.live.attrs * @parent can.view.live * * Keep attributes live to a [can.compute]. * * @param {HTMLElement} el The element whos attributes will be kept live. * @param {can.compute} compute The compute. * * @body * * ## Use * * var div = document.createElement('div'); * var compute = can.compute("foo='bar' zed='ted'"); * can.view.live.attr(div,compute); * */ attributes: function (el, compute, currentValue) { var oldAttrs = {}; var setAttrs = function (newVal) { var newAttrs = getAttributeParts(newVal), name; for( name in newAttrs ) { var newValue = newAttrs[name], oldValue = oldAttrs[name]; if(newValue !== oldValue) { can.attr.set(el, name, newValue); } delete oldAttrs[name]; } for( name in oldAttrs ) { elements.removeAttr(el, name); } oldAttrs = newAttrs; }; listen(el, compute, function (ev, newVal) { setAttrs(newVal); }); // current value has been set if (arguments.length >= 3) { oldAttrs = getAttributeParts(currentValue); } else { setAttrs(compute()); } }, attributePlaceholder: '__!!__', attributeReplace: /__!!__/g, attribute: function (el, attributeName, compute) { listen(el, compute, function (ev, newVal) { elements.setAttr(el, attributeName, hook.render()); }); var wrapped = can.$(el), hooks; // Get the list of hookups or create one for this element. // Hooks is a map of attribute names to hookup `data`s. // Each hookup data has: // `render` - A `function` to render the value of the attribute. // `funcs` - A list of hookup `function`s on that attribute. // `batchNum` - The last event `batchNum`, used for performance. hooks = can.data(wrapped, 'hooks'); if (!hooks) { can.data(wrapped, 'hooks', hooks = {}); } // Get the attribute value. // Cast to String. String expected for rendering. Attr may return other types for some attributes. var attr = String(elements.getAttr(el, attributeName)), // Split the attribute value by the template. // Only split out the first __!!__ so if we have multiple hookups in the same attribute, // they will be put in the right spot on first render parts = attr.split(live.attributePlaceholder), goodParts = [], hook; goodParts.push(parts.shift(), parts.join(live.attributePlaceholder)); // If we already had a hookup for this attribute... if (hooks[attributeName]) { // Just add to that attribute's list of `function`s. hooks[attributeName].computes.push(compute); } else { // Create the hookup data. hooks[attributeName] = { render: function () { var i = 0, // attr doesn't have a value in IE newAttr = attr ? attr.replace(live.attributeReplace, function () { return elements.contentText(hook.computes[i++]()); }) : elements.contentText(hook.computes[i++]()); return newAttr; }, computes: [compute], batchNum: undefined }; } // Save the hook for slightly faster performance. hook = hooks[attributeName]; // Insert the value in parts. goodParts.splice(1, 0, compute()); // Set the attribute. elements.setAttr(el, attributeName, goodParts.join('')); }, specialAttribute: function (el, attributeName, compute) { listen(el, compute, function (ev, newVal) { elements.setAttr(el, attributeName, getValue(newVal)); }); elements.setAttr(el, attributeName, getValue(compute())); }, /** * @function can.view.live.attr * @parent can.view.live * * Keep an attribute live to a [can.compute]. * * @param {HTMLElement} el The element whos attribute will be kept live. * @param {String} attributeName The attribute name. * @param {can.compute} compute The compute. * * @body * * ## Use * * var div = document.createElement('div'); * var compute = can.compute("foo bar"); * can.view.live.attr(div,"class", compute); */ simpleAttribute: function(el, attributeName, compute){ listen(el, compute, function (ev, newVal) { elements.setAttr(el, attributeName, newVal); }); elements.setAttr(el, attributeName, compute()); } }; live.attr = live.simpleAttribute; live.attrs = live.attributes; live.getAttributeParts = getAttributeParts; var newLine = /(\r|\n)+/g; var getValue = function (val) { var regexp = /^["'].*["']$/; val = val.replace(elements.attrReg, '') .replace(newLine, ''); // check if starts and ends with " or ' return regexp.test(val) ? val.substr(1, val.length - 2) : val; }; can.view.live = live; return live; });