diff --git a/index.js b/index.js index 79882ce..7daccb9 100644 --- a/index.js +++ b/index.js @@ -2,8 +2,9 @@ // (c) Steven Sanderson - http://knockoutjs.com/ // License: MIT (http://www.opensource.org/licenses/mit-license.php) -(function(window,document,navigator,undefined){ +(function(){ var DEBUG=true; +(function(window,document,navigator,jQuery,undefined){ !function(factory) { // Support three module loading scenarios if (typeof require === 'function' && typeof exports === 'object' && typeof module === 'object') { @@ -196,7 +197,7 @@ ko.utils = new (function () { setOptionNodeSelectionState: function (optionNode, isSelected) { // IE6 sometimes throws "unknown error" if you try to write to .selected directly, whereas Firefox struggles with setAttribute. Pick one based on browser. - if (navigator.userAgent.indexOf("MSIE 6") >= 0) + if (ieVersion < 7) optionNode.setAttribute("selected", isSelected); else optionNode.selected = isSelected; @@ -320,18 +321,21 @@ ko.utils = new (function () { return ko.isObservable(value) ? value() : value; }, - toggleDomNodeCssClass: function (node, className, shouldHaveClass) { - var currentClassNames = (node.className || "").split(/\s+/); - var hasClass = ko.utils.arrayIndexOf(currentClassNames, className) >= 0; - - if (shouldHaveClass && !hasClass) { - node.className += (currentClassNames[0] ? " " : "") + className; - } else if (hasClass && !shouldHaveClass) { - var newClassName = ""; - for (var i = 0; i < currentClassNames.length; i++) - if (currentClassNames[i] != className) - newClassName += currentClassNames[i] + " "; - node.className = ko.utils.stringTrim(newClassName); + toggleDomNodeCssClass: function (node, classNames, shouldHaveClass) { + if (classNames) { + var cssClassNameRegex = /[\w-]+/g, + currentClassNames = node.className.match(cssClassNameRegex) || []; + ko.utils.arrayForEach(classNames.match(cssClassNameRegex), function(className) { + var indexOfClass = ko.utils.arrayIndexOf(currentClassNames, className); + if (indexOfClass >= 0) { + if (!shouldHaveClass) + currentClassNames.splice(indexOfClass, 1); + } else { + if (shouldHaveClass) + currentClassNames.push(className); + } + }); + node.className = currentClassNames.join(" "); } }, @@ -340,13 +344,40 @@ ko.utils = new (function () { if ((value === null) || (value === undefined)) value = ""; - 'innerText' in element ? element.innerText = value - : element.textContent = value; + // We need there to be exactly one child: a text node. + // If there are no children, more than one, or if it's not a text node, + // we'll clear everything and create a single text node. + var innerTextNode = ko.virtualElements.firstChild(element); + if (!innerTextNode || innerTextNode.nodeType != 3 || ko.virtualElements.nextSibling(innerTextNode)) { + ko.virtualElements.setDomNodeChildren(element, [document.createTextNode(value)]); + } else { + innerTextNode.data = value; + } + + ko.utils.forceRefresh(element); + }, + + setElementName: function(element, name) { + element.name = name; + + // Workaround IE 6/7 issue + // - https://github.com/SteveSanderson/knockout/issues/197 + // - http://www.matts411.com/post/setting_the_name_attribute_in_ie_dom/ + if (ieVersion <= 7) { + try { + element.mergeAttributes(document.createElement(""), false); + } + catch(e) {} // For IE9 with doc mode "IE9 Standards" and browser mode "IE9 Compatibility View" + } + }, + forceRefresh: function(node) { + // Workaround for an IE9 rendering bug - https://github.com/SteveSanderson/knockout/issues/209 if (ieVersion >= 9) { - // Believe it or not, this actually fixes an IE9 rendering bug - // (See https://github.com/SteveSanderson/knockout/issues/209) - element.style.display = element.style.display; + // For text nodes and comment nodes (most likely virtual elements), we will have to refresh the container + var elem = node.nodeType == 1 ? node : node.parentNode; + if (elem.style) + elem.style.zoom = elem.style.zoom; } }, @@ -687,6 +718,9 @@ ko.exportSymbol('utils.domNodeDisposal.removeDisposeCallback', ko.utils.domNodeD ko.utils.setHtml = function(node, html) { ko.utils.emptyDomNode(node); + // There's no legitimate reason to display a stringified observable without unwrapping it, so we'll unwrap it + html = ko.utils.unwrapObservable(html); + if ((html !== null) && (html !== undefined)) { if (typeof html != 'string') html = html.toString(); @@ -863,12 +897,14 @@ ko.subscribable['fn'] = { "notifySubscribers": function (valueToNotify, event) { event = event || defaultEvent; if (this._subscriptions[event]) { - ko.utils.arrayForEach(this._subscriptions[event].slice(0), function (subscription) { - // In case a subscription was disposed during the arrayForEach cycle, check - // for isDisposed on each subscription before invoking its callback - if (subscription && (subscription.isDisposed !== true)) - subscription.callback(valueToNotify); - }); + ko.dependencyDetection.ignore(function() { + ko.utils.arrayForEach(this._subscriptions[event].slice(0), function (subscription) { + // In case a subscription was disposed during the arrayForEach cycle, check + // for isDisposed on each subscription before invoking its callback + if (subscription && (subscription.isDisposed !== true)) + subscription.callback(valueToNotify); + }); + }, this); } }, @@ -909,11 +945,20 @@ ko.dependencyDetection = (function () { throw new Error("Only subscribable things can act as dependencies"); if (_frames.length > 0) { var topFrame = _frames[_frames.length - 1]; - if (ko.utils.arrayIndexOf(topFrame.distinctDependencies, subscribable) >= 0) + if (!topFrame || ko.utils.arrayIndexOf(topFrame.distinctDependencies, subscribable) >= 0) return; topFrame.distinctDependencies.push(subscribable); topFrame.callback(subscribable); } + }, + + ignore: function(callback, callbackTarget) { + try { + _frames.push(null); + callback.call(callbackTarget); + } finally { + _frames.pop(); + } } }; })(); @@ -943,10 +988,12 @@ ko.observable = function (initialValue) { } if (DEBUG) observable._latestValue = _latestValue; ko.subscribable.call(observable); + observable.peek = function() { return _latestValue }; observable.valueHasMutated = function () { observable["notifySubscribers"](_latestValue); } observable.valueWillMutate = function () { observable["notifySubscribers"](_latestValue, "beforeChange"); } ko.utils.extend(observable, ko.observable['fn']); + ko.exportProperty(observable, 'peek', observable.peek); ko.exportProperty(observable, "valueHasMutated", observable.valueHasMutated); ko.exportProperty(observable, "valueWillMutate", observable.valueWillMutate); @@ -1002,7 +1049,7 @@ ko.observableArray = function (initialValues) { ko.observableArray['fn'] = { 'remove': function (valueOrPredicate) { - var underlyingArray = this(); + var underlyingArray = this.peek(); var removedValues = []; var predicate = typeof valueOrPredicate == "function" ? valueOrPredicate : function (value) { return value === valueOrPredicate; }; for (var i = 0; i < underlyingArray.length; i++) { @@ -1025,7 +1072,7 @@ ko.observableArray['fn'] = { 'removeAll': function (arrayOfValues) { // If you passed zero args, we remove everything if (arrayOfValues === undefined) { - var underlyingArray = this(); + var underlyingArray = this.peek(); var allValues = underlyingArray.slice(0); this.valueWillMutate(); underlyingArray.splice(0, underlyingArray.length); @@ -1041,7 +1088,7 @@ ko.observableArray['fn'] = { }, 'destroy': function (valueOrPredicate) { - var underlyingArray = this(); + var underlyingArray = this.peek(); var predicate = typeof valueOrPredicate == "function" ? valueOrPredicate : function (value) { return value === valueOrPredicate; }; this.valueWillMutate(); for (var i = underlyingArray.length - 1; i >= 0; i--) { @@ -1074,16 +1121,20 @@ ko.observableArray['fn'] = { var index = this['indexOf'](oldItem); if (index >= 0) { this.valueWillMutate(); - this()[index] = newItem; + this.peek()[index] = newItem; this.valueHasMutated(); } } } // Populate ko.observableArray.fn with read/write functions from native arrays +// Important: Do not add any additional functions here that may reasonably be used to *read* data from the array +// because we'll eval them without causing subscriptions, so ko.computed output could end up getting stale ko.utils.arrayForEach(["pop", "push", "reverse", "shift", "sort", "splice", "unshift"], function (methodName) { ko.observableArray['fn'][methodName] = function () { - var underlyingArray = this(); + // Use "peek" to avoid creating a subscription in any computed that we're executing in the context of + // (for consistency with mutating regular observables) + var underlyingArray = this.peek(); this.valueWillMutate(); var methodCallResult = underlyingArray[methodName].apply(underlyingArray, arguments); this.valueHasMutated(); @@ -1214,26 +1265,25 @@ ko.dependentObservable = function (evaluatorFunctionOrOptions, evaluatorFunction function dependentObservable() { if (arguments.length > 0) { - set.apply(dependentObservable, arguments); - } else { - return get(); - } - } - - function set() { - if (typeof writeFunction === "function") { - // Writing a value - writeFunction.apply(evaluatorFunctionTarget, arguments); + if (typeof writeFunction === "function") { + // Writing a value + writeFunction.apply(evaluatorFunctionTarget, arguments); + } else { + throw new Error("Cannot write a value to a ko.computed unless you specify a 'write' option. If you wish to read the current value, don't pass any parameters."); + } + return this; // Permits chained assignments } else { - throw new Error("Cannot write a value to a ko.computed unless you specify a 'write' option. If you wish to read the current value, don't pass any parameters."); + // Reading the value + if (!_hasBeenEvaluated) + evaluateImmediate(); + ko.dependencyDetection.registerDependency(dependentObservable); + return _latestValue; } } - function get() { - // Reading the value + dependentObservable.peek = function () { if (!_hasBeenEvaluated) evaluateImmediate(); - ko.dependencyDetection.registerDependency(dependentObservable); return _latestValue; } @@ -1247,6 +1297,7 @@ ko.dependentObservable = function (evaluatorFunctionOrOptions, evaluatorFunction if (options['deferEvaluation'] !== true) evaluateImmediate(); + ko.exportProperty(dependentObservable, 'peek', dependentObservable.peek); ko.exportProperty(dependentObservable, 'dispose', dependentObservable.dispose); ko.exportProperty(dependentObservable, 'getDependenciesCount', dependentObservable.getDependenciesCount); @@ -1369,7 +1420,9 @@ ko.exportSymbol('toJSON', ko.toJSON); case 'option': if (element[hasDomDataExpandoProperty] === true) return ko.utils.domData.get(element, ko.bindingHandlers.options.optionValueDomDataKey); - return element.getAttribute("value"); + return ko.utils.ieVersion <= 7 + ? (element.getAttributeNode('value').specified ? element.value : element.text) + : element.value; case 'select': return element.selectedIndex >= 0 ? ko.selectExtensions.readValue(element.options[element.selectedIndex]) : undefined; default: @@ -1419,12 +1472,14 @@ ko.exportSymbol('toJSON', ko.toJSON); ko.exportSymbol('selectExtensions', ko.selectExtensions); ko.exportSymbol('selectExtensions.readValue', ko.selectExtensions.readValue); ko.exportSymbol('selectExtensions.writeValue', ko.selectExtensions.writeValue); - -ko.jsonExpressionRewriting = (function () { +ko.expressionRewriting = (function () { var restoreCapturedTokensRegex = /\@ko_token_(\d+)\@/g; - var javaScriptAssignmentTarget = /^[\_$a-z][\_$a-z0-9]*(\[.*?\])*(\.[\_$a-z][\_$a-z0-9]*(\[.*?\])*)*$/i; var javaScriptReservedWords = ["true", "false"]; + // Matches something that can be assigned to--either an isolated identifier or something ending with a property accessor + // This is designed to be simple and avoid false negatives, but could produce false positives (e.g., a+b.c). + var javaScriptAssignmentTarget = /^(?:[$_a-z][$\w]*|(.+)(\.\s*[$_a-z][$\w]*|\[.+\]))$/i; + function restoreTokens(string, tokens) { var prevValue = null; while (string != prevValue) { // Keep restoring tokens until it no longer makes a difference (they may be nested) @@ -1436,10 +1491,11 @@ ko.jsonExpressionRewriting = (function () { return string; } - function isWriteableValue(expression) { + function getWriteableValue(expression) { if (ko.utils.arrayIndexOf(javaScriptReservedWords, ko.utils.stringTrim(expression).toLowerCase()) >= 0) return false; - return expression.match(javaScriptAssignmentTarget) !== null; + var match = expression.match(javaScriptAssignmentTarget); + return match === null ? false : match[1] ? ('Object(' + match[1] + ')' + match[2]) : expression; } function ensureQuoted(key) { @@ -1542,9 +1598,9 @@ ko.jsonExpressionRewriting = (function () { return result; }, - insertPropertyAccessorsIntoJson: function (objectLiteralStringOrKeyValueArray) { + preProcessBindings: function (objectLiteralStringOrKeyValueArray) { var keyValueArray = typeof objectLiteralStringOrKeyValueArray === "string" - ? ko.jsonExpressionRewriting.parseObjectLiteral(objectLiteralStringOrKeyValueArray) + ? ko.expressionRewriting.parseObjectLiteral(objectLiteralStringOrKeyValueArray) : objectLiteralStringOrKeyValueArray; var resultStrings = [], propertyAccessorResultStrings = []; @@ -1559,7 +1615,7 @@ ko.jsonExpressionRewriting = (function () { resultStrings.push(":"); resultStrings.push(val); - if (isWriteableValue(ko.utils.stringTrim(val))) { + if (val = getWriteableValue(ko.utils.stringTrim(val))) { if (propertyAccessorResultStrings.length > 0) propertyAccessorResultStrings.push(", "); propertyAccessorResultStrings.push(quotedKey + " : function(__ko_value) { " + val + " = __ko_value; }"); @@ -1606,11 +1662,15 @@ ko.jsonExpressionRewriting = (function () { }; })(); -ko.exportSymbol('jsonExpressionRewriting', ko.jsonExpressionRewriting); -ko.exportSymbol('jsonExpressionRewriting.bindingRewriteValidators', ko.jsonExpressionRewriting.bindingRewriteValidators); -ko.exportSymbol('jsonExpressionRewriting.parseObjectLiteral', ko.jsonExpressionRewriting.parseObjectLiteral); -ko.exportSymbol('jsonExpressionRewriting.insertPropertyAccessorsIntoJson', ko.jsonExpressionRewriting.insertPropertyAccessorsIntoJson); -(function() { +ko.exportSymbol('expressionRewriting', ko.expressionRewriting); +ko.exportSymbol('expressionRewriting.bindingRewriteValidators', ko.expressionRewriting.bindingRewriteValidators); +ko.exportSymbol('expressionRewriting.parseObjectLiteral', ko.expressionRewriting.parseObjectLiteral); +ko.exportSymbol('expressionRewriting.preProcessBindings', ko.expressionRewriting.preProcessBindings); + +// For backward compatibility, define the following aliases. (Previously, these function names were misleading because +// they referred to JSON specifically, even though they actually work with arbitrary JavaScript object literal expressions.) +ko.exportSymbol('jsonExpressionRewriting', ko.expressionRewriting); +ko.exportSymbol('jsonExpressionRewriting.insertPropertyAccessorsIntoJson', ko.expressionRewriting.preProcessBindings);(function() { // "Virtual elements" is an abstraction on top of the usual DOM API which understands the notion that comment nodes // may be used to represent hierarchy (in addition to the DOM's natural hierarchy). // If you call the DOM-manipulating functions on ko.virtualElements, you will be able to read and write the state @@ -1624,7 +1684,7 @@ ko.exportSymbol('jsonExpressionRewriting.insertPropertyAccessorsIntoJson', ko.js // So, use node.text where available, and node.nodeValue elsewhere var commentNodesHaveTextProperty = document.createComment("test").text === ""; - var startCommentRegex = commentNodesHaveTextProperty ? /^$/ : /^\s*ko\s+(.*\:.*)\s*$/; + var startCommentRegex = commentNodesHaveTextProperty ? /^$/ : /^\s*ko(?:\s+(.+\s*\:[\s\S]*))?\s*$/; var endCommentRegex = commentNodesHaveTextProperty ? /^$/ : /^\s*\/ko\s*$/; var htmlTagsWithOptionallyClosingChildren = { 'ul': true, 'ol': true }; @@ -1730,7 +1790,9 @@ ko.exportSymbol('jsonExpressionRewriting.insertPropertyAccessorsIntoJson', ko.js }, insertAfter: function(containerNode, nodeToInsert, insertAfterNode) { - if (!isStartComment(containerNode)) { + if (!insertAfterNode) { + ko.virtualElements.prepend(containerNode, nodeToInsert); + } else if (!isStartComment(containerNode)) { // Insert after insertion point if (insertAfterNode.nextSibling) containerNode.insertBefore(nodeToInsert, insertAfterNode.nextSibling); @@ -1855,7 +1917,7 @@ ko.exportSymbol('virtualElements.setDomNodeChildren', ko.virtualElements.setDomN } function createBindingsStringEvaluator(bindingsString, scopesCount) { - var rewrittenBindings = " { " + ko.jsonExpressionRewriting.insertPropertyAccessorsIntoJson(bindingsString) + " } "; + var rewrittenBindings = " { " + ko.expressionRewriting.preProcessBindings(bindingsString) + " } "; return ko.utils.buildEvalWithinScopeFunction(rewrittenBindings, scopesCount); } })(); @@ -1864,7 +1926,7 @@ ko.exportSymbol('bindingProvider', ko.bindingProvider); (function () { ko.bindingHandlers = {}; - ko.bindingContext = function(dataItem, parentBindingContext) { + ko.bindingContext = function(dataItem, parentBindingContext, dataItemAlias) { if (parentBindingContext) { ko.utils.extend(this, parentBindingContext); // Inherit $root and any custom properties this['$parentContext'] = parentBindingContext; @@ -1874,11 +1936,17 @@ ko.exportSymbol('bindingProvider', ko.bindingProvider); } else { this['$parents'] = []; this['$root'] = dataItem; + // Export 'ko' in the binding context so it will be available in bindings and templates + // even if 'ko' isn't exported as a global, such as when using an AMD loader. + // See https://github.com/SteveSanderson/knockout/issues/490 + this['ko'] = ko; } this['$data'] = dataItem; + if (dataItemAlias) + this[dataItemAlias] = dataItem; } - ko.bindingContext.prototype['createChildContext'] = function (dataItem) { - return new ko.bindingContext(dataItem, this); + ko.bindingContext.prototype['createChildContext'] = function (dataItem, dataItemAlias) { + return new ko.bindingContext(dataItem, this, dataItemAlias); }; ko.bindingContext.prototype['extend'] = function(properties) { var clone = ko.utils.extend(new ko.bindingContext(), this); @@ -2194,7 +2262,7 @@ ko.bindingHandlers['value'] = { var valueUpdateHandler = function() { var modelValue = valueAccessor(); var elementValue = ko.selectExtensions.readValue(element); - ko.jsonExpressionRewriting.writeValueToProperty(modelValue, allBindingsAccessor, 'value', elementValue, /* checkIfDifferent: */ true); + ko.expressionRewriting.writeValueToProperty(modelValue, allBindingsAccessor, 'value', elementValue, /* checkIfDifferent: */ true); } // Workaround for https://github.com/SteveSanderson/knockout/issues/122 @@ -2278,7 +2346,9 @@ ko.bindingHandlers['options'] = { } if (value) { - var allBindings = allBindingsAccessor(); + var allBindings = allBindingsAccessor(), + includeDestroyed = allBindings['optionsIncludeDestroyed']; + if (typeof value.length != "number") value = [value]; if (allBindings['optionsCaption']) { @@ -2287,26 +2357,31 @@ ko.bindingHandlers['options'] = { ko.selectExtensions.writeValue(option, undefined); element.appendChild(option); } + for (var i = 0, j = value.length; i < j; i++) { + // Skip destroyed items + var arrayEntry = value[i]; + if (arrayEntry && arrayEntry['_destroy'] && !includeDestroyed) + continue; + var option = document.createElement("option"); + function applyToObject(object, predicate, defaultValue) { + var predicateType = typeof predicate; + if (predicateType == "function") // Given a function; run it against the data value + return predicate(object); + else if (predicateType == "string") // Given a string; treat it as a property name on the data value + return object[predicate]; + else // Given no optionsText arg; use the data value itself + return defaultValue; + } + // Apply a value to the option element - var optionValue = typeof allBindings['optionsValue'] == "string" ? value[i][allBindings['optionsValue']] : value[i]; - optionValue = ko.utils.unwrapObservable(optionValue); - ko.selectExtensions.writeValue(option, optionValue); + var optionValue = applyToObject(arrayEntry, allBindings['optionsValue'], arrayEntry); + ko.selectExtensions.writeValue(option, ko.utils.unwrapObservable(optionValue)); // Apply some text to the option element - var optionsTextValue = allBindings['optionsText']; - var optionText; - if (typeof optionsTextValue == "function") - optionText = optionsTextValue(value[i]); // Given a function; run it against the data value - else if (typeof optionsTextValue == "string") - optionText = value[i][optionsTextValue]; // Given a string; treat it as a property name on the data value - else - optionText = optionValue; // Given no optionsText arg; use the data value itself - if ((optionText === null) || (optionText === undefined)) - optionText = ""; - + var optionText = applyToObject(arrayEntry, allBindings['optionsText'], optionValue); ko.utils.setTextContent(option, optionText); element.appendChild(option); @@ -2340,25 +2415,14 @@ ko.bindingHandlers['options'] = { ko.bindingHandlers['options'].optionValueDomDataKey = '__ko.optionValueDomData__'; ko.bindingHandlers['selectedOptions'] = { - getSelectedValuesFromSelectNode: function (selectNode) { - var result = []; - var nodes = selectNode.childNodes; - for (var i = 0, j = nodes.length; i < j; i++) { - var node = nodes[i], tagName = ko.utils.tagNameLower(node); - if (tagName == "option" && node.selected) - result.push(ko.selectExtensions.readValue(node)); - else if (tagName == "optgroup") { - var selectedValuesFromOptGroup = ko.bindingHandlers['selectedOptions'].getSelectedValuesFromSelectNode(node); - Array.prototype.splice.apply(result, [result.length, 0].concat(selectedValuesFromOptGroup)); // Add new entries to existing 'result' instance - } - } - return result; - }, 'init': function (element, valueAccessor, allBindingsAccessor) { ko.utils.registerEventHandler(element, "change", function () { - var value = valueAccessor(); - var valueToWrite = ko.bindingHandlers['selectedOptions'].getSelectedValuesFromSelectNode(this); - ko.jsonExpressionRewriting.writeValueToProperty(value, allBindingsAccessor, 'value', valueToWrite); + var value = valueAccessor(), valueToWrite = []; + ko.utils.arrayForEach(element.getElementsByTagName("option"), function(node) { + if (node.selected) + valueToWrite.push(ko.selectExtensions.readValue(node)); + }); + ko.expressionRewriting.writeValueToProperty(value, allBindingsAccessor, 'value', valueToWrite); }); }, 'update': function (element, valueAccessor) { @@ -2367,12 +2431,10 @@ ko.bindingHandlers['selectedOptions'] = { var newValue = ko.utils.unwrapObservable(valueAccessor()); if (newValue && typeof newValue.length == "number") { - var nodes = element.childNodes; - for (var i = 0, j = nodes.length; i < j; i++) { - var node = nodes[i]; - if (ko.utils.tagNameLower(node) === "option") - ko.utils.setOptionNodeSelectionState(node, ko.utils.arrayIndexOf(newValue, ko.selectExtensions.readValue(node)) >= 0); - } + ko.utils.arrayForEach(element.getElementsByTagName("option"), function(node) { + var isSelected = ko.utils.arrayIndexOf(newValue, ko.selectExtensions.readValue(node)) >= 0; + ko.utils.setOptionNodeSelectionState(node, isSelected); + }); } } }; @@ -2382,6 +2444,7 @@ ko.bindingHandlers['text'] = { ko.utils.setTextContent(element, valueAccessor()); } }; +ko.virtualElements.allowedBindings['text'] = true; ko.bindingHandlers['html'] = { 'init': function() { @@ -2389,19 +2452,25 @@ ko.bindingHandlers['html'] = { return { 'controlsDescendantBindings': true }; }, 'update': function (element, valueAccessor) { - var value = ko.utils.unwrapObservable(valueAccessor()); - ko.utils.setHtml(element, value); + // setHtml will unwrap the value if needed + ko.utils.setHtml(element, valueAccessor()); } }; +var classesWrittenByBindingKey = '__ko__cssValue'; ko.bindingHandlers['css'] = { 'update': function (element, valueAccessor) { - var value = ko.utils.unwrapObservable(valueAccessor() || {}); - for (var className in value) { - if (typeof className == "string") { + var value = ko.utils.unwrapObservable(valueAccessor()); + if (typeof value == "object") { + for (var className in value) { var shouldHaveClass = ko.utils.unwrapObservable(value[className]); ko.utils.toggleDomNodeCssClass(element, className, shouldHaveClass); } + } else { + value = String(value || ''); // Make sure we don't try to store or set a non-string value + ko.utils.toggleDomNodeCssClass(element, element[classesWrittenByBindingKey], false); + element[classesWrittenByBindingKey] = value; + ko.utils.toggleDomNodeCssClass(element, value, true); } } }; @@ -2421,13 +2490,8 @@ ko.bindingHandlers['style'] = { ko.bindingHandlers['uniqueName'] = { 'init': function (element, valueAccessor) { if (valueAccessor()) { - element.name = "ko_unique_" + (++ko.bindingHandlers['uniqueName'].currentIndex); - - // Workaround IE 6/7 issue - // - https://github.com/SteveSanderson/knockout/issues/197 - // - http://www.matts411.com/post/setting_the_name_attribute_in_ie_dom/ - if (ko.utils.isIe6 || ko.utils.isIe7) - element.mergeAttributes(document.createElement(""), false); + var name = "ko_unique_" + (++ko.bindingHandlers['uniqueName'].currentIndex); + ko.utils.setElementName(element, name); } } }; @@ -2455,7 +2519,7 @@ ko.bindingHandlers['checked'] = { else if ((!element.checked) && (existingEntryIndex >= 0)) modelValue.splice(existingEntryIndex, 1); } else { - ko.jsonExpressionRewriting.writeValueToProperty(modelValue, allBindingsAccessor, 'checked', valueToWrite, true); + ko.expressionRewriting.writeValueToProperty(modelValue, allBindingsAccessor, 'checked', valueToWrite, true); } }; ko.utils.registerEventHandler(element, "click", updateHandler); @@ -2509,26 +2573,52 @@ ko.bindingHandlers['attr'] = { } else if (!toRemove) { element.setAttribute(attrName, attrValue.toString()); } + + // Treat "name" specially - although you can think of it as an attribute, it also needs + // special handling on older versions of IE (https://github.com/SteveSanderson/knockout/pull/333) + // Deliberately being case-sensitive here because XHTML would regard "Name" as a different thing + // entirely, and there's no strong reason to allow for such casing in HTML. + if (attrName === "name") { + ko.utils.setElementName(element, toRemove ? "" : attrValue.toString()); + } } } } }; +var hasfocusUpdatingProperty = '__ko_hasfocusUpdating'; ko.bindingHandlers['hasfocus'] = { 'init': function(element, valueAccessor, allBindingsAccessor) { - var writeValue = function(valueToWrite) { + var handleElementFocusChange = function(isFocused) { + // Where possible, ignore which event was raised and determine focus state using activeElement, + // as this avoids phantom focus/blur events raised when changing tabs in modern browsers. + // However, not all KO-targeted browsers (Firefox 2) support activeElement. For those browsers, + // prevent a loss of focus when changing tabs/windows by setting a flag that prevents hasfocus + // from calling 'blur()' on the element when it loses focus. + // Discussion at https://github.com/SteveSanderson/knockout/pull/352 + element[hasfocusUpdatingProperty] = true; + var ownerDoc = element.ownerDocument; + if ("activeElement" in ownerDoc) { + isFocused = (ownerDoc.activeElement === element); + } var modelValue = valueAccessor(); - ko.jsonExpressionRewriting.writeValueToProperty(modelValue, allBindingsAccessor, 'hasfocus', valueToWrite, true); + ko.expressionRewriting.writeValueToProperty(modelValue, allBindingsAccessor, 'hasfocus', isFocused, true); + element[hasfocusUpdatingProperty] = false; }; - ko.utils.registerEventHandler(element, "focus", function() { writeValue(true) }); - ko.utils.registerEventHandler(element, "focusin", function() { writeValue(true) }); // For IE - ko.utils.registerEventHandler(element, "blur", function() { writeValue(false) }); - ko.utils.registerEventHandler(element, "focusout", function() { writeValue(false) }); // For IE + var handleElementFocusIn = handleElementFocusChange.bind(null, true); + var handleElementFocusOut = handleElementFocusChange.bind(null, false); + + ko.utils.registerEventHandler(element, "focus", handleElementFocusIn); + ko.utils.registerEventHandler(element, "focusin", handleElementFocusIn); // For IE + ko.utils.registerEventHandler(element, "blur", handleElementFocusOut); + ko.utils.registerEventHandler(element, "focusout", handleElementFocusOut); // For IE }, 'update': function(element, valueAccessor) { - var value = ko.utils.unwrapObservable(valueAccessor()); - value ? element.focus() : element.blur(); - ko.utils.triggerEvent(element, value ? "focusin" : "focusout"); // For IE, which doesn't reliably fire "focus" or "blur" events synchronously + if (!element[hasfocusUpdatingProperty]) { + var value = ko.utils.unwrapObservable(valueAccessor()); + value ? element.focus() : element.blur(); + ko.utils.triggerEvent(element, value ? "focusin" : "focusout"); // For IE, which doesn't reliably fire "focus" or "blur" events synchronously + } } }; @@ -2544,7 +2634,7 @@ ko.bindingHandlers['with'] = { return ko.bindingHandlers['template']['update'](element, ko.bindingHandlers['with'].makeTemplateValueAccessor(valueAccessor), allBindingsAccessor, viewModel, bindingContext); } }; -ko.jsonExpressionRewriting.bindingRewriteValidators['with'] = false; // Can't rewrite control flow bindings +ko.expressionRewriting.bindingRewriteValidators['with'] = false; // Can't rewrite control flow bindings ko.virtualElements.allowedBindings['with'] = true; // "if: someExpression" is equivalent to "template: { if: someExpression }" @@ -2559,7 +2649,7 @@ ko.bindingHandlers['if'] = { return ko.bindingHandlers['template']['update'](element, ko.bindingHandlers['if'].makeTemplateValueAccessor(valueAccessor), allBindingsAccessor, viewModel, bindingContext); } }; -ko.jsonExpressionRewriting.bindingRewriteValidators['if'] = false; // Can't rewrite control flow bindings +ko.expressionRewriting.bindingRewriteValidators['if'] = false; // Can't rewrite control flow bindings ko.virtualElements.allowedBindings['if'] = true; // "ifnot: someExpression" is equivalent to "template: { ifnot: someExpression }" @@ -2574,7 +2664,7 @@ ko.bindingHandlers['ifnot'] = { return ko.bindingHandlers['template']['update'](element, ko.bindingHandlers['ifnot'].makeTemplateValueAccessor(valueAccessor), allBindingsAccessor, viewModel, bindingContext); } }; -ko.jsonExpressionRewriting.bindingRewriteValidators['ifnot'] = false; // Can't rewrite control flow bindings +ko.expressionRewriting.bindingRewriteValidators['ifnot'] = false; // Can't rewrite control flow bindings ko.virtualElements.allowedBindings['ifnot'] = true; // "foreach: someExpression" is equivalent to "template: { foreach: someExpression }" @@ -2591,6 +2681,7 @@ ko.bindingHandlers['foreach'] = { // If bindingValue.data is the array, preserve all relevant options return { 'foreach': bindingValue['data'], + 'as': bindingValue['as'], 'includeDestroyed': bindingValue['includeDestroyed'], 'afterAdd': bindingValue['afterAdd'], 'beforeRemove': bindingValue['beforeRemove'], @@ -2606,9 +2697,8 @@ ko.bindingHandlers['foreach'] = { return ko.bindingHandlers['template']['update'](element, ko.bindingHandlers['foreach'].makeTemplateValueAccessor(valueAccessor), allBindingsAccessor, viewModel, bindingContext); } }; -ko.jsonExpressionRewriting.bindingRewriteValidators['foreach'] = false; // Can't rewrite control flow bindings -ko.virtualElements.allowedBindings['foreach'] = true; -// If you want to make a custom template engine, +ko.expressionRewriting.bindingRewriteValidators['foreach'] = false; // Can't rewrite control flow bindings +ko.virtualElements.allowedBindings['foreach'] = true;// If you want to make a custom template engine, // // [1] Inherit from this class (like ko.nativeTemplateEngine does) // [2] Override 'renderTemplateSource', supplying a function with this signature: @@ -2668,12 +2758,6 @@ ko.templateEngine.prototype['isTemplateRewritten'] = function (template, templat // Skip rewriting if requested if (this['allowTemplateRewriting'] === false) return true; - - // Perf optimisation - see below - var templateIsInExternalDocument = templateDocument && templateDocument != document; - if (!templateIsInExternalDocument && this.knownRewrittenTemplates && this.knownRewrittenTemplates[template]) - return true; - return this['makeTemplateSource'](template, templateDocument)['data']("isRewritten"); }; @@ -2682,19 +2766,6 @@ ko.templateEngine.prototype['rewriteTemplate'] = function (template, rewriterCal var rewritten = rewriterCallback(templateSource['text']()); templateSource['text'](rewritten); templateSource['data']("isRewritten", true); - - // Perf optimisation - for named templates, track which ones have been rewritten so we can - // answer 'isTemplateRewritten' *without* having to use getElementById (which is slow on IE < 8) - // - // Note that we only cache the status for templates in the main document, because caching on a per-doc - // basis complicates the implementation excessively. In a future version of KO, we will likely remove - // this 'isRewritten' cache entirely anyway, because the benefit is extremely minor and only applies - // to rewritable templates, which are pretty much deprecated since KO 2.0. - var templateIsInExternalDocument = templateDocument && templateDocument != document; - if (!templateIsInExternalDocument && typeof template == "string") { - this.knownRewrittenTemplates = this.knownRewrittenTemplates || {}; - this.knownRewrittenTemplates[template] = true; - } }; ko.exportSymbol('templateEngine', ko.templateEngine); @@ -2704,7 +2775,7 @@ ko.templateRewriting = (function () { var memoizeVirtualContainerBindingSyntaxRegex = //g; function validateDataBindValuesForRewriting(keyValueArray) { - var allValidators = ko.jsonExpressionRewriting.bindingRewriteValidators; + var allValidators = ko.expressionRewriting.bindingRewriteValidators; for (var i = 0; i < keyValueArray.length; i++) { var key = keyValueArray[i]['key']; if (allValidators.hasOwnProperty(key)) { @@ -2722,16 +2793,15 @@ ko.templateRewriting = (function () { } function constructMemoizedTagReplacement(dataBindAttributeValue, tagToRetain, templateEngine) { - var dataBindKeyValueArray = ko.jsonExpressionRewriting.parseObjectLiteral(dataBindAttributeValue); + var dataBindKeyValueArray = ko.expressionRewriting.parseObjectLiteral(dataBindAttributeValue); validateDataBindValuesForRewriting(dataBindKeyValueArray); - var rewrittenDataBindAttributeValue = ko.jsonExpressionRewriting.insertPropertyAccessorsIntoJson(dataBindKeyValueArray); + var rewrittenDataBindAttributeValue = ko.expressionRewriting.preProcessBindings(dataBindKeyValueArray); // For no obvious reason, Opera fails to evaluate rewrittenDataBindAttributeValue unless it's wrapped in an additional // anonymous function, even though Opera's built-in debugger can evaluate it anyway. No other browser requires this // extra indirection. - var applyBindingsToNextSiblingScript = "ko.templateRewriting.applyMemoizedBindingsToNextSibling(function() { \ - return (function() { return { " + rewrittenDataBindAttributeValue + " } })() \ - })"; + var applyBindingsToNextSiblingScript = + "ko.__tr_ambtns(function(){return(function(){return{" + rewrittenDataBindAttributeValue + "} })()})"; return templateEngine['createJavaScriptEvaluatorBlock'](applyBindingsToNextSiblingScript) + tagToRetain; } @@ -2760,8 +2830,9 @@ ko.templateRewriting = (function () { } })(); -ko.exportSymbol('templateRewriting', ko.templateRewriting); -ko.exportSymbol('templateRewriting.applyMemoizedBindingsToNextSibling', ko.templateRewriting.applyMemoizedBindingsToNextSibling); // Exported only because it has to be referenced by string lookup from within rewritten template + +// Exported only because it has to be referenced by string lookup from within rewritten template +ko.exportSymbol('__tr_ambtns', ko.templateRewriting.applyMemoizedBindingsToNextSibling); (function() { // A template source represents a read/write way of accessing a template. This is to eliminate the need for template loading/saving // logic to be duplicated in every template engine (and means they can all work with anonymous templates, etc.) @@ -2955,7 +3026,7 @@ ko.exportSymbol('templateRewriting.applyMemoizedBindingsToNextSibling', ko.templ : new ko.bindingContext(ko.utils.unwrapObservable(dataOrBindingContext)); // Support selecting template as a function of the data being rendered - var templateName = typeof(template) == 'function' ? template(bindingContext['$data']) : template; + var templateName = typeof(template) == 'function' ? template(bindingContext['$data'], bindingContext) : template; var renderedNodesArray = executeTemplate(targetNodeOrNodeArray, renderMode, templateName, bindingContext, options); if (renderMode == "replaceNode") { @@ -2982,9 +3053,9 @@ ko.exportSymbol('templateRewriting.applyMemoizedBindingsToNextSibling', ko.templ // This will be called by setDomNodeChildrenFromArrayMapping to get the nodes to add to targetNode var executeTemplateForArrayItem = function (arrayValue, index) { // Support selecting template as a function of the data being rendered - var templateName = typeof(template) == 'function' ? template(arrayValue) : template; - arrayItemContext = parentBindingContext['createChildContext'](ko.utils.unwrapObservable(arrayValue)); + arrayItemContext = parentBindingContext['createChildContext'](ko.utils.unwrapObservable(arrayValue), options['as']); arrayItemContext['$index'] = index; + var templateName = typeof(template) == 'function' ? template(arrayValue, arrayItemContext) : template; return executeTemplate(null, "ignoreTargetNode", templateName, arrayItemContext, options); } @@ -3057,7 +3128,7 @@ ko.exportSymbol('templateRewriting.applyMemoizedBindingsToNextSibling', ko.templ if (shouldDisplay) { // Render once for this single data point (or use the viewModel if no data was provided) var innerBindingContext = (typeof bindingValue == 'object') && ('data' in bindingValue) - ? bindingContext['createChildContext'](ko.utils.unwrapObservable(bindingValue['data'])) // Given an explitit 'data' value, we create a child binding context for it + ? bindingContext['createChildContext'](ko.utils.unwrapObservable(bindingValue['data']), bindingValue['as']) // Given an explitit 'data' value, we create a child binding context for it : bindingContext; // Given no explicit 'data' value, we retain the same binding context templateSubscription = ko.renderTemplate(templateName || element, innerBindingContext, /* options: */ bindingValue, element); } else @@ -3070,13 +3141,13 @@ ko.exportSymbol('templateRewriting.applyMemoizedBindingsToNextSibling', ko.templ }; // Anonymous templates can't be rewritten. Give a nice error message if you try to do it. - ko.jsonExpressionRewriting.bindingRewriteValidators['template'] = function(bindingValue) { - var parsedBindingValue = ko.jsonExpressionRewriting.parseObjectLiteral(bindingValue); + ko.expressionRewriting.bindingRewriteValidators['template'] = function(bindingValue) { + var parsedBindingValue = ko.expressionRewriting.parseObjectLiteral(bindingValue); if ((parsedBindingValue.length == 1) && parsedBindingValue[0]['unknown']) return null; // It looks like a string literal, not an object literal, so treat it as a named template (which is allowed for rewriting) - if (ko.jsonExpressionRewriting.keyValueArrayContainsKey(parsedBindingValue, "name")) + if (ko.expressionRewriting.keyValueArrayContainsKey(parsedBindingValue, "name")) return null; // Named templates can be rewritten, so return "no error" return "This template engine does not support anonymous templates nested within its templates"; }; @@ -3087,85 +3158,95 @@ ko.exportSymbol('templateRewriting.applyMemoizedBindingsToNextSibling', ko.templ ko.exportSymbol('setTemplateEngine', ko.setTemplateEngine); ko.exportSymbol('renderTemplate', ko.renderTemplate); -(function () { +ko.utils.compareArrays = (function () { + var statusNotInOld = 'added', statusNotInNew = 'deleted'; + // Simple calculation based on Levenshtein distance. - function calculateEditDistanceMatrix(oldArray, newArray, maxAllowedDistance) { - var distances = []; - for (var i = 0; i <= newArray.length; i++) - distances[i] = []; - - // Top row - transform old array into empty array via deletions - for (var i = 0, j = Math.min(oldArray.length, maxAllowedDistance); i <= j; i++) - distances[0][i] = i; - - // Left row - transform empty array into new array via additions - for (var i = 1, j = Math.min(newArray.length, maxAllowedDistance); i <= j; i++) { - distances[i][0] = i; - } - - // Fill out the body of the array - var oldIndex, oldIndexMax = oldArray.length, newIndex, newIndexMax = newArray.length; - var distanceViaAddition, distanceViaDeletion; - for (oldIndex = 1; oldIndex <= oldIndexMax; oldIndex++) { - var newIndexMinForRow = Math.max(1, oldIndex - maxAllowedDistance); - var newIndexMaxForRow = Math.min(newIndexMax, oldIndex + maxAllowedDistance); - for (newIndex = newIndexMinForRow; newIndex <= newIndexMaxForRow; newIndex++) { - if (oldArray[oldIndex - 1] === newArray[newIndex - 1]) - distances[newIndex][oldIndex] = distances[newIndex - 1][oldIndex - 1]; + function compareArrays(oldArray, newArray, dontLimitMoves) { + oldArray = oldArray || []; + newArray = newArray || []; + + if (oldArray.length <= newArray.length) + return compareSmallArrayToBigArray(oldArray, newArray, statusNotInOld, statusNotInNew, dontLimitMoves); + else + return compareSmallArrayToBigArray(newArray, oldArray, statusNotInNew, statusNotInOld, dontLimitMoves); + } + + function compareSmallArrayToBigArray(smlArray, bigArray, statusNotInSml, statusNotInBig, dontLimitMoves) { + var myMin = Math.min, + myMax = Math.max, + editDistanceMatrix = [], + smlIndex, smlIndexMax = smlArray.length, + bigIndex, bigIndexMax = bigArray.length, + compareRange = (bigIndexMax - smlIndexMax) || 1, + maxDistance = smlIndexMax + bigIndexMax + 1, + thisRow, lastRow, + bigIndexMaxForRow, bigIndexMinForRow; + + for (smlIndex = 0; smlIndex <= smlIndexMax; smlIndex++) { + lastRow = thisRow; + editDistanceMatrix.push(thisRow = []); + bigIndexMaxForRow = myMin(bigIndexMax, smlIndex + compareRange); + bigIndexMinForRow = myMax(0, smlIndex - 1); + for (bigIndex = bigIndexMinForRow; bigIndex <= bigIndexMaxForRow; bigIndex++) { + if (!bigIndex) + thisRow[bigIndex] = smlIndex + 1; + else if (!smlIndex) // Top row - transform empty array into new array via additions + thisRow[bigIndex] = bigIndex + 1; + else if (smlArray[smlIndex - 1] === bigArray[bigIndex - 1]) + thisRow[bigIndex] = lastRow[bigIndex - 1]; // copy value (no edit) else { - var northDistance = distances[newIndex - 1][oldIndex] === undefined ? Number.MAX_VALUE : distances[newIndex - 1][oldIndex] + 1; - var westDistance = distances[newIndex][oldIndex - 1] === undefined ? Number.MAX_VALUE : distances[newIndex][oldIndex - 1] + 1; - distances[newIndex][oldIndex] = Math.min(northDistance, westDistance); + var northDistance = lastRow[bigIndex] || maxDistance; // not in big (deletion) + var westDistance = thisRow[bigIndex - 1] || maxDistance; // not in small (addition) + thisRow[bigIndex] = myMin(northDistance, westDistance) + 1; } } } - return distances; - } - - function findEditScriptFromEditDistanceMatrix(editDistanceMatrix, oldArray, newArray) { - var oldIndex = oldArray.length; - var newIndex = newArray.length; - var editScript = []; - var maxDistance = editDistanceMatrix[newIndex][oldIndex]; - if (maxDistance === undefined) - return null; // maxAllowedDistance must be too small - while ((oldIndex > 0) || (newIndex > 0)) { - var me = editDistanceMatrix[newIndex][oldIndex]; - var distanceViaAdd = (newIndex > 0) ? editDistanceMatrix[newIndex - 1][oldIndex] : maxDistance + 1; - var distanceViaDelete = (oldIndex > 0) ? editDistanceMatrix[newIndex][oldIndex - 1] : maxDistance + 1; - var distanceViaRetain = (newIndex > 0) && (oldIndex > 0) ? editDistanceMatrix[newIndex - 1][oldIndex - 1] : maxDistance + 1; - if ((distanceViaAdd === undefined) || (distanceViaAdd < me - 1)) distanceViaAdd = maxDistance + 1; - if ((distanceViaDelete === undefined) || (distanceViaDelete < me - 1)) distanceViaDelete = maxDistance + 1; - if (distanceViaRetain < me - 1) distanceViaRetain = maxDistance + 1; - - if ((distanceViaAdd <= distanceViaDelete) && (distanceViaAdd < distanceViaRetain)) { - editScript.push({ status: "added", value: newArray[newIndex - 1] }); - newIndex--; - } else if ((distanceViaDelete < distanceViaAdd) && (distanceViaDelete < distanceViaRetain)) { - editScript.push({ status: "deleted", value: oldArray[oldIndex - 1] }); - oldIndex--; + var editScript = [], meMinusOne, notInSml = [], notInBig = []; + for (smlIndex = smlIndexMax, bigIndex = bigIndexMax; smlIndex || bigIndex;) { + meMinusOne = editDistanceMatrix[smlIndex][bigIndex] - 1; + if (bigIndex && meMinusOne === editDistanceMatrix[smlIndex][bigIndex-1]) { + notInSml.push(editScript[editScript.length] = { // added + 'status': statusNotInSml, + 'value': bigArray[--bigIndex], + 'index': bigIndex }); + } else if (smlIndex && meMinusOne === editDistanceMatrix[smlIndex - 1][bigIndex]) { + notInBig.push(editScript[editScript.length] = { // deleted + 'status': statusNotInBig, + 'value': smlArray[--smlIndex], + 'index': smlIndex }); } else { - editScript.push({ status: "retained", value: oldArray[oldIndex - 1] }); - newIndex--; - oldIndex--; + editScript.push({ + 'status': "retained", + 'value': bigArray[--bigIndex] }); + --smlIndex; + } + } + + if (notInSml.length && notInBig.length) { + // Set a limit on the number of consecutive non-matching comparisons; having it a multiple of + // smlIndexMax keeps the time complexity of this algorithm linear. + var limitFailedCompares = smlIndexMax * 10, failedCompares, + a, d, notInSmlItem, notInBigItem; + // Go through the items that have been added and deleted and try to find matches between them. + for (failedCompares = a = 0; (dontLimitMoves || failedCompares < limitFailedCompares) && (notInSmlItem = notInSml[a]); a++) { + for (d = 0; notInBigItem = notInBig[d]; d++) { + if (notInSmlItem['value'] === notInBigItem['value']) { + notInSmlItem['moved'] = notInBigItem['index']; + notInBigItem['moved'] = notInSmlItem['index']; + notInBig.splice(d,1); // This item is marked as moved; so remove it from notInBig list + failedCompares = d = 0; // Reset failed compares count because we're checking for consecutive failures + break; + } + } + failedCompares += d; } } return editScript.reverse(); } - ko.utils.compareArrays = function (oldArray, newArray, maxEditsToConsider) { - if (maxEditsToConsider === undefined) { - return ko.utils.compareArrays(oldArray, newArray, 1) // First consider likely case where there is at most one edit (very fast) - || ko.utils.compareArrays(oldArray, newArray, 10) // If that fails, account for a fair number of changes while still being fast - || ko.utils.compareArrays(oldArray, newArray, Number.MAX_VALUE); // Ultimately give the right answer, even though it may take a long time - } else { - oldArray = oldArray || []; - newArray = newArray || []; - var editDistanceMatrix = calculateEditDistanceMatrix(oldArray, newArray, maxEditsToConsider); - return findEditScriptFromEditDistanceMatrix(editDistanceMatrix, oldArray, newArray); - } - }; + return compareArrays; })(); ko.exportSymbol('utils.compareArrays', ko.utils.compareArrays); @@ -3181,13 +3262,26 @@ ko.exportSymbol('utils.compareArrays', ko.utils.compareArrays); // "callbackAfterAddingNodes" will be invoked after any "mapping"-generated nodes are inserted into the container node // You can use this, for example, to activate bindings on those nodes. - function fixUpVirtualElements(contiguousNodeArray) { - // Ensures that contiguousNodeArray really *is* an array of contiguous siblings, even if some of the interior - // ones have changed since your array was first built (e.g., because your array contains virtual elements, and - // their virtual children changed when binding was applied to them). - // This is needed so that we can reliably remove or update the nodes corresponding to a given array item - - if (contiguousNodeArray.length > 2) { + function fixUpNodesToBeMovedOrRemoved(contiguousNodeArray) { + // Before moving, deleting, or replacing a set of nodes that were previously outputted by the "map" function, we have to reconcile + // them against what is in the DOM right now. It may be that some of the nodes have already been removed from the document, + // or that new nodes might have been inserted in the middle, for example by a binding. Also, there may previously have been + // leading comment nodes (created by rewritten string-based templates) that have since been removed during binding. + // So, this function translates the old "map" output array into its best guess of what set of current DOM nodes should be removed. + // + // Rules: + // [A] Any leading nodes that aren't in the document any more should be ignored + // These most likely correspond to memoization nodes that were already removed during binding + // See https://github.com/SteveSanderson/knockout/pull/440 + // [B] We want to output a contiguous series of nodes that are still in the document. So, ignore any nodes that + // have already been removed, and include any nodes that have been inserted among the previous collection + + // Rule [A] + while (contiguousNodeArray.length && !ko.utils.domNodeIsAttachedToDocument(contiguousNodeArray[0])) + contiguousNodeArray.splice(0, 1); + + // Rule [B] + if (contiguousNodeArray.length > 1) { // Build up the actual new contiguous node set var current = contiguousNodeArray[0], last = contiguousNodeArray[contiguousNodeArray.length - 1], newContiguousSet = [current]; while (current !== last) { @@ -3201,6 +3295,7 @@ ko.exportSymbol('utils.compareArrays', ko.utils.compareArrays); // (The following line replaces the contents of contiguousNodeArray with newContiguousSet) Array.prototype.splice.apply(contiguousNodeArray, [0, contiguousNodeArray.length].concat(newContiguousSet)); } + return contiguousNodeArray; } function mapNodeAndRefreshWhenChanged(containerNode, mapping, valueToMap, callbackAfterAddingNodes, index) { @@ -3211,10 +3306,9 @@ ko.exportSymbol('utils.compareArrays', ko.utils.compareArrays); // On subsequent evaluations, just replace the previously-inserted DOM nodes if (mappedNodes.length > 0) { - fixUpVirtualElements(mappedNodes); - ko.utils.replaceDomNodes(mappedNodes, newMappedNodes); + ko.utils.replaceDomNodes(fixUpNodesToBeMovedOrRemoved(mappedNodes), newMappedNodes); if (callbackAfterAddingNodes) - callbackAfterAddingNodes(valueToMap, newMappedNodes); + callbackAfterAddingNodes(valueToMap, newMappedNodes, index); } // Replace the contents of the mappedNodes array, thereby updating the record @@ -3239,96 +3333,106 @@ ko.exportSymbol('utils.compareArrays', ko.utils.compareArrays); // Build the new mapping result var newMappingResult = []; var lastMappingResultIndex = 0; - var nodesToDelete = []; var newMappingResultIndex = 0; - var nodesAdded = []; - var insertAfterNode = null; - for (var i = 0, j = editScript.length; i < j; i++) { - switch (editScript[i].status) { - case "retained": - // Just keep the information - don't touch the nodes - var dataToRetain = lastMappingResult[lastMappingResultIndex]; - dataToRetain.indexObservable(newMappingResultIndex); - newMappingResultIndex = newMappingResult.push(dataToRetain); - if (dataToRetain.domNodes.length > 0) - insertAfterNode = dataToRetain.domNodes[dataToRetain.domNodes.length - 1]; - lastMappingResultIndex++; - break; - case "deleted": - // Stop tracking changes to the mapping for these nodes - lastMappingResult[lastMappingResultIndex].dependentObservable.dispose(); - - // Queue these nodes for later removal - fixUpVirtualElements(lastMappingResult[lastMappingResultIndex].domNodes); - ko.utils.arrayForEach(lastMappingResult[lastMappingResultIndex].domNodes, function (node) { - nodesToDelete.push({ - element: node, - index: i, - value: editScript[i].value + var nodesToDelete = []; + var itemsToProcess = []; + var itemsForBeforeRemoveCallbacks = []; + var itemsForMoveCallbacks = []; + var itemsForAfterAddCallbacks = []; + var mapData; + + function itemMovedOrRetained(editScriptIndex, oldPosition) { + mapData = lastMappingResult[oldPosition]; + if (newMappingResultIndex !== oldPosition) + itemsForMoveCallbacks[editScriptIndex] = mapData; + // Since updating the index might change the nodes, do so before calling fixUpNodesToBeMovedOrRemoved + mapData.indexObservable(newMappingResultIndex++); + fixUpNodesToBeMovedOrRemoved(mapData.mappedNodes); + newMappingResult.push(mapData); + itemsToProcess.push(mapData); + } + + function callCallback(callback, items) { + if (callback) { + for (var i = 0, n = items.length; i < n; i++) { + if (items[i]) { + ko.utils.arrayForEach(items[i].mappedNodes, function(node) { + callback(node, i, items[i].arrayEntry); }); - insertAfterNode = node; - }); + } + } + } + } + + for (var i = 0, editScriptItem, movedIndex; editScriptItem = editScript[i]; i++) { + movedIndex = editScriptItem['moved']; + switch (editScriptItem['status']) { + case "deleted": + if (movedIndex === undefined) { + mapData = lastMappingResult[lastMappingResultIndex]; + + // Stop tracking changes to the mapping for these nodes + mapData.dependentObservable.dispose(); + + // Queue these nodes for later removal + nodesToDelete.push.apply(nodesToDelete, fixUpNodesToBeMovedOrRemoved(mapData.mappedNodes)); + if (options['beforeRemove']) { + itemsForBeforeRemoveCallbacks[i] = mapData; + itemsToProcess.push(mapData); + } + } lastMappingResultIndex++; break; + case "retained": + itemMovedOrRetained(i, lastMappingResultIndex++); + break; + case "added": - var valueToMap = editScript[i].value; - var indexObservable = ko.observable(newMappingResultIndex); - var mapData = mapNodeAndRefreshWhenChanged(domNode, mapping, valueToMap, callbackAfterAddingNodes, indexObservable); - var mappedNodes = mapData.mappedNodes; - - // On the first evaluation, insert the nodes at the current insertion point - newMappingResultIndex = newMappingResult.push({ - arrayEntry: editScript[i].value, - domNodes: mappedNodes, - dependentObservable: mapData.dependentObservable, - indexObservable: indexObservable - }); - for (var nodeIndex = 0, nodeIndexMax = mappedNodes.length; nodeIndex < nodeIndexMax; nodeIndex++) { - var node = mappedNodes[nodeIndex]; - nodesAdded.push({ - element: node, - index: i, - value: editScript[i].value - }); - if (insertAfterNode == null) { - // Insert "node" (the newly-created node) as domNode's first child - ko.virtualElements.prepend(domNode, node); - } else { - // Insert "node" into "domNode" immediately after "insertAfterNode" - ko.virtualElements.insertAfter(domNode, node, insertAfterNode); - } - insertAfterNode = node; + if (movedIndex !== undefined) { + itemMovedOrRetained(i, movedIndex); + } else { + mapData = { arrayEntry: editScriptItem['value'], indexObservable: ko.observable(newMappingResultIndex++) }; + newMappingResult.push(mapData); + itemsToProcess.push(mapData); + if (!isFirstExecution) + itemsForAfterAddCallbacks[i] = mapData; } - if (callbackAfterAddingNodes) - callbackAfterAddingNodes(valueToMap, mappedNodes, indexObservable); break; } } - ko.utils.arrayForEach(nodesToDelete, function (node) { ko.cleanNode(node.element) }); + // Call beforeMove first before any changes have been made to the DOM + callCallback(options['beforeMove'], itemsForMoveCallbacks); - var invokedBeforeRemoveCallback = false; - if (!isFirstExecution) { - if (options['afterAdd']) { - for (var i = 0; i < nodesAdded.length; i++) - options['afterAdd'](nodesAdded[i].element, nodesAdded[i].index, nodesAdded[i].value); - } - if (options['beforeRemove']) { - for (var i = 0; i < nodesToDelete.length; i++) - options['beforeRemove'](nodesToDelete[i].element, nodesToDelete[i].index, nodesToDelete[i].value); - invokedBeforeRemoveCallback = true; + // Next remove nodes for deleted items; or call beforeRemove, which will remove them + ko.utils.arrayForEach(nodesToDelete, options['beforeRemove'] ? ko.cleanNode : ko.removeNode); + callCallback(options['beforeRemove'], itemsForBeforeRemoveCallbacks); + + // Next add/reorder the remaining items (will include deleted items if there's a beforeRemove callback) + for (var i = 0, nextNode = ko.virtualElements.firstChild(domNode), lastNode, node; mapData = itemsToProcess[i]; i++) { + // Get nodes for newly added items + if (!mapData.mappedNodes) + ko.utils.extend(mapData, mapNodeAndRefreshWhenChanged(domNode, mapping, mapData.arrayEntry, callbackAfterAddingNodes, mapData.indexObservable)); + + // Put nodes in the right place if they aren't there already + for (var j = 0; node = mapData.mappedNodes[j]; nextNode = node.nextSibling, lastNode = node, j++) { + if (node !== nextNode) + ko.virtualElements.insertAfter(domNode, node, lastNode); } - } - if (!invokedBeforeRemoveCallback && nodesToDelete.length) { - for (var i = 0; i < nodesToDelete.length; i++) { - var element = nodesToDelete[i].element; - if (element.parentNode) - element.parentNode.removeChild(element); + + // Run the callbacks for newly added nodes (for example, to apply bindings, etc.) + if (!mapData.initialized && callbackAfterAddingNodes) { + callbackAfterAddingNodes(mapData.arrayEntry, mapData.mappedNodes, mapData.indexObservable); + mapData.initialized = true; } } + // Finally call afterMove and afterAdd callbacks + callCallback(options['afterMove'], itemsForMoveCallbacks); + callCallback(options['afterAdd'], itemsForAfterAddCallbacks); + // Store a copy of the array items we just considered so we can difference it next time ko.utils.domData.set(domNode, lastMappingResultDomDataKey, newMappingResult); } @@ -3440,4 +3544,5 @@ ko.exportSymbol('nativeTemplateEngine', ko.nativeTemplateEngine); ko.exportSymbol('jqueryTmplTemplateEngine', ko.jqueryTmplTemplateEngine); })(); }); -})(window,document,navigator); +})(window,document,navigator,window["jQuery"]); +})(); diff --git a/package.json b/package.json index 5843e52..cf11b58 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,11 @@ { "name": "knockoutify", - "description": "Knockout.js lib for browsers", - "version": "2.1.0", + "description": "Knockout makes it easier to create rich, responsive UIs with JavaScript", + "version": "2.2.0-alpha.1", "repository": { "type": "git", - "url": "git://github.com/guillaume86/knockoutify.git" + "url": "git://github.com/domenic/knockoutify.git" }, "author": "Steven Sanderson", - "engines": { - "node": "*" - }, "main": "./index.js" -} \ No newline at end of file +}