Source: window.js

/**
 * @namespace qui.window
 */

import $      from '$qui/lib/jquery.module.js'
import Logger from '$qui/lib/logger.module.js'

import Signal    from '$qui/base/signal.js'
import Config    from '$qui/config.js'
import * as AJAX from '$qui/utils/ajax.js'
import {asap}    from '$qui/utils/misc.js'


const logger = Logger.get('qui.window')

let unloading = false
let reloading = false
let smallScreenThreshold = null
let scalingFactor = null
let appActive = false
let appFocused = false


/**
 * jQuery-wrapped document object.
 * @alias qui.window.$document
 * @type {jQuery}
 */
export let $document = null

/**
 * jQuery-wrapped window object.
 * @alias qui.window.$window
 * @type {jQuery}
 */
export let $window = null

/**
 * jQuery-wrapped document body object.
 * @alias qui.window.$body
 * @type {jQuery}
 */
export let $body = null

/**
 * Emitted whenever the window is resized. Handlers are called with the following parameters:
 *  * `width: Number`, the new window width
 *  * `height: Number`, the new window height
 * @alias qui.window.resizeSignal
 */
export const resizeSignal = new Signal()

/**
 * Emitted whenever the screen layout changes. Handlers are called with the following parameters:
 *  * `smallScreen: Boolean`, telling if the screen is small, as defined by {@link qui.window.isSmallScreen}
 *  * `landscape: Boolean`, telling if the screen orientation is landscape
 * @alias qui.window.screenLayoutChangeSignal
 */
export const screenLayoutChangeSignal = new Signal()

/**
 * Emitted whenever the application enters or leaves full-screen mode. Handlers are called with the following
 * parameters:
 *  * `fullScreen`, telling if the application is currently in full-screen mode, nor not: `Boolean`
 * @alias qui.window.fullScreenChangeSignal
 */
export const fullScreenChangeSignal = new Signal()

/**
 * Emitted whenever the application window becomes focused or is no longer focused. Handlers are called with the
 * following parameters:
 *  * `focused: Boolean`, telling if the application is focused or not
 * @alias qui.window.focusChangeSignal
 */
export const focusChangeSignal = new Signal()

/**
 * Emitted whenever the application window becomes active or is no longer active. Handlers are called with the
 * following parameters:
 *  * `active: Boolean`, telling if the application is active or not
 * @alias qui.window.activeChangeSignal
 */
export const activeChangeSignal = new Signal()

/**
 * Emitted when the application window is about to be closed. Handlers are called with no parameters. If any of the
 * handlers returns `false`, window closing will be prevented, if possible.
 * @alias qui.window.closeSignal
 */
export const closeSignal = new Signal()


/* Full screen */

function handleEnterFullScreen() {
    $body.addClass('full-screen')
    logger.debug('entering full screen')
}

function handleExitFullScreen() {
    $body.removeClass('full-screen')
    logger.debug('full screen exited')
}

/**
 * Put the window full-screen mode.
 * @alias qui.window.enterFullScreen
 */
export function enterFullScreen() {
    document.documentElement.requestFullscreen()
}

/**
 * Tell whether the browser window is in full-screen mode or not.
 * @alias qui.window.isFullScreen
 * @returns {Boolean}
 */
export function isFullScreen() {
    return document.fullscreenEnabled != null
}


/* Screen size & layout */

function handleSmallScreen() {
    logger.debug('small screen mode')
}

function handleLargeScreen() {
    logger.debug('large screen mode')
}

function handleLandscape() {
    logger.debug('landscape mode')
}

function handlePortrait() {
    logger.debug('portrait mode')
}

/**
 * @private
 * @returns {Boolean}
 */
function evaluateScreenLayout() {
    let smallScreen = isSmallScreen()
    let landscape = isLandscape()
    let changed = false

    if (smallScreen && !$body.hasClass('small-screen')) {
        $body.addClass('small-screen')
        handleSmallScreen()
        changed = true
    }
    else if (!smallScreen && $body.hasClass('small-screen')) {
        $body.removeClass('small-screen')
        handleLargeScreen()
        changed = true
    }

    if (landscape && !$body.hasClass('landscape')) {
        $body.addClass('landscape')
        handleLandscape()
        changed = true
    }
    else if (!landscape && $body.hasClass('landscape')) {
        $body.removeClass('landscape')
        handlePortrait()
        changed = true
    }

    if (changed) {
        screenLayoutChangeSignal.emit(smallScreen, landscape)
    }

    return changed
}

/**
 * Tell whether the screen is small or not. A screen is considered small if its width is below a *small*
 * threshold.
 * @alias qui.window.isSmallScreen
 * @returns {Boolean}
 */
export function isSmallScreen() {
    return document.documentElement.clientWidth <= smallScreenThreshold * scalingFactor
}

/**
 * Tell the current small screen threshold.
 * @alias qui.window.getSmallScreenThreshold
 * @returns {Number}
 */
export function getSmallScreenThreshold() {
    return smallScreenThreshold
}

/**
 * Set the small screen threshold. Defaults to `700` logical pixels.
 *
 * You can set the threshold to `0` to disable small screen mode; setting it to a large number (e.g. `1e6`) ensures that
 * small screen mode is always active.
 *
 * This function may force-close current pages and re-navigate to the current path, if screen layout is changed.
 *
 * @alias qui.window.setSmallScreenThreshold
 * @param {?Number} threshold small screen threshold, in logical pixels; passing `null` will reset to default
 */
export function setSmallScreenThreshold(threshold) {
    logger.debug(`setting small screen threshold to ${threshold} pixels`)
    smallScreenThreshold = threshold
    if (smallScreenThreshold == null) {
        smallScreenThreshold = Config.defaultSmallScreenThreshold
    }

    if ($body != null) {
        evaluateScreenLayout()
    }
}

/**
 * Tell if the screen orientation is landscape (`true`) or portrait (`false`).
 * @alias qui.window.isLandscape
 * @returns {Boolean}
 */
export function isLandscape() {
    let width = document.documentElement.clientWidth
    let height = document.documentElement.clientHeight

    return width >= height
}

/**
 * Tell the current scaling factor.
 * @alias qui.window.getScalingFactor
 * @returns {Number}
 */
export function getScalingFactor() {
    return scalingFactor
}

/**
 * Set the root scaling factor. Use `1` to disable scaling.
 *
 * This function may force-close current pages and re-navigate to the current path, if screen layout is changed.
 *
 * @alias qui.window.setScalingFactor
 * @param {Number} factor
 */
export function setScalingFactor(factor) {
    logger.debug(`setting scaling factor to ${factor}`)
    scalingFactor = factor

    if ($body != null) {
        if (scalingFactor === 1) {
            $body.css('zoom', '')
        }
        else {
            $body.css('zoom', `${factor * 100}%`)
        }

        /* Changing scaling factor will effectively change the perceived size of the window */
        $window.trigger('resize')
    }
}

/**
 * Tell the current application window width.
 * @alias qui.window.getWidth
 * @returns {Number}
 */
export function getWidth() {
    return document.documentElement.clientWidth / scalingFactor
}

/**
 * Tell the current application window height.
 * @alias qui.window.getHeight
 * @returns {Number}
 */
export function getHeight() {
    return document.documentElement.clientHeight / scalingFactor
}


/* Focus */

function handleBecomeFocused() {
    if (appFocused) {
        return
    }

    appFocused = true

    $body.addClass('focused')
    logger.info('application is focused')
    focusChangeSignal.emit(true)
}

function handleBecomeUnfocused() {
    if (!appFocused) {
        return
    }

    appFocused = false

    $body.removeClass('focused')
    logger.info('application is unfocused')
    focusChangeSignal.emit(false)
}

/**
 * Tell whether the application is focused or not.
 * @alias qui.window.isFocused
 * @returns {Boolean}
 */
export function isFocused() {
    return appFocused
}


/* Active */

function handleBecomeActive() {
    if (appActive) {
        return
    }

    appActive = true

    $body.addClass('active')
    logger.info('application is active')
    activeChangeSignal.emit(true)
}

function handleBecomeInactive() {
    if (!appActive) {
        return
    }

    /* An inactive app can't be focused */
    if (appFocused) {
        handleBecomeUnfocused()
    }

    appActive = false

    $body.removeClass('active')
    logger.info('application is inactive')
    activeChangeSignal.emit(false)
}

/**
 * Tell whether the application is active or not.
 * @alias qui.window.isActive
 * @returns {Boolean}
 */
export function isActive() {
    return appActive
}


/* Reloading & closing */

/**
 * Reload the window.
 * @alias qui.window.reload
 * @param {String} [path] optional path to navigate
 */
export function reload(path) {
    reloading = true

    logger.debug('reloading')

    asap(function () {
        if (path) {
            if (window.location.href === path) {
                path = ''
            }

            window.location.href = path
        }
        else {
            window.location.reload()
        }
    })
}

/**
 * Tell if the window is currently closing.
 * @alias qui.window.isClosing
 * @returns {Boolean}
 */
export function isClosing() {
    return unloading || reloading
}

export function init() {
    /* Initialize some settings with default values from configuration, but only if they haven't been initialized yet */
    if (smallScreenThreshold == null) {
        smallScreenThreshold = Config.defaultSmallScreenThreshold
    }
    if (scalingFactor == null) {
        scalingFactor = Config.defaultScalingFactor
    }

    /* Wrap main objects in jQuery */
    $document = $(document)
    $window = $(window)
    $body = $(document.body)

    if (scalingFactor !== 1) {
        $body.css('zoom', `${scalingFactor * 100}%`)
    }

    /* Window resize handling */
    $window.on('resize', function () {
        let width = getWidth()
        let height = getHeight()

        resizeSignal.emit(width, height)
        evaluateScreenLayout()
    })

    /* Window unload handling */
    $window.on('beforeunload', function (e) {
        unloading = true

        logger.info('application unload requested')

        /* These nested asap() calls allow us to detect if unloading has been cancelled */
        asap(function () {
            asap(function () {
                logger.info('application unload cancelled')
            })
        })

        let canUnload = true

        /* Give a chance to any pending requests to complete */
        let writePendingRequests = AJAX.getPendingRequests()
                                   .filter(r => ['POST', 'PUT', 'PATCH', 'DELETE'].includes(r.details.method))

        if (writePendingRequests.length > 0) {
            logger.warn(`application unload: there are ${writePendingRequests.length} write pending ajax requests`)
            canUnload = false
        }

        let closeSignalResult = closeSignal.emit()
        if (closeSignalResult === false) {
            logger.warn('application unload prevented by close signal handler')
            canUnload = false
        }

        if (!canUnload) {
            e.preventDefault()
            unloading = false
            return false
        }
    })

    /* Full screen handling */
    $document.on('fullscreenchange', function () {
        if (isFullScreen()) {
            handleEnterFullScreen()
        }
        else {
            handleExitFullScreen()
        }

        fullScreenChangeSignal.emit(isFullScreen())
    })

    /* Active handling */
    $document.on('visibilitychange', function () {
        if (this.visibilityState === 'visible') {
            handleBecomeActive()
        }
        else {
            handleBecomeInactive()
        }
    })

    $window.on('pageshow', () => handleBecomeActive())
    $window.on('pagehide', () => handleBecomeInactive())

    $document.on('resume', () => handleBecomeActive())
    $document.on('freeze', () => handleBecomeInactive())

    /* Initial active state */
    if (document.visibilityState === 'visible') {
        handleBecomeActive()
    }
    else {
        handleBecomeInactive()
    }

    /* Focus handling */
    $window.on('focus', () => handleBecomeFocused())
    $window.on('blur', () => handleBecomeUnfocused())

    /* Initial focus */
    if (document.hasFocus()) {
        handleBecomeFocused()
    }
    else {
        handleBecomeUnfocused()
    }

    /* Check every second to ensure the active and focus states are correctly set. This shouldn't normally be
     * necessary, but PWAs running on Chrome sometimes don't fire some events wen waking up (unfreezing/resuming) */
    setInterval(function () {

        if (document.visibilityState === 'visible' && !appActive) {
            logger.warn('surprised to detect application active')
            handleBecomeActive()
        }
        else if (document.visibilityState !== 'visible' && appActive) {
            logger.warn('surprised to detect application inactive')
            handleBecomeInactive()
        }

        if (document.hasFocus() && !appFocused) {
            logger.warn('surprised to detect application focused')
            handleBecomeFocused()
        }
        else if (!document.hasFocus() && appFocused) {
            logger.warn('surprised to detect application unfocused')
            handleBecomeUnfocused()
        }

    }, 1000)

    asap(function () {
        /* Reset the scroll position of the body */
        $body.scrollLeft(0)
        $body.scrollTop(0)

        /* Trigger an initial resize */
        $window.resize()
    })
}