// Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License.txt in the project root for license information. /// import _Global = require('../Core/_Global'); import Promise = require('../Promise'); import _Signal = require('../_Signal'); "use strict"; // This module provides a state machine which is designed to be used by controls which need to // open, close, and fire related events (e.g. beforeopen, afterclose). The state machine handles // many edge cases. For example, what happens if: // - open is called when we're already opened? // - close is called while we're in the middle of opening? // - dispose is called while we're in the middle of firing beforeopen? // The state machine takes care of all of these edge cases so that the control doesn't have to. // The control is responible for knowing how to play its open/close animations and update its DOM. // The state machine is responsible for ensuring that these things happen at the appropriate times. // This module is broken up into 3 major pieces: // - OpenCloseMachine: Controls should instantiate one of these. The machine keeps track of the // current state and has methods for forwarding calls to the current state. // - IOpenCloseControl: Controls must provide an object which implements this interface. The // interface gives the machine hooks for invoking the control's open and close animations. // - States: The various states (e.g. Closed, Opened, Opening) that the machine can be in. Each // implements IOpenCloseState. // Example usage: // class MyControl { // element: HTMLElement; // private _machine: OpenCloseMachine; // // constructor(element?: HTMLElement, options: any = {}) { // this.element = element || document.createElement("div"); // // // Create the machine. // this._machine = new OpenCloseMachine({ // eventElement: this.element, // onOpen: (): Promise => { // // Do the work to render the contol in its opened state with an animation. // // Return the animation promise. // }, // onClose: (): Promise => { // // Do the work to render the contol in its closed state with an animation. // // Return the animation promise. // }, // onUpdateDom() { // // Do the work to render the internal state of the control to the DOM. If a // // control restricts all its DOM modifications to onUpdateDom, the state machine // // can guarantee that the control won't modify its DOM while it is animating. // }, // onUpdateDomWithIsOpened: (isOpened: boolean ) => { // // Do the same work as onUpdateDom but ensure that the DOM is rendered with either // // the opened or closed visual as dictacted by isOpened. The control should have some // // internal state to track whether it is currently opened or closed. Treat this as a // // cue to mutate that internal state to reflect the value of isOpened. // }, // }); // // // Initialize the control. During this time, the machine will not ask the control to // // play any animations or update its DOM. // this.opened = true; // _Control.setOptions(this, options); // // // Tell the machine the control is initialized. After this, the machine will start asking // // the control to play animations and update its DOM as appropriate. // this._machine.exitInit(); // } // // get opened() { // return this._machine.opened; // } // set opened(value: boolean) { // this._machine.opened = value; // } // open() { // this._machine.open(); // } // close() { // this._machine.close(); // } // forceLayout() { // this._machine.updateDom(); // } // dispose() { // this._machine.dispose(); // } // } var EventNames = { beforeOpen: "beforeopen", afterOpen: "afteropen", beforeClose: "beforeclose", afterClose: "afterclose", // Private events // Indicates that the OpenCloseMachine has settled either into the Opened state // or Closed state. This is more comprehensive than the "afteropen" and "afterclose" // events because it fires even if the machine has reached the state due to: // - Exiting the Init state // - The beforeopen/beforeclose events being canceled _openCloseStateSettled: "_openCloseStateSettled" }; // // IOpenCloseControl // export interface IOpenCloseControl { // The element on which the events should be dispatched. The events are: // - beforeopen (cancelable) // - afteropen // - beforeclose (cancelable) // - afterclose eventElement: HTMLElement; // Called when the control should render its opened state with an animation. // onOpen is called if the beforeopen event is not preventDefaulted. afteropen is fired // upon completion of onOpen's promise. onOpen(): Promise; // Called when the control should render its closed state with an animation. // onClose is called if the beforeclose event is not preventDefaulted. afterclose is fired // upon completion of onClose's promise. onClose(): Promise; // Called when the control should render its current internal state to the DOM. If a // control restricts all its DOM modifications to onUpdateDom, the state machine can // guarantee that the control won't modify its DOM while it is animating. onUpdateDom(): void; // Same as onUpdateDom but enables the machine to force the control to update and render // its closed or opened visual as dictated by isOpened. onUpdateDomWithIsOpened(isOpened: boolean): void; } // // OpenCloseMachine // export class OpenCloseMachine { _control: IOpenCloseControl; _initializedSignal: _Signal; private _disposed: boolean; private _state: IOpenCloseState; // // Methods called by the control // // When the machine is created, it sits in the Init state. When in the Init state, calls to // updateDom will be postponed until the machine exits the Init state. Consequently, while in // this state, the control can feel free to call updateDom as many times as it wants without // worrying about it being expensive due to updating the DOM many times. The control should call // *exitInit* to move the machine out of the Init state. constructor(args: IOpenCloseControl) { this._control = args; this._initializedSignal = new _Signal(); this._disposed = false; this._setState(States.Init); } // Moves the machine out of the Init state and into the Opened or Closed state depending on whether // open or close was called more recently. exitInit() { this._initializedSignal.complete(); } // These method calls are forwarded to the current state. updateDom() { this._state.updateDom(); } open() { this._state.open(); } close() { this._state.close(); } get opened() { return this._state.opened; } set opened(value: boolean) { if (value) { this.open(); } else { this.close(); } } // Puts the machine into the Disposed state. dispose() { this._setState(States.Disposed); this._disposed = true; this._control = null; } // // Methods called by states // _setState(NewState: any, arg0?: any) { if (!this._disposed) { this._state && this._state.exit(); this._state = new NewState(); this._state.machine = this; this._state.enter(arg0); } } // Triggers arbitrary app code _fireEvent(eventName: string, options?: { detail?: any; cancelable?: boolean; }): boolean { options = options || {}; var detail = options.detail || null; var cancelable = !!options.cancelable; var eventObject = _Global.document.createEvent("CustomEvent"); eventObject.initCustomEvent(eventName, true, cancelable, detail); return this._control.eventElement.dispatchEvent(eventObject); } // Triggers arbitrary app code _fireBeforeOpen(): boolean { return this._fireEvent(EventNames.beforeOpen, { cancelable: true }); } // Triggers arbitrary app code _fireBeforeClose(): boolean { return this._fireEvent(EventNames.beforeClose, { cancelable: true }); } } // // States (each implements IOpenCloseState) // // WinJS animation promises always complete successfully. This // helper allows an animation promise to complete in the canceled state // so that the success handler can be skipped when the animation is // interrupted. function cancelablePromise(animationPromise: Promise) { return Promise._cancelBlocker(animationPromise, function () { animationPromise.cancel(); }); } // Noop function, used in the various states to indicate that they don't support a given // message. Named with the somewhat cute name '_' because it reads really well in the states. function _() { } // Implementing the control as a state machine helps us correctly handle: // - re-entrancy while firing events // - calls into the control during asynchronous operations (e.g. animations) // // Many of the states do their "enter" work within a promise chain. The idea is that if // the state is interrupted and exits, the rest of its work can be skipped by canceling // the promise chain. // An interesting detail is that anytime the state may trigger app code (e.g. due to // firing an event), the current promise must end and a new promise must be chained off of it. // This is necessary because the app code may interact with the control and cause it to // change states. If we didn't create a new promise, then the very next line of code that runs // after triggering app code may not be valid because the state may have exited. Starting a // new promise after each triggering of app code prevents us from having to worry about this // problem. In this configuration, when a promise's success handler runs, it guarantees that // the state hasn't exited. // For similar reasons, each of the promise chains created in "enter" starts off with a _Signal // which is completed at the end of the "enter" function (this boilerplate is abstracted away by // the "interruptible" function). The reason is that we don't want any of the code in "enter" // to run until the promise chain has been stored in a variable. If we didn't do this (e.g. instead, // started the promise chain with Promise.wrap()), then the "enter" code could trigger the "exit" // function (via app code) before the promise chain had been stored in a variable. Under these // circumstances, the promise chain would be uncancelable and so the "enter" work would be // unskippable. This wouldn't be good when we needed the state to exit early. // These two functions manage interruptible work promises (one creates them the other cancels // them). They communicate with each other thru the _interruptibleWorkPromises property which // "interruptible" creates on your object. function interruptible(object: T, workFn: (promise: Promise) => Promise) { object["_interruptibleWorkPromises"] = object["_interruptibleWorkPromises"] || []; var workStoredSignal = new _Signal(); object["_interruptibleWorkPromises"].push(workFn(workStoredSignal.promise)); workStoredSignal.complete(); } function cancelInterruptibles() { (this["_interruptibleWorkPromises"] || []).forEach((workPromise: _Signal) => { workPromise.cancel(); }); } interface IOpenCloseState { // Debugging name: string; // State lifecyle enter(args: any): void; exit(): void; // Immediately exit the state & cancel async work. Most important during dispose. // In general, the current state is responsible for switching to the next state. The // one exception is dispose where the machine will force the current state to exit. // Machine's API surface opened: boolean; // read only. Writes go thru open/close. open(): void; close(): void; updateDom(): void; // If a state decides to postpone updating the DOM, it should // update the DOM immediately before switching to the next state. // Provided by _setState for use within the state machine: OpenCloseMachine; } // Transitions: // When created, the state machine will take one of the following initialization // transitions depending on how the machines's APIs have been used by the time // exitInit() is called on it: // Init -> Closed // Init -> Opened // Following that, the life of the machine will be dominated by the following // sequences of transitions. In geneneral, these sequences are uninterruptible. // Closed -> BeforeOpen -> Closed (when preventDefault is called on beforeopen event) // Closed -> BeforeOpen -> Opening -> Opened // Opened -> BeforeClose -> Opened (when preventDefault is called on beforeclose event) // Opened -> BeforeClose -> Closing -> Closed // However, any state can be interrupted to go to the Disposed state: // * -> Disposed module States { function updateDomImpl(): void { this.machine._control.onUpdateDom(); } // Initial state. Gives the control the opportunity to initialize itself without // triggering any animations or DOM modifications. When done, the control should // call *exitInit* to move the machine to the next state. export class Init implements IOpenCloseState { private _opened: boolean; machine: OpenCloseMachine; name = "Init"; enter() { interruptible(this, (ready) => { return ready.then(() => { return this.machine._initializedSignal.promise; }).then(() => { this.machine._control.onUpdateDomWithIsOpened(this._opened); this.machine._setState(this._opened ? Opened : Closed); }); }); } exit = cancelInterruptibles; get opened(): boolean { return this._opened; } open() { this._opened = true; } close() { this._opened = false; } updateDom = _; // Postponed until immediately before we switch to another state } // A rest state. The control is closed and is waiting for the app to call open. class Closed implements IOpenCloseState { machine: OpenCloseMachine; name = "Closed"; enter(args?: { openIsPending?: boolean; }) { args = args || {}; if (args.openIsPending) { this.open(); } this.machine._fireEvent(EventNames._openCloseStateSettled); } exit = _; opened = false; open() { this.machine._setState(BeforeOpen); } close = _; updateDom = updateDomImpl; } // An event state. The control fires the beforeopen event. class BeforeOpen implements IOpenCloseState { machine: OpenCloseMachine; name = "BeforeOpen"; enter() { interruptible(this, (ready) => { return ready.then(() => { return this.machine._fireBeforeOpen(); // Give opportunity for chain to be canceled when triggering app code }).then((shouldOpen) => { if (shouldOpen) { this.machine._setState(Opening); } else { this.machine._setState(Closed); } }); }); } exit = cancelInterruptibles; opened = false; open = _; close = _; updateDom = updateDomImpl; } // An animation/event state. The control plays its open animation and fires afteropen. class Opening implements IOpenCloseState { private _closeIsPending: boolean; machine: OpenCloseMachine; name = "Opening"; enter() { interruptible(this, (ready) => { return ready.then(() => { this._closeIsPending = false; return cancelablePromise(this.machine._control.onOpen()); }).then(() => { this.machine._fireEvent(EventNames.afterOpen); // Give opportunity for chain to be canceled when triggering app code }).then(() => { this.machine._control.onUpdateDom(); this.machine._setState(Opened, { closeIsPending: this._closeIsPending }); }); }); } exit = cancelInterruptibles; get opened() { return !this._closeIsPending; } open() { this._closeIsPending = false; } close() { this._closeIsPending = true; } updateDom = _; // Postponed until immediately before we switch to another state } // A rest state. The control is opened and is waiting for the app to call close. class Opened implements IOpenCloseState { machine: OpenCloseMachine; name = "Opened"; enter(args?: { closeIsPending?: boolean }) { args = args || {}; if (args.closeIsPending) { this.close(); } this.machine._fireEvent(EventNames._openCloseStateSettled); } exit = _; opened = true; open = _; close() { this.machine._setState(BeforeClose); } updateDom = updateDomImpl; } // An event state. The control fires the beforeclose event. class BeforeClose implements IOpenCloseState { machine: OpenCloseMachine; name = "BeforeClose"; enter() { interruptible(this, (ready) => { return ready.then(() => { return this.machine._fireBeforeClose(); // Give opportunity for chain to be canceled when triggering app code }).then((shouldClose) => { if (shouldClose) { this.machine._setState(Closing); } else { this.machine._setState(Opened); } }); }); } exit = cancelInterruptibles; opened = true; open = _; close = _; updateDom = updateDomImpl; } // An animation/event state. The control plays the close animation and fires the afterclose event. class Closing implements IOpenCloseState { private _openIsPending: boolean; machine: OpenCloseMachine; name = "Closing"; enter() { interruptible(this, (ready) => { return ready.then(() => { this._openIsPending = false; return cancelablePromise(this.machine._control.onClose()); }).then(() => { this.machine._fireEvent(EventNames.afterClose); // Give opportunity for chain to be canceled when triggering app code }).then(() => { this.machine._control.onUpdateDom(); this.machine._setState(Closed, { openIsPending: this._openIsPending }); }); }); } exit = cancelInterruptibles; get opened() { return this._openIsPending; } open() { this._openIsPending = true; } close() { this._openIsPending = false; } updateDom = _; // Postponed until immediately before we switch to another state } export class Disposed implements IOpenCloseState { machine: OpenCloseMachine; name = "Disposed"; enter = _; exit = _; opened = false; open = _; close = _; updateDom = _; } }