Manager.js

"use strict";
import ScreenTransition from './ScreenTransition';
import NavigationItem from './NavigationItem';
import Action from './Action';
import {LitElement, html, css} from 'lit';

const TEMP_OVERLAY = 'temp-overlay';
const TEMP_SET = 'temp-set';

/**
 * @callback screenFactoryFunction
 * @param {string} id 
 * @param {object} state Memento object representing the state of the screen.  Provided by the call to  {@link Manager#push}, {@link Manager#set}, {@link Manager#replace}, or {@link Screen#getState}
 * @param {HTMLElement} container Element to populate with contests of screen represented by id in state
 * @return {Screen}
 */

 /** 
 * Called to ask a screen for what its current state is.  Typically right before it is removed from the DOM.
 * @callback getStateFunction
 * @return {object}
 */

 /**
  * @callback disconnectFunction
  * @param {HTMLElement} container Same container that was passed to the screenFactoryFunction
  */

 /**
 * @typedef Screen
 * @property {getStateFunction} getState function to return a momento object representing the current state of the screen
 * @property {disconnectFunction} [disconnect] function to call when the screen elements are no longer needed in the DOM
 */

 
/**
 * @typedef ScrollValues
 * @property {number} x
 * @property {number} y
 * @package
 */


/**
 * @typedef ScreenChange
 * @property {NavigationItem} from The screen that was previously shown
 * @property {NavigationItem} to The screen that will become visible
 * @property {Manager} controller
 * @property {Action} action type of call that triggered this transition
 */

/**
 * @typedef ItemState
 * @property {object} state momento object from caller
 * @property {string} id
 * @property {ScreenTransition} transition
 * @property {ScrollValues} viewportScroll
*/

/**
 * @typedef NavigatorState
 * @property {ScreenTransition} transition
 * @property {Array<ItemState>} stack
 */

/**
 * Web component to manage display of pages or screens in a 
 * single page application for Cordova.
 * @fires Manager#before-change
 * @fires Manager#after-change
 * @public
 */
export class Manager extends LitElement  {
  /**
   * **Do not use constructor directly**.  This is a custom HTMLElement and should
   * be created using `document.createElement`.
   * @example
   * let s = document.createElement('backstack-manager');
   * s.screenFactory = someFunction;
   * @hideconstructor
   */
  constructor() {
    super();

    /**
     * Callback responsible for populating screens given an id and state
     * @type {screenFactoryFunction}  
     */
    this.screenFactory = jsonScreenFactory;
    this._stack = [];
    this._targetTransition = '';
    this._targetId = '';
    this._baseTransition = '';
    this._baseId = '';
    this._isAnimating = false;

    this._busy = false;

    /** 
     * Default transition that is used if no transition is provided by the {@link Options}
     * @type {ScreenTransition} 
     */
    this.transition = ScreenTransition.None;
  }

  static get properties() {
    return { 
      _targetTransition: {state:true},
      _targetId: {state:true},
      _baseTransition: {state:true},
      _baseId: {state:true},
      _isAnimating: {state:true},
      transition: {type:String, attribute:"transition"}
    };
  }

  /** 
   * Top most screen in the history stack.
   * @type {NavigationItem}
   * @readonly
   */
  get current() {
    const length = this._stack.length;
    return length > 0 ? this._stack[length - 1] : null;
  }

  /**
   * Previous screen in the navigation stack, or null
   * @type {NavigationItem} 
   * @readonly
   * @public
   */  
  get previous() {
    const length = this._stack.length;
    return length > 1 ? this._stack[length - 2] : null;
  }

  /**
   * Set the viewport id and put the item on the stack
   * @param {NavigationItem} item
   * @private
   */
  pushNextItem(item) {
    let id = this.current ? this.current.viewportId : 0;
    if( ! item.isOverlay)
      id++;
    item.viewportId = id;
    
    this._stack.push(item);
  }

  /**
   * Show a new screen, maintaining previous screens in the history stack.
   * @param {string} id identifier passed to {@link screenFactoryFunction}
   * @param {object} state passed to {@link screenFactoryFunction}
   * @param {Options} [options]
   * @return {Promise<ScreenChange>}
   * @public
   */
  push(id, state, options) {
    const next = new NavigationItem(this, id, state, options);

    if( !this.checkBeforeChange(this.current, next, Action.Push))
      return Promise.reject('Event handler aborted screen change');

    const from = this.current;
    this.pushNextItem(next);
    return this.animateIn(next, from)
      .then(item => {
        item.action = Action.Push;
        this.notifyAfterChange(item);
        return item;
      })
  }

  /**
   * Replace the current screen, erasing the history stack.
   * @param {string} id identifier passed to {@link screenFactoryFunction}
   * @param {object} state passed to {@link screenFactoryFunction}
   * @param {Options} [options]
   * @return {Promise<ScreenChange>}
   * @public
   */
  set(id, state, options) {
    const newScreen = new NavigationItem(this, id, state, options);

    if( !this.checkBeforeChange(this.current, newScreen, Action.Set))
      return Promise.reject('Event handler aborted screen change');

    const previous = this.current;
    const oldStack = this._stack;
    this._stack = [];
    this.pushNextItem(newScreen);
    newScreen.tempViewportId = TEMP_SET;

    return this.animateIn(newScreen, previous)
    .then((item)=>{
      for(const item of oldStack)
        item.dehydrate();
      
      item.action = Action.Set;
      this.notifyAfterChange(item);
      return item;
    })
  }

  /**
   * Replace the current screen with a new one. Leaves the rest of the history
   * stack unchanged.
   * @param {string} id identifier passed to {@link screenFactoryFunction}
   * @param {object} state passed to {@link screenFactoryFunction}
   * @param {Options} [options]
   * @return {Promise<ScreenChange>}
   * @public
   */
  replace(id, state, options) {
    const next = new NavigationItem(this, id, state, options);

    if( !this.checkBeforeChange(this.current, next, Action.Replace))
      return Promise.reject('Event handler aborted screen change');
    
    if(this._stack.length < 1)
      return this.set(id, state, options);
    
    const previous = this._stack.pop();

    next.tempViewportId = TEMP_SET;
    this.pushNextItem(next);
    return this.animateIn(next, previous)
    .then(item => {
      item.action = Action.Replace;
      this.notifyAfterChange(item);
      return item;
    })
  }

  /**
   * Remove the current screen from the stack and show the one below it.
   * @return {Promise<ScreenChange>}
   * @public
   */
  back() {
    if( !this.checkBeforeChange(this.current, this.previous, Action.Back))
      return Promise.reject('Event handler aborted screen change');

    if(this._stack.length < 2) 
      return Promise.reject('Event handler aborted screen change');

    const from = this._stack.pop();
    const to = this.current;
    return this.animateOut(from, to)
      .then((item) => {
        item.action = Action.Back;
        this.notifyAfterChange(item);
        return item;
      });
  }

  /**
   * @memberof Manager
   * @return {NavigatorState}
   * @public
   */
  getState() {
    return {
      transition: this.transition,
      stack:this._stack.map((item)=>{
        const itemState =  {
          state: item.getState(),
          id: item.id,
          transition: item.transition,
          viewportScroll: item.viewportScroll
        };
        if(item.isOverlay)
          itemState.isOverlay = true;
        
        return itemState;
      })
    } 
  }


  /**
   * Replace the current screen and history stack with the provided state
   * @param {NavigatorState}
   * @return {Promise<Manager>}
   * @public
   */
  setState(state) {
    if( ! state)
      throw new Error('Cannot set empty state');

    if( ! Array.isArray(state.stack))
      throw new Error('state.stack should be an array');

    for(let i = 0; i < this._stack.length; i++) {
      const item = this._stack[i];
      item.dehydrate();
    }
    var viewport = 0;
    this._stack = state.stack.map((item,i) => {
      const options = {
        transition:item.transition, 
        viewportScroll: 
        item.viewportScroll,
        isOverlay:item.isOverlay
      }
      const ni = new NavigationItem(this, item.id, item.state, options);
      if( ! ni.isOverlay)
        viewport++;
      ni.viewportId = viewport;
      return ni;
    });

    this._baseId = this._stack.length;
    this._baseTransition = '';
    this._targetId = '';
    this._targetTransition = ''
    this.transition = state.transition || ScreenTransition.None;

    this.fireLegacyEvent(null, this.current);
    //this.notifyAfterChange(null, this.current, Action.State)

    return this.updateComplete.then(()=>{
      if(this.current) {
        this.hydrateViewport(this.current.viewportId);
      }
      return this;
    });
  }

  /**
   * Raise the `before-change` event prior to committing to a screen change.
   * @param {NavigationItem} to
   * @param {Action} action
   * @private
   */
  checkBeforeChange(from, to, action) {
    /**
     * Occurs before the transition to a new screen.  This is a cancelable event.
     * 
     * @event Manager#before-change
     * @type {CustomEvent}
     * @property {NavigationItem} details.to screen that is entering
     * @property {NavigationItem} details.from screen that is leaving
     * @property {Action} details.action action that caused this transition
     */
    const e = new CustomEvent('before-change', {detail: {from, to, action}, cancelable:true});
    return this.dispatchEvent(e);
  }

  /**
   * Raise the 'after-change' event when the screen change has completed.
   * @param {ScreenChange} change
   * @private
   */
  notifyAfterChange(change) {
    /**
     * Occurs after the transition to a new screen has completed.
     * @event Manager#after-change
     * @type {CustomEvent}
     * @property {NavigationItem} details.to screen that is entering
     * @property {NavigationItem} details.from screen that is leaving
     * @property {Action} details.action action that caused this transition
     */
    const e = new CustomEvent('after-change', {detail: change});
    this.dispatchEvent(e);
  }

  /**
   * Raise the 'screen' event when the screen changes.  This was the original
   * event that was broken into 'before-change' and 'after-change' for 
   * more control. Will be deprecated on next major release.
   * @param {NavigationItem} from
   * @param {NavigationItem} to
   * @private
   */
  fireLegacyEvent(from, to, action) {
    const e = new CustomEvent('screen', {detail: {from, to}});
    this.dispatchEvent(e);
  }

   /**
   * @param {NavigationItem} entering
   * @param {NavigationItem} previous
   * @return {Promise<ScreenChange>}
   * @private
   */
  animateIn(entering, previous) {
    if( ! entering)
      return Promise.reject('Cannot animate in nothing');

    if(previous)
      previous.preserveState();

    this.fireLegacyEvent(previous, entering);

    if( ! entering.transition) {
      this._baseId = entering.viewportId;
      return this.updateComplete
      .then(()=>{
        entering.tempViewportId = null;
        entering.hydrate();
        if(previous && ! entering.isOverlay)
          previous.dehydrate();

        return {from:previous, to:entering, controller:this};
      })
    }

    const transitions = parseTransitions(entering.transition);

    if(entering.isOverlay) {
      entering.tempViewportId = TEMP_OVERLAY;
      transitions.base = '';
    }

    this._baseId = previous ? previous.viewportId : 'none';
    this._baseTransition = '';
    this._targetTransition = transitions.target;
    this._targetId = entering.slot;

    return this.updateComplete
    .then(()=>{
      entering.hydrate();
      return awaitAnimationFrame()})
    .then(awaitAnimationFrame)
    .then(()=>new Promise((resolve,reject)=>{
        this._isAnimating = true;
        this._targetTransition = '';
        this._baseTransition = transitions.base;
        this.afterTransition = resolve
      }))
    .then(()=>{
      this._isAnimating = false;
      this._baseTransition = '';
      this._targetId = ''
      this._baseId = entering.viewportId;
      entering.tempViewportId = null;
      return this.updateComplete;
    })
    .then(()=>{
      if(previous && ! entering.isOverlay) {
        // always dehydrate the previous because it will be missed by this.dehydrateViewport if it isn't on the stack.
        previous.dehydrate(); 
        // if the previous viewport is now hidden, dehydrate all of the screens in it
        if(previous.viewportId !== entering.viewportId)
          this.dehydrateViewport(previous.viewportId);
      }
      return {from:previous, to:entering, controller:this}
    });
  }

  /**
   * @private
   */
  dehydrateViewport(id) {
    for(let item of this._stack) {
      if(id == item.viewportId)
        item.dehydrate();
    }
  }

  /**
   * @private
   */
  hydrateViewport(id) {
    for(let item of this._stack) {
      if(id == item.viewportId)
        item.hydrate();
    }
  }

   /**
   * @param {NavigationItem} leaving
   * @param {NavigationItem} next
   * @return {Promise<ScreenChange>}
   * @private
   */
  animateOut(leaving, next) {
    if( ! leaving)
      return Promise.reject('Cannot animate out nothing');

    leaving.preserveState();

    this.fireLegacyEvent(leaving, next);

    if( ! leaving.transition) {
      this._baseId = next.viewportId;
      return this.updateComplete
      .then(()=>{
        this.hydrateViewport(next.viewportId);
        leaving.dehydrate();
        return {from:leaving, to:next, controller:this};
      })
    }

    const transitions = parseTransitions(leaving.transition);
    if(leaving.isOverlay) {
      leaving.tempViewportId = TEMP_OVERLAY;
      this._baseTransition = '';
    }

    this._targetId = leaving.slot;
    this._targetTransition = '';
    this._baseId = next ? next.viewportId : 'none';
    this._baseTransition = transitions.base;

    return this.updateComplete
    .then(()=>{
      this.hydrateViewport(next.viewportId);
      return awaitAnimationFrame()})
    .then(awaitAnimationFrame)
    .then(()=>new Promise((resolve,reject)=>{
        this._isAnimating = true;
        this._targetTransition = transitions.target
        this._baseTransition = '';
        this.afterTransition = resolve
    }))
    .then(()=>{
      this._isAnimating = false;
      this._targetTransition = '';
      this._baseId = next.viewportId;
      this._targetId = '';
      leaving.tempViewportId = null;
      return this.updateComplete;
    })
    .then(()=>{
      leaving.dehydrate();
      return {from:leaving, to:next, controller:this}
    })
  }

  /**
   * @private
   */
  transitionEnd(e) {
    if(this.afterTransition) {
      this.afterTransition();
      this.afterTransition = null;
    }
  }

  /**
   * @private
   */
  render() {
    var screenClass = this._isAnimating ? 'screen animating' : 'screen';
    return html`
      <div id="base" class="${screenClass} ${this._baseTransition}">
        <slot name="${this._baseId}"></slot>
      </div>
      ${this._targetId ? 
          html`<div id="target" 
                    class="${screenClass} ${this._targetTransition} ${this._targetId === TEMP_OVERLAY ? 'overlay' : ''}"
                    @transitionend=${this.transitionEnd}
                    @transitioncancel=${this.transitionEnd}>
                <slot name="${this._targetId}"></slot>
              </div>`: 
          html``
      }`;
  } 

  static get styles() {return css`
    :host {
      width: 100vw;
      height: 100vh;
      display: block;
      position: relative;
      overflow: hidden;
    }
    .screen {
      width: 100%;
      height: 100%;
      position: absolute;
      top: 0;
      left: 0;
      transform: translate3d(0, 0, 0);
      animation-fill-mode: forwards;
      opacity: 1;
      overflow: auto;
      background: var(--screen-background, radial-gradient(circle, rgba(255,255,255,1) 15%, rgba(233,233,233,1) 85%));
    }
    .overlay {
      background:none;
    }
    .animating {
      transition: transform 0.5s, opacity 0.5s;
    }
    .slide-left {transform: translate3d(100%, 0, 0)}
    .slide-right {transform: translate3d(-100%, 0, 0)}
    .push-right {transform: translate3d(-100%, 0, 0)}
    .slide-up {transform:translate3d(0,100%,0)}
    .slide-down {transform:translate3d(0,-100%,0)}
    .zoom-in {transform:scale(0.01); opacity:0}
    .fade-in {opacity:0;}
    .fade-left {transform: translate3d(25%, 0, 0); opacity:0}
    .fade-right {transform: translate3d(-25%, 0, 0); opacity:0}
    .fade-up {transform: translate3d(0, 25%, 0); opacity:0}
    .fade-down {transform: translate3d(0, -25%, 0); opacity:0}
    `;}
}

function jsonScreenFactory(id, state, container) {
  container.innerHTML = `<h1>State for '${id}'</h1><pre>${JSON.stringify(state)}</pre>`;
  return {getState:function() {return state}};
}

function awaitAnimationFrame() {
  return new Promise((resolve,reject)=>{window.requestAnimationFrame(resolve)});
}

function parseTransitions(s) {
  if( ! s)
    return {target:'', base:''}
  const split = s.split('/');
  return {
    target:split[0],
    base: split.length > 1 ? split[1] : ''
  };
}

export default Manager;