diff --git a/.gitignore b/.gitignore index d17157f..c6fd2f2 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ x64/ build/ [Bb]in/ [Oo]bj/ +node_modules/ # Enable "build/" folder in the NuGet Packages folder since NuGet packages use it for MSBuild targets !packages/*/build/ diff --git a/LICENSE b/LICENSE index 611071e..daaedde 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2013 Josh Williams +Copyright (c) 2015 Microsoft Corp. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/js/knockout-winjs.js b/js/knockout-winjs.js index 81b27a9..2422f3a 100644 --- a/js/knockout-winjs.js +++ b/js/knockout-winjs.js @@ -1,20 +1,67 @@ -/*! - * knockout-winjs - * - * Copyright 2013 Josh Williams and other contributors - * Released under the MIT license - */ +// Copyright (c) Microsoft Corp. All Rights Reserved. Licensed under the MIT License. See License.txt in the project root for license information. + (function () { "use strict"; - function addBindings(controls) { + // A property processor should return this if the intention is that the return value is not to be set on the control property + var doNotSetOptionOnControl = {}; + + // Check if property starts with 'on' to see if it's an event handler + function isPropertyEventHandler(propertyName) { + return propertyName[0] === "o" && propertyName[1] === "n"; + } + + // Check if two WinJS layouts are considered the same + function isSameLayout(layout1, layout2) { + if (layout1 && layout2 && layout1.type && layout2.type) { + return objectShallowEquals(layout1, layout2); + } else { + return layout1 === layout2; + } + }; + + function arrayShallowEquals(array1, array2) { + if (array1 === array2) { + return true; + } + if (!array1 || !array2 || array1.length !== array2.length) { + return false; + } + for (var i in array1) { + if (array2[i] !== array1[i]) { + return false; + } + } + return true; + } + + // Perform a shallow comparison of two objects + function objectShallowEquals(object1, object2) { + if (object1 === object2) { + return true; + } + for (var prop in object1) { + if (object1[prop] !== object2[prop]) { + return false; + } + } + for (var prop in object2) { + if (object1[prop] !== object2[prop]) { + return false; + } + } + return true; + } + + function addBindings(controls, eventConfig) { Object.keys(controls).forEach(function (name) { var controlConfiguration = controls[name]; - var ctor = WinJS.Utilities.getMember(name); - var eventName = controlConfiguration.event - var propertyProcessor = controlConfiguration.propertyProcessor; - var bindDescendants = controlConfiguration.bindDescendants || false; - var bindingName = "win" + name.substr(name.lastIndexOf(".") + 1); + var eventsChangingProperties = eventConfig[name]; + var ctor = WinJS.Utilities.getMember("WinJS.UI." + name); + var propertyProcessor = controlConfiguration.propertyProcessor || {}; + var delayedPropertyProcessor = controlConfiguration.postCtorPropertyProcessor || {}; + var bindDescendantsBeforeParent = controlConfiguration.bindDescendantsBeforeParent || false; + var bindingName = "win" + name; ko.bindingHandlers[bindingName] = { init: function (element, valueAccessor, allBindings, viewModel, bindingContext) { @@ -25,53 +72,62 @@ // Options record for the WinJS Control var options = {}; + // If the WinJS control depends on having child elements + if (element.children.length > 0 && bindDescendantsBeforeParent) { + ko.applyBindingsToDescendants(bindingContext, element); + } + // Iterate over the observable properties to get their value for (var property in value) { - if (value.hasOwnProperty(property)) { - if (propertyProcessor && propertyProcessor[property] !== undefined) { - options[property] = propertyProcessor[property](value[property], function () { return element }); + // Exclude events from options to set up during ctor, we explicitly set event handlers in the update function + // because setting an event property will not unhook the event listener that was set as options during initialization. + if (value.hasOwnProperty(property) && !isPropertyEventHandler(property) && (!delayedPropertyProcessor[property])) { + if (propertyProcessor[property]) { + var propertyResult = propertyProcessor[property](value[property], function () { return element }); + + // doNotSetOptionOnControl from propertyProcessor means don't set the option on the control + if (propertyResult !== doNotSetOptionOnControl) { + options[property] = propertyResult; + } } else { options[property] = ko.unwrap(value[property]); } } } - // If the WinJS control depends on having child elements - if (element.children.length > 0 && bindDescendants) { - // This is done synchornously - // @TODO: Determine if this could be done async - ko.applyBindingsToDescendants(bindingContext, element); - } - - // Create a new instanace of the control with the element and options + // Create a new instance of the control with the element and options var control = new ctor(element, options); - // Add event handler that will kick off changes to the observable values - // For most controls this is the "change" event - if (eventName) { - ko.utils.registerEventHandler(element, eventName, function changed(e) { - // Iterate over the observable properties - for (var property in value) { - // Check to see if they exsist - if (value.hasOwnProperty(property)) { - // Determine if that value is a writableObservable property - if (ko.isWriteableObservable(value[property])) { - // Kickoff updates - value[property](control[property]); + // Add event handler that will kick off changes to the changing properties that are observed + if (eventConfig[name]) { + var events = eventConfig[name]; + for (var event in events) { + ko.utils.registerEventHandler(element, event, function changed(e) { + + // Iterate over the properties that change as a result of the events + for (var propertyIndex in eventConfig[name][event]) { + var property = eventConfig[name][event][propertyIndex]; + // Check to see if they exist + if (value && value.hasOwnProperty(property)) { + // Determine if that value is a writableObservable property and check the property has changed + if (ko.isWriteableObservable(value[property]) && value[property]() !== control[property]) { + // Kickoff updates + value[property](control[property]); + } } } - } - }); + }); + } } - // Add disposal callback to dispose the WinJS controle when it's not needed anymore + // Add disposal callback to dispose the WinJS control when it's not needed anymore ko.utils.domNodeDisposal.addDisposeCallback(element, function (e) { if (element.winControl) { element.winControl.dispose(); } }); - return { controlsDescendantBindings: bindDescendants }; + return { controlsDescendantBindings: bindDescendantsBeforeParent }; }, update: function (element, valueAccessor, allBindings, viewModel, bindingContext) { @@ -79,17 +135,21 @@ var control = element.winControl; var value = valueAccessor(); - // Only update the control properties that are different with the unpacked value + // Only update the control properties that are different with the unpacked value, we also process delayed property processors only here for (var property in value) { if (value.hasOwnProperty(property)) { var unwrappedValue = ko.unwrap(value[property]); if (control[property] !== unwrappedValue) { - if (propertyProcessor && propertyProcessor[property] !== undefined) { + if (propertyProcessor && propertyProcessor[property]) { var returnValue = propertyProcessor[property](value[property], function () { return element }, control[property]); - if (returnValue !== null) { + if (returnValue !== doNotSetOptionOnControl) { + control[property] = returnValue; + } + } else if (delayedPropertyProcessor && delayedPropertyProcessor[property]) { + var returnValue = delayedPropertyProcessor[property](value[property], function () { return element }, control[property]); + if (returnValue !== doNotSetOptionOnControl) { control[property] = returnValue; } - } else { control[property] = unwrappedValue; } @@ -101,85 +161,118 @@ }); } - // Helper for adding and remove click hanlders between two elements - function addRemoveClickHandlers(anchor, oldAnchor, sourceElement) { - var retVal = null; - var value = ko.unwrap(anchor); - var element = document.querySelector(value); - - var sourceElement = sourceElement(); - - if (!oldAnchor || value !== sourceElement.dataset['cachedAnchor']) { - var showMenu = function (e) { - sourceElement.winControl.show(); - }; - element._handler = showMenu; - element.addEventListener("click", element._handler); - ko.utils.domNodeDisposal.addDisposeCallback(element, function (e) { - element.removeEventListener("click", element._handler); - }); - - if (oldAnchor) { - oldAnchor.removeEventListener("click", oldAnchor._handler); - } - - sourceElement.dataset['cachedAnchor'] = value; - retVal = element; - } - - return retVal; - } - // Helper for diffing between an obserable array and binding list function bindingListWatch(value, oldValue, sourceElement) { var unpacked = ko.unwrap(value); - // Will create a bindingList once - // @TODO: Figure out how to detect if they are replacing the bindingList with a brand new one - var retVal = null; - if (!oldValue) { - var bindingList = new WinJS.Binding.List(unpacked); - value.subscribe(function (newValue) { - for (var i = 0, len = newValue.length; i < len; i++) { - var item = newValue[i]; - switch (item.status) { - case "deleted": - bindingList.splice(item.index, 1); - break; - case "added": - if (item.index === len) { + // Will create a bindingList once per new observable array + var retVal = doNotSetOptionOnControl; + if (Array.isArray(unpacked) && ko.isWriteableObservable(value)) { + if (!value._winKoChangesSubscription) { + if (value._rateLimitedChange) { + throw new Error("Knockout-WinJS does not support rate limited observable arrays currently"); + } + var bindingList = new WinJS.Binding.List(unpacked); + + value._winKoChangesSubscriptionDisabled = false; + value._winKoBindingList = bindingList; + + // Subscribe to the array diff callback for observable array changes + value._winKoChangesSubscription = value.subscribe(function (newValue) { + if (!value._winKoChangesSubscriptionDisabled) { + + // disable binding list callbacks to prevent an infinte loop + bindingList._winKoChangesSubscriptionDisabled = true; + var offset = 0; + + var deletes = newValue.filter(function (item) { + return item.status === "deleted"; + }); + var adds = newValue.filter(function (item) { + return item.status === "added"; + }); + for (var deletedItem in deletes) { + var item = deletes[deletedItem]; + bindingList.splice(item.index - offset, 1); + offset++; + } + + var arrayLength = bindingList.length; + for (var i = 0; i < adds.length; i++) { + var item = adds[i]; + if (item.index === arrayLength) { bindingList.push(item.value); } else if (item.index === 0) { bindingList.unshift(item.value); } else { bindingList.push(item.value) - bindingList.move(value().length - 1, item.index); + bindingList.move(arrayLength, item.index); } - break; + arrayLength++; + } + bindingList._winKoChangesSubscriptionDisabled = false; } - } - }, this, "arrayChange"); + }, this, "arrayChange"); + + // Binding list may also change on its accord (i.e. ListView reorder) for which we should update the observable array + var bindingListMutationEvents = ["itemchanged", "itemmoved", "itemmutated", "itemremoved", "reload"]; + + var updateOriginalArray = function () { + if (!bindingList._winKoChangesSubscriptionDisabled) { + + // Disable observable array callbacks to prevent an infinite loop + value._winKoChangesSubscriptionDisabled = true; + value.removeAll(); + for (var i = 0, len = bindingList.length; i < len; i++) { + value.push(bindingList.getAt(i)); + } + value._winKoChangesSubscriptionDisabled = false; + } + }; + + bindingListMutationEvents.forEach(function (event) { + bindingList.addEventListener(event, updateOriginalArray); + }); + + // Dispose the old subscription or when the element gets disposed + ko.utils.domNodeDisposal.addDisposeCallback(sourceElement(), function () { + value._winKoChangesSubscription.dispose(); + }); - retVal = bindingList.dataSource; + } + // Only return the binding list data source if it was not set priorly + if (!sourceElement()._winKoDataSourceBound) { + sourceElement()._winKoDataSourceBound = true; + retVal = value._winKoBindingList.dataSource; + } + } else { + retVal = unpacked; } return retVal; } // Helper for itemTemplate changes - function itemTemplateWatch(value, oldValue, sourceElement) { - var retVal = null; - var value = ko.unwrap(value); + function itemTemplateWatch(value, oldValue, sourceElement, property) { + var retVal = doNotSetOptionOnControl; + value = ko.unwrap(value); var template = value; - var sourceElement = sourceElement(); - // @TODO: add support for wiring up item disposal to binding cleanup - var renderer = WinJS.UI.simpleItemRenderer(function (item) { - var element = document.createElement("div"); - ko.renderTemplate(template, item.data, {}, element); - return element; - }); + var renderer; - if (!oldValue || template !== sourceElement.dataset['lastTemplate']) { - sourceElement.dataset['lastTemplate'] = template; + sourceElement = sourceElement(); + // If the renderer is a string that means we are trying to render a KO template + if (typeof value === "string") { + renderer = WinJS.UI.simpleItemRenderer(function (item) { + var element = document.createElement("div"); + ko.renderTemplate(template, item.data, {}, element); + return element; + }); + } else { + // Otherwise the template is a WinJS function template + renderer = value; + } + var templateProp = "win" + property + "Old"; + if (!oldValue || template !== sourceElement[templateProp]) { + sourceElement[templateProp] = template; retVal = renderer; } @@ -187,103 +280,237 @@ } var controls = { - "WinJS.UI.AppBar": { - bindDescendants: true - }, - "WinJS.UI.AppBarCommand": {}, - "WinJS.UI.BackButton": {}, - "WinJS.UI.DatePicker": { - event: "change" + // We need to bind child nodes of AppBar (buttons) before AppBar itself in knockout + AppBar: { + bindDescendantsBeforeParent: true }, - "WinJS.UI.FlipView": { + AppBarCommand: { propertyProcessor: { - 'itemTemplate': function (value, flipViewElement, update) { - return itemTemplateWatch(value, update, flipViewElement); - }, - 'itemDataSource': function (value, flipViewElement, update) { - return bindingListWatch(value, update, flipViewElement); + 'type': function (value, appBarCommandElement, update) { + if (!appBarCommandElement._winTypeInitialized) { + appBarCommandElement._winTypeInitialized = true; + return value; + } else { + console.warn("Cannot change AppBarCommand type after initializing the control"); + } } - }, - bindDescendants: true + } }, - "WinJS.UI.Flyout": { + AutoSuggestBox: {}, + BackButton: {}, + Command: { propertyProcessor: { - 'anchor': function (value, flyoutElement, oldAnchor) { - return addRemoveClickHandlers(value, oldAnchor, flyoutElement); + 'type': function (value, commandElement, update) { + if (!commandElement._winTypeInitialized) { + commandElement._winTypeInitialized = true; + return value; + } else { + console.warn("Cannot change Command type after initializing the control"); + } } } }, - "WinJS.UI.Hub": { - bindDescendants: true + ContentDialog: {}, + DatePicker: {}, + FlipView: { + propertyProcessor: { + 'itemTemplate': function (value, flipViewElement, current) { + return itemTemplateWatch(value, current, flipViewElement, 'ItemTemplate'); + }, + 'itemDataSource': function (value, flipViewElement, current) { + return bindingListWatch(value, current, flipViewElement); + } + }, + bindDescendantsBeforeParent: true }, - "WinJS.UI.HubSection": {}, - "WinJS.UI.ItemContainer": { - event: "selectionchanged" + Flyout: {}, + Hub: { + bindDescendantsBeforeParent: true, }, - "WinJS.UI.ListView": { - event: "selectionchanged", + HubSection: {}, + ItemContainer: {}, + ListView: { propertyProcessor: { - 'itemTemplate': function (value, listViewElement, update) { - return itemTemplateWatch(value, update, listViewElement); + 'groupHeaderTemplate': function (value, listViewElement, current) { + return itemTemplateWatch(value, current, listViewElement, 'GroupHeaderTemplate'); }, - 'itemDataSource': function (value, listViewElement, update) { - return bindingListWatch(value, update, listViewElement); + 'groupDataSource': function (value, listViewElement, current) { + return bindingListWatch(value, current, listViewElement); }, - 'layout': function (value, listViewElement, update) { - var retVal = null; + 'itemTemplate': function (value, listViewElement, current) { + return itemTemplateWatch(value, current, listViewElement, 'ItemTemplate'); + }, + 'itemDataSource': function (value, listViewElement, current) { + return bindingListWatch(value, current, listViewElement); + }, + 'layout': function (value, listViewElement, current) { + var retVal = doNotSetOptionOnControl; var unpacked = ko.unwrap(value); var listViewElement = listViewElement(); - var layout = (unpacked.type) ? new unpacked.type(unpacked) : unpacked; - if (!update || '' + layout !== listViewElement.dataset['cachedLayout']) { - retVal = layout; - listViewElement.dataset['cachedLayout'] = '' + layout; + // Only set layout if it's an actually different one + if (!current || !isSameLayout(unpacked, listViewElement._winCachedLayout)) { + retVal = (unpacked && unpacked.type) ? new unpacked.type(unpacked) : unpacked; + listViewElement._winCachedLayout = unpacked; } return retVal; - } }, - bindDescendants: true - }, - "WinJS.UI.Menu": { - propertyProcessor: { - 'anchor': function (value, menuElement, oldAnchor) { - return addRemoveClickHandlers(value, oldAnchor, menuElement); + postCtorPropertyProcessor: { + // Selection needs to set selection object on the list view and needs the control to be initialized + 'selection': function (value, listViewElement, current) { + var unpacked = ko.unwrap(value); + listViewElement = listViewElement(); + + // If value is a ko.observableArray, subscribe to selection changes on it + if (Array.isArray(unpacked) && ko.isWriteableObservable(value)) { + if (!listViewElement._winKoSelectionChangedHandlerSet) { + listViewElement.winControl.addEventListener("selectionchanged", function () { + var currSelectionArray = listViewElement.winControl.selection.getIndices(); + var oldSelection = ko.unwrap(value); + if (!arrayShallowEquals(oldSelection, currSelectionArray)) { + value(listViewElement.winControl.selection.getIndices()); + } + }); + listViewElement._winKoSelectionChangedHandlerSet = true; + } + } + listViewElement.winControl.selection.set(unpacked); + + return doNotSetOptionOnControl; } }, - bindDescendants: true + bindDescendantsBeforeParent: true }, - "WinJS.UI.MenuCommand": {}, - "WinJS.UI.NavBar": {}, - "WinJS.UI.NavBarCommand": {}, - "WinJS.UI.NavBarContainer": {}, - "WinJS.UI.Rating": { - event: "change" + Menu: { + bindDescendantsBeforeParent: true }, - "WinJS.UI.SearchBox": { - event: "querychanged" + MenuCommand: {}, + NavBar: {}, + NavBarCommand: {}, + NavBarContainer: { + bindDescendantsBeforeParent: true }, - // @TODO: Semantic Zoom - "WinJS.UI.TimePicker": { - event: "change" + Pivot: { + bindDescendantsBeforeParent: true, + propertyProcessor: { + 'selectedIndex': function (value, pivotElement, current) { + // Temporary workaround for selectionchanged not updating this property until WinJS issue #1317 is fixed + if (!pivotElement._winKoSelectedIndexHandlerSet) { + pivotElement().addEventListener("selectionchanged", function (e) { + if (ko.isWriteableObservable(value)) { + value(e.detail.index); + } + }); + pivotElement()._winKoSelectedIndexHandlerSet = true; + } + return ko.unwrap(value); + }, + 'selectedItem': function (value, pivotElement, current) { + // Temporary workaround for selectionchanged not updating this property until WinJS issue #1317 is fixed + if (!pivotElement._winKoSelectedItemHandlerSet) { + pivotElement().addEventListener("selectionchanged", function (e) { + if (ko.isWriteableObservable(value)) { + value(pivotElement().winControl.items.getAt(e.detail.index)); + } + }); + pivotElement()._winKoSelectedItemHandlerSet = true; + } + return ko.unwrap(value); + }, + } }, - "WinJS.UI.ToggleSwitch": { - event: "change" + PivotItem: {}, + Rating: {}, + SemanticZoom: { + bindDescendantsBeforeParent: true }, - // @TODO: Determine a better way to update Tooltip - "WinJS.UI.Tooltip": { + SplitView: {}, + SplitViewPaneToggle: {}, + TimePicker: {}, + ToggleSwitch: {}, + ToolBar: { + bindDescendantsBeforeParent: true + }, + Tooltip: { propertyProcessor: { - 'contentElement': function (value, toolTipElement, update) { + 'contentElement': function (value, toolTipElement, current) { var value = ko.unwrap(value); var element = document.querySelector(value); return element; } } + }, + ViewBox: {} + }; + // An object to store which event of a control changes what property + var eventConfig = { + AppBar: { + "afterclose": ["opened"], + "afteropen": ["opened"] + }, + AutoSuggestBox: { + "querychanged": ["queryText"] + }, + ContentDialog: { + "afterhide": ["hidden"], + "aftershow": ["hidden"] + }, + DatePicker: { + "change": ["current"] + }, + FlipView: { + "pageselected": ["currentPage"] + }, + Flyout: { + "afterhide": ["hidden"], + "aftershow": ["hidden"] + }, + Hub: { + "loadingstatechanged": ["loadingState"] + }, + ItemContainer: { + "selectionchanged": ["selected"] + }, + ListView: { + "loadingstatechanged": ["loadingState"] + }, + Menu: { + "afterhide": ["hidden"], + "aftershow": ["hidden"] + }, + NavBar: { + "afterclose": ["opened"], + "afteropen": ["opened"] + }, + Pivot: null, + // "selectionchanged": ["selectedIndex", "selectedItem"] Need a custom hook for selectionchanged + // until WinJS #1317 Pivot selectedItem and selectedIndex are not updated on selectionchanged handler is fixed + // }, + Rating: { + "change": ["userRating"] + }, + SemanticZoom: { + "zoomchanged": ["zoomedOut"] + }, + SplitView: { + "afterclose": ["paneOpened"], + "afteropen": ["paneOpened"] + }, + TimePicker: { + "change": ["current"] + }, + ToggleSwitch: { + "change": ["checked"] + }, + ToolBar: { + "afterclose": ["opened"], + "afteropen": ["opened"] } }; - addBindings(controls); + addBindings(controls, eventConfig); })(); \ No newline at end of file