/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- / /* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ 'use strict'; /** * Shrinking UI: would make app window tilted and ready to be tapped, dragged * and sent out and received in. It receives four events to do the UI change: * * 'shrinking-start': tilt the window and let user can drag it * 'shrinking-stop' : tilt the window back immediately and let user can use it * 'shrinking-receiving' : after the app launched, do the receiving animation * * It would send an event out to nofify that the user has dragged the window * to the top of the frame, which means the content should be sent: * (@see '_handleSendingOut') * * 'shrinking-sent': user has dragged the window to the top of the frame * * The caller should notice that after the first time the window got tilted, * it must be tilted back by the 'shrinking-stop' event, or the app window * and the whole Gaia may be malfunctioning. Also, when the screen got black * out, or any other state changes occurs, the whole shrinking process would be * interrupted, and the 'shrinking-stop' is necessary in such situation. * */ /* globals dump, Promise */ (function(exports) { var ShrinkingUI = function(foregroundElement, backgroundElement) { this.elements.foregroundElement = foregroundElement; this.elements.backgroundElement = backgroundElement; }; ShrinkingUI.prototype = { DEBUG: false, THRESHOLD: 50, SUSPEND_INTERVAL: 100, state: { shrinking: false, ending: false, tilting: false, overThreshold: false, toward: 'TOP', touch: { initY: -1, prevY: -1 }, slideTransitionCb: null, // Last slide transition tiltTransitionCb: null // Last tilt transition }, elements: {}, configs: { degreeLandscape: '2.7deg', degreePortrait: '0.65deg', overDegreeLandscape: '2.9deg', overDegreePortrait: '0.9deg' }, tip: null, cover: null }; ShrinkingUI.prototype.debug = function su_debug(msg, optObject) { if (this.DEBUG) { var output = '[DEBUG] ShrinkingUI: ' + msg; if (optObject) { output += JSON.stringify(optObject); } dump(output); } }; /** * Bind events and do some necessary initialization. * * @this {ShrinkingUI} */ ShrinkingUI.prototype.start = function su_start() { window.addEventListener('shrinking-receiving', this); this.startTilt(); }; /** * Unbind events and do some necessary initialization. * * @this {ShrinkingUI} */ ShrinkingUI.prototype.stop = function su_stop() { if (this.isActive()) { this.stopTilt(); } window.removeEventListener('shrinking-receiving', this); }; /** * The event dispatcher. * * @param {event} |evt| * @this {ShrinkingUI} */ ShrinkingUI.prototype.handleEvent = function su_handleEvent(evt) { switch (evt.type) { case 'shrinking-receiving': // It should be launched, then received. // So we'll get a new app. this._receivingEffects(); break; case 'touchstart': this._handleSendingStart(evt); break; case 'touchmove': this._handleSendingSlide(evt); break; case 'touchend': this._handleSendingOut(evt); break; } }; ShrinkingUI.prototype.respondToHierarchyEvent = function su_respondToHierarchyEvent(evt) { if (this['_handle_' + evt.type]) { return this['_handle_' + evt.type](evt); } return true; }; ShrinkingUI.prototype._handle_holdhome = function su__handle_holdhome(evt) { return this.isActive(); }; ShrinkingUI.prototype._handle_home = function su__handle_home(evt) { return this.isActive(); }; /** * Start tilting the app window. * * @this {ShrinkingUI} */ ShrinkingUI.prototype.startTilt = function su_startTilt() { // Already shrunk. if (this.isActive()) { return; } if (!this.elements.backgroundElement || !this.elements.foregroundElement) { return; } this.state.shrinking = true; this.state.tilting = true; var afterTilt = () => { // After it tilted done, turn it to the screenshot mode. this.elements.foregroundElement.classList.add('hidden'); this.state.tilting = false; }; this._setTip(); this._setState(true); // disable rotation to prevent display UI with wrong image screen.mozLockOrientation(screen.mozOrientation); this._shrinkingTilt(afterTilt); }; /** * Set the tip on the window. * * @this {ShrinkingTilt} */ ShrinkingUI.prototype._setTip = function su_setTip() { var backgroundElement = this.elements.backgroundElement; if (!this.tip) { this.tip = this._slidingTip(); backgroundElement.appendChild(this.tip); } this.tip.classList.remove('hide'); }; /** * Stop the shrinking effect and tilt it back. * * @this {ShrinkingUI} */ ShrinkingUI.prototype.stopTilt = function su_stopTilt() { if (!this.state.shrinking || this.state.ending) { return; } // When shrinking get forcibly stopped, restore the flag. if (this.state.tilting) { this.state.tilting = false; } this.state.shrinking = false; this.state.ending = true; var afterTiltBack = (() => { // Turn off the screenshot mode. this.elements.foregroundElement.classList.remove('hidden'); this._cleanEffects().then(() => { this.state.ending = false; }); }); this.tip.remove(); this.tip = null; this._shrinkingTiltBack(true, afterTiltBack); }; /** * Indicate shrinking UI is active. * * @return {boolean} The frame is shrinked or not. * @see _setState * @this {ShrinkingUI} */ ShrinkingUI.prototype.isActive = function su_isActive() { if (this.state.shrinking || this.state.tilting || this.state.ending) { return true; } return false; }; /** * Avoid to manipulate strings and DOM attributes everywhere. * Users should observe the 'data-shrinking-state' to see if now the * app window is tilted ('true') or not ('false'). * * @param {boolean} |state| Ccurrently the UI is on or off. * @this {ShrinkingUI} */ ShrinkingUI.prototype._setState = function su_shrinking(state) { // TODO: Call AppWindow.setState and AppWindow.getState instead of // setting/getting the attribute directly in shrinking UI. var foregroundElement = this.elements.foregroundElement; foregroundElement.setAttribute('data-shrinking-state', state.toString()); this.debug('Setting shrink state to: ' + state); }; /** * Update the current 'transitionend' callback for the slide animation. * @param {cb} transitioned callback * @this {ShrinkingUI} */ ShrinkingUI.prototype._updateSlideTransition = function su_updateSlideTransition(cb) { if (this.tip && this.state.slideTransitionCb) { this.elements.foregroundElement.style.transitionDuration = '0s'; this.tip.removeEventListener('transitionend', this.state.slideTransitionCb); } this.state.slideTransitionCb = cb; }; /** * Update the current 'transitionend' callback for the tilt animation. * @param {cb} transitioned callback * @this {ShrinkingUI} */ ShrinkingUI.prototype._updateTiltTransition = function su_updateTiltTransition(cb) { var foregroundElement = this.elements.foregroundElement; if (foregroundElement && this.state.tiltTransitionCb) { foregroundElement.removeEventListener('transitionend', this.state.tiltTransitionCb); } this.state.tiltTransitionCb = cb; }; /** * Receiving: make the app itself fly in from the top. * * @this {ShrinkingUI} */ ShrinkingUI.prototype._receivingEffects = function su_receivingEffects() { // Hide it before we move it. // Visibility won't work. var foregroundElement = this.elements.foregroundElement; foregroundElement.style.opacity = '0'; var afterTop = (function() { // Restore the display to let it fly in. foregroundElement.style.opacity = ''; foregroundElement.style.transition = 'transform 0.5s ease'; // 2. Slide to the BOTTOM. // 3. Tilt back and display it as normal apps. this._sendingSlideTo('BOTTOM', (function doTiltBack() { this._shrinkingTiltBack(false, this._cleanEffects); }).bind(this) ); }).bind(this); var afterTilt = (function() { // Make it fly to top immediately (can't set to zero or the // callback won't work). foregroundElement.style.transition = 'transform 0.05s ease'; this._sendingSlideTo('TOP', afterTop); }).bind(this); // 1. Make it on top and tilted. this._shrinkingTilt(afterTilt); }; /** * Slide the frame to the specific position. * * @param {number} |y| The Y coordinate the frame should slide to. * @param {function()} |callback| (Optional) execute after the frame moved * to the position. * @this {ShrinkingUI} */ ShrinkingUI.prototype._sendingSlideTo = function su_sendingSlideTo(y, callback) { var foregroundElement = this.elements.foregroundElement; var cbDone = (function on_cbDone(evt) { foregroundElement.removeEventListener('transitionend', cbDone); // Clear animation, so the shrinkingUI will follow the touchmove event. this.elements.foregroundElement.style.transitionDuration = '0s'; if ('undefined' !== typeof callback) { callback(); } }).bind(this); if ('TOP' === y) { // We should have animation to finish the rest of transformY. foregroundElement.style.transitionDuration = '0.3s'; y = foregroundElement.parentElement.clientHeight; } else if ('BOTTOM' === y) { // If no rest transformY is needed, we should trigger the callback // directly. if (this.state.touch.prevY <= 0) { cbDone(); } else { foregroundElement.style.transitionDuration = '0.3s'; } y = 0; } else if (y < 0) { y = 0; } foregroundElement.addEventListener('transitionend', cbDone); this._updateTiltTransition(cbDone); foregroundElement.style.transform = 'rotateX(' + this._getTiltingDegree() + ') ' + 'translateY(-' + y + 'px)'; }; /** * Create a new sliding cover and bind all necessary event handlers. * * @return {DOMElement} * @this {ShrinkingUI} */ ShrinkingUI.prototype._slidingCover = function slidingCover() { var cover = document.createElement('div'); cover.id = 'shrinking-cover'; cover.style.width = '100%'; cover.style.height = '100%'; cover.style.position = 'relative'; cover.style.zIndex = '2'; cover.addEventListener('touchstart', this); cover.addEventListener('touchmove', this); cover.addEventListener('touchend', this); return cover; }; /** * Create a tip. * * @return {DOMElement} * @this {ShrinkingUI} */ ShrinkingUI.prototype._slidingTip = function slidingTip() { var tip = document.createElement('div'); var tipArrow = document.createElement('div'); var tipText = document.createElement('div'); tip.id = 'shrinking-tip'; tipArrow.id = 'shrinking-tip-arrow'; tipText.id = 'shrinking-text'; tipArrow.textContent = '\u00A0'; tipText.setAttribute('data-l10n-id', 'shrinking-tip'); tip.appendChild(tipText); tip.appendChild(tipArrow); return tip; }; /** * Will return the disabled element to let caller manupulate it. * * @return {DOMElement} * @this {ShrinkingUI} */ ShrinkingUI.prototype._disableSlidingCover = function su_disableSlidingCover() { this.cover.removeEventListener('touchstart', this); this.cover.removeEventListener('touchmove', this); this.cover.removeEventListener('touchend', this); }; /** * Make the app frame tilted and overlay it with a cover. * * @param {function()} |cb| (Optional) if there're are any movement * should be performed after tilting back. * @this {ShrinkingUI} */ ShrinkingUI.prototype._shrinkingTilt = function su_shrinkingTilt(cb) { var foregroundElement = this.elements.foregroundElement; var backgroundElement = this.elements.backgroundElement; // If start then do some effect would tilt the window again, // would generate two cover which should be unique. if (null === this.cover) { // TODO: Call AppWindow.inject(DOM) instead of changing the DOM // directly in Shrinking UI. this.cover = this._slidingCover(); var anchor = foregroundElement.firstElementChild; foregroundElement.insertBefore(this.cover, anchor); } backgroundElement.classList.add('shrinking-wrapper'); foregroundElement.style.transition = 'transform 0.5s ease'; // Add a little bouncing back animation. // Nested callbacks: first animation is for sinking down, // and the second one is for bouncing back. var bounceBack = (function on_bounceBack(evt) { if (evt.target !== foregroundElement) { return; } foregroundElement.removeEventListener('transitionend', bounceBack); foregroundElement.addEventListener('transitionend', bounceBackEnd); this._updateTiltTransition(bounceBackEnd); foregroundElement.style.transition = 'transform ease'; foregroundElement.style.transform = 'rotateX(' + this._getTiltingDegree() + ') '; }).bind(this); var bounceBackEnd = (function on_bounceBackEnd(evt) { if (evt.target !== foregroundElement) { return; } foregroundElement.removeEventListener('transitionend', bounceBackEnd); foregroundElement.style.transition = 'transform 0.5s ease 0s'; if (cb) { cb(); } }).bind(this); foregroundElement.addEventListener('transitionend', bounceBack); this._updateTiltTransition(bounceBack); // After set up, trigger the transition. foregroundElement.style.transformOrigin = '50% 100% 0'; foregroundElement.style.transform = 'rotateX(' + this._getOverTiltingDegree() + ')'; }; /** * Restore the app frame. It depends on another stateful method * because we must update some states after restoring the frame. * * Note this function would not clean the effects, because the * window may need to be tilted again. * * @param {boolean} |instant| If true, tilt it back instantly * @param {function()} |cb| (Optional) Callback when the tilt was done. * @this {ShrinkingUI} */ ShrinkingUI.prototype._shrinkingTiltBack = function su_shrinkingTiltBack(instant, callback) { var foregroundElement = this.elements.foregroundElement; // Setup the rotating animation. if (!instant) { var tsEnd = (function _tsEnd(evt) { foregroundElement.removeEventListener('transitionend', tsEnd); if (callback) { callback(); } }).bind(this); foregroundElement.style.transition = 'transform 0.3s ease'; foregroundElement.addEventListener('transitionend', tsEnd); this._updateTiltTransition(tsEnd); foregroundElement.style.transform = 'rotateX(0.0deg)'; } else { foregroundElement.style.transition = ''; foregroundElement.style.transform = 'rotateX(0.0deg)'; if (callback) { callback(); } } }; /** * User start to tap on the tilted frame. * * @param {event} |evt| * @this {ShrinkingUI} */ ShrinkingUI.prototype._handleSendingStart = function su_handleSendingStart(evt) { // We keep the value of pageY when touchstart, so that we can // use it to calculate the offset of transition of every frame. this.state.touch.initY = evt.touches[0].pageY; this.debug('_handleSendingStart(): ', this.isActive()); // Stop the touch event to affect the inner elements. evt.stopImmediatePropagation(); // Remove the tip above the window. var tsEnd = (function _tsEnd() { this.tip.removeEventListener('transitionend', tsEnd); }).bind(this); this.tip.classList.add('hide'); this.tip.addEventListener('transitionend', tsEnd); this._updateSlideTransition(tsEnd); return false; }; /** * Sending is in progress. * * @param {event} |evt| The 'touchstart' event. * @this {ShrinkingUI} */ ShrinkingUI.prototype._handleSendingSlide = function su_handleSendingSlide(evt) { var pgy = evt.touches[0].pageY; var slideY = this.state.touch.initY - pgy; if ('undefined' === typeof this.state.touch.initY) { this.state.touch.initY = slideY; } if ('undefined' === typeof this.state.touch.prevY) { this.state.touch.prevY = slideY; } // User is dragging it back or not. this.state.toward = (this.state.touch.prevY < slideY) ? 'TOP' : 'BOTTOM'; this._sendingSlideTo(slideY); this.state.touch.prevY = slideY; this.state.overThreshold = (slideY > this.THRESHOLD) ? true : false; }; /** * Sending was finished (user released the finger). * Would trigger 'shrinking-sent' event if user sent it out. * * @param {event} |evt| The 'touchmove' event. * @this {ShrinkingUI} */ ShrinkingUI.prototype._handleSendingOut = function su_handleSendingOut(evt) { this.debug('_handleSendingOut(): ', this.isActive()); if (this.state.overThreshold && 'TOP' === this.state.toward) { this._sendingSlideTo('TOP' , (function() { // When it got sent, freeze it at the top. this._disableSlidingCover(); window.dispatchEvent(new CustomEvent('shrinking-sent')); }).bind(this)); } else { // Fallback to the bottom if user cancel it. this._sendingSlideTo('BOTTOM', (function resumeSending() { this._setTip(); }).bind(this) ); } }; /** * Must clear and restore the app frame after transition end * to make it come back with animation. * * @this {ShrinkingUI} */ ShrinkingUI.prototype._cleanEffects = function su_cleanEffects() { var foregroundElement = this.elements.foregroundElement; var backgroundElement = this.elements.backgroundElement; return new Promise((resolve, rejected) => { this.debug('_cleanEffects(): ', this.isActive()); this._disableSlidingCover(); foregroundElement.style.transition = ''; foregroundElement.style.transform = ''; foregroundElement.style.transformOrigin = '50% 50% 0'; this._setState(false); // Clear the 'transitionend' animation listener callbacks this._updateTiltTransition(null); this._updateSlideTransition(null); backgroundElement.classList.remove('shrinking-wrapper'); this.cover.remove(); this.cover = null; resolve(); }); }; ShrinkingUI.prototype._getTiltingDegree = function su_getTiltingDegree() { return (window.OrientationManager ? window.OrientationManager.fetchCurrentOrientation() .indexOf('landscape') !== -1 : window.innerHeight < window.innerWidth) ? this.configs.degreeLandscape : this.configs.degreePortrait; }; ShrinkingUI.prototype._getOverTiltingDegree = function su_getOverTiltingDegree() { return (window.OrientationManager ? window.OrientationManager.fetchCurrentOrientation() .indexOf('landscape') !== -1 : window.innerHeight < window.innerWidth) ? this.configs.overDegreeLandscape : this.configs.overDegreePortrait; }; exports.ShrinkingUI = ShrinkingUI; })(window);