src/window.js
/*
* OS.js - JavaScript Cloud/Web Desktop Platform
*
* Copyright (c) Anders Evenrud <andersevenrud@gmail.com>
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* @author Anders Evenrud <andersevenrud@gmail.com>
* @license Simplified BSD License
*/
import {EventEmitter} from '@osjs/event-emitter';
import {droppable} from './utils/dnd';
import {escapeHtml, createCssText, supportsTransition, getActiveElement} from './utils/dom';
import {
createAttributes,
createState,
createDOMAttributes,
createDOMStyles,
clampPosition,
renderCallback,
transformVectors,
positionFromGravity,
dimensionFromElement
} from './utils/windows';
import logger from './logger';
/**
* Window dimension definition
* @typedef {Object} WindowDimension
* @property {number} width Width in pixels (or float for percentage in setters)
* @property {number} height Height in pixels (or float for percentage in setters)
*/
/**
* Window position definition
* @typedef {Object} WindowPosition
* @property {number} left Left in pixels (or float for percentage in setters)
* @property {number} top Top in pixels (or float for percentage in setters)
*/
/**
* Window session
* @typedef {Object} WindowSession
* @property {number} id
* @property {boolean} maximized
* @property {boolean} minimized
* @property {WindowPosition} position
* @property {WindowDimension} dimension
*/
/**
* Window attributes definition
*
* @typedef {Object} WindowAttributes
* @property {string[]} [classNames=[]] A list of class names
* @property {boolean} [ontop=false] If always on top
* @property {string} [gravity] Gravity (center/top/left/right/bottom or any combination)
* @property {boolean} [resizable=true] If resizable
* @property {boolean} [focusable=true] If focusable
* @property {boolean} [maximizable=true] If window if maximizable
* @property {boolean} [minimizable=true] If minimizable
* @property {boolean} [moveable=true] If moveable
* @property {boolean} [closeable=true] If closeable
* @property {boolean} [header=true] Show header
* @property {boolean} [controls=true] Show controls
* @property {string} [visibility=global] Global visibility, 'restricted' to hide from window lists etc.
* @property {boolean} [clamp=true] Clamp the window position upon creation
* @property {boolean} [droppable=true] If window should have the default drop action
* @property {WindowDimension} [minDimension] Minimum dimension
* @property {WindowDimension} [maxDimension] Maximum dimension
* @property {{name: string}} [mediaQueries] A map of matchMedia to name
*/
/**
* Window state definition
*
* @typedef {Object} WindowState
* @property {string} title Title
* @property {string} icon Icon
* @property {boolean} [moving=false] If moving
* @property {boolean} [resizing=false] If resizing
* @property {boolean} [loading=false] If loading
* @property {boolean} [focused=false] If focused
* @property {boolean} [maximized=false] If maximized
* @property {boolean} [mimimized=false] If mimimized
* @property {boolean} [modal=false] If modal to the parent
* @property {number} [zIndex=10] The z-index (auto calculated)
* @property {WindowPosition} [position] Position
* @property {WindowDimension} [dimension] Dimension
*/
/**
* Window options definition
*
* @typedef {Object} WindowOptions
* @property {string} id Window Id (not globaly unique)
* @property {string} [title] Window Title
* @property {string} [icon] Window Icon
* @property {Window} [parent] The parent Window reference
* @property {string|Function} [template] The Window HTML template (or function with signature (el, win) for programatic construction)
* @property {Function} [ondestroy] A callback function when window destructs to interrupt the procedure
* @property {WindowPosition|string} [position] Window position
* @property {WindowDimension} [dimension] Window dimension
* @property {WindowAttributes} [attributes] Apply Window attributes
* @property {WindowState} [state] Apply Window state
*/
let windows = [];
let windowCount = 0;
let nextZindex = 1;
let lastWindow = null;
/*
* Default window template
*/
const TEMPLATE = `<div class="osjs-window-inner">
<div class="osjs-window-header">
<div class="osjs-window-icon">
<div></div>
</div>
<div class="osjs-window-title"></div>
<div class="osjs-window-button" data-action="minimize">
<div></div>
</div>
<div class="osjs-window-button" data-action="maximize">
<div></div>
</div>
<div class="osjs-window-button" data-action="close">
<div></div>
</div>
</div>
<div class="osjs-window-content">
</div>
<div class="osjs-window-resize" data-direction="n"></div>
<div class="osjs-window-resize" data-direction="nw"></div>
<div class="osjs-window-resize" data-direction="w"></div>
<div class="osjs-window-resize" data-direction="sw"></div>
<div class="osjs-window-resize" data-direction="s"></div>
<div class="osjs-window-resize" data-direction="se"></div>
<div class="osjs-window-resize" data-direction="e"></div>
<div class="osjs-window-resize" data-direction="ne"></div>
</div>`.replace(/\n\s+/g, '').trim();
/**
* Window Implementation
*/
export default class Window extends EventEmitter {
/**
* Create window
*
* @param {Core} core Core reference
* @param {WindowOptions} [options={}] Options
*/
constructor(core, options = {}) {
options = {
id: null,
title: null,
parent: null,
template: null,
ondestroy: null,
attributes: {},
position: {},
dimension: {},
state: {},
...options
};
logger.debug('Window::constructor()', options);
super('Window@' + options.id);
if (typeof options.position === 'string') {
options.attributes.gravity = options.position;
options.position = {};
}
/**
* The Window ID
* @type {string}
* @readonly
*/
this.id = options.id;
/**
* The Window ID
* @type {Number}
* @readonly
*/
this.wid = ++windowCount;
/**
* Parent Window reference
* @type {Window}
* @readonly
*/
this.parent = options.parent;
/**
* Child windows (via 'parent')
* @type {Window[]}
*/
this.children = [];
/**
* Core instance reference
* @type {Core}
* @readonly
*/
this.core = core;
/**
* The window destruction state
* @type {boolean}
*/
this.destroyed = false;
/**
* The window rendered state
* @type {boolean}
*/
this.rendered = false;
/**
* The window was inited
* @type {boolean}
*/
this.inited = false;
/**
* The window attributes
* @type {WindowAttributes}
*/
this.attributes = createAttributes(options.attributes);
/**
* The window state
* @type {WindowState}
*/
this.state = createState(options.state, options, this.attributes);
/**
* The window container
* @type {Element}
* @readonly
*/
this.$element = document.createElement('div');
/**
* The content container
* @type {Element}
*/
this.$content = null;
/**
* The header container
* @type {Element}
*/
this.$header = null;
/**
* The icon container
* @type {Element}
*/
this.$icon = null;
/**
* The title container
* @type {Element}
*/
this.$title = null;
/**
* Internal variable to signal not to use default position
* given by user (used for restore)
* @private
* @type {boolean}
*/
this._preventDefaultPosition = false;
/**
* Internal timeout reference used for triggering the loading
* overlay.
* @private
* @type {boolean}
*/
this._loadingDebounce = null;
/**
* The window template
* @private
* @type {string|Function}
*/
this._template = options.template;
/**
* Custom destructor callback
* @private
* @type {Function}
* @readonly
*/
this._ondestroy = options.ondestroy || (() => true);
/**
* Last DOM update CSS text
* @private
* @type {string}
*/
this._lastCssText = '';
/**
* Last DOM update data attributes
* @private
* @type {WindowAttributes}
*/
this._lastAttributes = {};
windows.push(this);
}
/**
* Destroy window
*/
destroy() {
if (this.destroyed) {
return;
}
if (typeof this._ondestroy === 'function' && this._ondestroy() === false) {
return;
}
this.destroyed = true;
logger.debug('Window::destroy()');
this.emit('destroy', this);
this.core.emit('osjs/window:destroy', this);
this.children.forEach(w => w.destroy());
if (this.$element) {
this.$element.remove();
}
if (lastWindow === this) {
lastWindow = null;
}
const foundIndex = windows.findIndex(w => w === this);
if (foundIndex !== -1) {
windows.splice(foundIndex, 1);
}
this.children = [];
this.parent = null;
this.$element = null;
this.$content = null;
this.$header = null;
this.$icon = null;
this.$title = null;
super.destroy();
}
/**
* Initialize window
*/
init() {
if (this.inited) {
return this;
}
if (this.parent) {
// Assign the window if it is a child
this.on('destroy', () => {
const foundIndex = this.parent.children.findIndex(w => w === this);
if (foundIndex !== -1) {
this.parent.children.splice(foundIndex, 1);
}
});
this.parent.children.push(this);
}
this._initTemplate();
this._initBehavior();
this.inited = true;
this.emit('init', this);
this.core.emit('osjs/window:create', this);
return this;
}
/**
* Initializes window template
* @private
*/
_initTemplate() {
const tpl = this.core.config('windows.template') || TEMPLATE;
if (this._template) {
this.$element.innerHTML = typeof this._template === 'function'
? this._template(this, tpl)
: this._template;
} else {
this.$element.innerHTML = tpl;
}
this.$content = this.$element.querySelector('.osjs-window-content');
this.$header = this.$element.querySelector('.osjs-window-header');
this.$icon = this.$element.querySelector('.osjs-window-icon > div');
this.$title = this.$element.querySelector('.osjs-window-title');
}
/**
* Initializes window behavior
* @private
*/
_initBehavior() {
// Transform percentages in dimension to pixels etc
if (this.core.has('osjs/desktop')) {
const rect = this.core.make('osjs/desktop').getRect();
const {dimension, position} = transformVectors(rect, this.state.dimension, this.state.position);
this.state.dimension = dimension;
this.state.position = position;
}
// Behavior
const behavior = this.core.make('osjs/window-behavior');
if (behavior) {
behavior.init(this);
}
// DnD functionality
if (this.attributes.droppable) {
const d = droppable(this.$element, {
ondragenter: (...args) => this.emit('dragenter', ...args, this),
ondragover: (...args) => this.emit('dragover', ...args, this),
ondragleave: (...args) => this.emit('dragleave', ...args, this),
ondrop: (...args) => this.emit('drop', ...args, this)
});
this.on('destroy', () => d.destroy());
}
}
/**
* Checks the modal state of the window upon render
* @private
*/
_checkModal() {
// TODO: Global modal
if (this.parent) {
if (this.attributes.modal) {
this.on('render', () => this.parent.setState('loading', true));
this.on('destroy', () => this.parent.setState('loading', false));
}
this.on('destroy', () => this.parent.focus());
}
}
/**
* Sets the initial class names
* @private
*/
_setClassNames() {
const classNames = ['osjs-window', ...this.attributes.classNames];
if (this.id) {
classNames.push(`Window_${this.id}`);
}
classNames.filter(val => !!val)
.forEach((val) => this.$element.classList.add(val));
}
/**
* Render window
* @param {Function} [callback] Callback when window DOM has been constructed
* @return {Window} this instance
*/
render(callback = function() {}) {
if (this.rendered) {
return this;
} else if (!this.inited) {
this.init();
}
this._setClassNames();
this._updateButtons();
this._updateAttributes();
this._updateStyles();
this._updateTitle();
this._updateIconStyles();
this._updateHeaderStyles();
this._checkModal();
if (!this._preventDefaultPosition) {
this.gravitate(this.attributes.gravity);
}
// Clamp the initial window position to viewport
if (this.attributes.clamp) {
this.clampToViewport(false);
}
this.setNextZindex(true);
this.core.$contents.appendChild(this.$element);
renderCallback(this, callback);
this.rendered = true;
setTimeout(() => {
this.emit('render', this);
this.core.emit('osjs/window:render', this);
}, 1);
return this;
}
/**
* Close the window
* @return {boolean}
*/
close() {
if (this.destroyed) {
return false;
}
this.emit('close', this);
this.destroy();
return true;
}
/**
* Focus the window
* @return {boolean}
*/
focus() {
if (!this.state.minimized && this._toggleState('focused', true, 'focus')) {
this._focus();
return true;
}
return false;
}
/**
* Internal for focus
* @private
*/
_focus() {
if (lastWindow && lastWindow !== this) {
lastWindow.blur();
}
lastWindow = this;
this.setNextZindex();
}
/**
* Blur (un-focus) the window
* @return {boolean}
*/
blur() {
// Forces blur-ing of browser input element belonging to this window
const activeElement = getActiveElement(this.$element);
if (activeElement) {
activeElement.blur();
}
return this._toggleState('focused', false, 'blur');
}
/**
* Minimize (hide) the window
* @return {boolean}
*/
minimize() {
if (this.attributes.minimizable) {
if (this._toggleState('minimized', true, 'minimize')) {
this.blur();
return true;
}
}
return false;
}
/**
* Raise (un-minimize) the window
* @return {boolean}
*/
raise() {
return this._toggleState('minimized', false, 'raise');
}
/**
* Maximize the window
* @return {boolean}
*/
maximize() {
if (this.attributes.maximizable) {
return this._maximize(true);
}
return false;
}
/**
* Restore (un-maximize) the window
* @return {boolean}
*/
restore() {
return this._maximize(false);
}
/**
* Internal for Maximize or restore
* @private
* @param {boolean} toggle Maximize or restore
* @return {boolean}
*/
_maximize(toggle) {
if (this._toggleState('maximized', toggle, toggle ? 'maximize' : 'restore')) {
const emit = () => this.emit('resized', {
width: this.$element ? this.$element.offsetWidth : -1,
height: this.$element ? this.$element.offsetHeight : -1
}, this);
if (supportsTransition()) {
this.once('transitionend', emit);
} else {
emit();
}
return true;
}
return false;
}
/**
* Resize to fit to current container
* @param {Element} [container] The DOM element to use
*/
resizeFit(container) {
container = container || this.$content.firstChild;
if (container) {
const rect = this.core.has('osjs/desktop')
? this.core.make('osjs/desktop').getRect()
: null;
const {width, height} = dimensionFromElement(this, rect, container);
if (!isNaN(width) && !isNaN(height)) {
this.setDimension({width, height});
}
}
}
/**
* Clamps the position to viewport
* @param {boolean} [update=true] Update DOM
*/
clampToViewport(update = true) {
if (!this.core.has('osjs/desktop')) {
return;
}
const rect = this.core.make('osjs/desktop').getRect();
this.state.position = {
...this.state.position,
...clampPosition(rect, this.state)
};
if (update) {
this._updateStyles();
}
}
/**
* Set the Window icon
* @param {string} uri Icon URI
*/
setIcon(uri) {
this.state.icon = uri;
this._updateIconStyles();
}
/**
* Set the Window title
* @param {string} title Title
*/
setTitle(title) {
this.state.title = title || '';
this._updateTitle();
this.core.emit('osjs/window:change', this, 'title', title);
}
/**
* Set the Window dimension
* @param {WindowDimension} dimension The dimension
*/
setDimension(dimension) {
const {width, height} = {...this.state.dimension, ...dimension || {}};
this.state.dimension.width = width;
this.state.dimension.height = height;
this._updateStyles();
}
/**
* Set the Window position
* @param {WindowPosition} position The position
* @param {boolean} [preventDefault=false] Prevents any future position setting in init procedure
*/
setPosition(position, preventDefault = false) {
const {left, top} = {...this.state.position, ...position || {}};
this.state.position.top = top;
this.state.position.left = left;
if (preventDefault) {
this._preventDefaultPosition = true;
}
this._updateStyles();
}
/**
* Set the Window z index
* @param {Number} zIndex the index
*/
setZindex(zIndex) {
this.state.zIndex = zIndex;
logger.debug('Window::setZindex()', zIndex);
this._updateStyles();
}
/**
* Sets the Window to next z index
* @param {boolean} [force] Force next index
*/
setNextZindex(force) {
const setNext = force || this._checkNextZindex();
if (setNext) {
this.setZindex(nextZindex);
nextZindex++;
}
}
/**
* Set a state by value
* @param {string} name State name
* @param {*} value State value
* @param {boolean} [update=true] Update the DOM
* @see {WindowState}
*/
setState(name, value, update = true) {
const set = () => this._setState(name, value, update);
// Allows for some "grace time" so the overlay does not
// "blink"
if (name === 'loading' && update) {
clearTimeout(this._loadingDebounce);
if (value === true) {
this._loadingDebounce = setTimeout(() => set(), 250);
return;
}
}
set();
}
/**
* Gravitates window towards a certain area
* @param {string} gravity Gravity
*/
gravitate(gravity) {
if (!this.core.has('osjs/desktop')) {
return;
}
const rect = this.core.make('osjs/desktop').getRect();
const position = positionFromGravity(this, rect, gravity);
this.setPosition(position);
}
/**
* Gets a astate
* @return {*}
*/
getState(n) {
const value = this.state[n];
return ['position', 'dimension', 'styles'].indexOf(n) !== -1
? {...value}
: value;
}
/**
* Get a snapshot of the Window session
* @return {WindowSession}
*/
getSession() {
return this.attributes.sessionable === false ? null : {
id: this.id,
maximized: this.state.maximized,
minimized: this.state.minimized,
position: {...this.state.position},
dimension: {...this.state.dimension}
};
}
/**
* Get a list of all windows
*
* @return {Window[]}
*/
static getWindows() {
return windows;
}
/**
* Gets the lastly focused Window
* @return {Window}
*/
static lastWindow() {
return lastWindow;
}
/**
* Internal method for setting state
* @private
* @param {string} name State name
* @param {*} value State value
* @param {boolean} [update=true] Update the DOM
* @param {boolean} [updateAll=true] Update the entire DOM
*/
_setState(name, value, update = true) {
const oldValue = this.state[name];
this.state[name] = value;
if (update) {
if (oldValue !== value) {
logger.debug('Window::_setState()', name, value);
}
this._updateAttributes();
this._updateStyles();
}
}
/**
* Internal method for toggling state
* @private
* @param {string} name State name
* @param {any} value State value
* @param {string} eventName Name of event to emit
* @param {boolean} [update=true] Update the DOM
*/
_toggleState(name, value, eventName, update = true) {
if (this.state[name] === value) {
return false;
}
logger.debug('Window::_toggleState()', name, value, eventName, update);
this.state[name] = value;
this.emit(eventName, this);
this.core.emit('osjs/window:change', this, name, value);
if (update) {
this._updateAttributes();
}
return true;
}
/**
* Check if we have to set next zindex
* @private
* @return {boolean}
*/
_checkNextZindex() {
const {ontop} = this.attributes;
const {zIndex} = this.state;
const windexes = windows
.filter(w => w.attributes.ontop === ontop)
.filter(w => w.wid !== this.wid)
.map(w => w.state.zIndex);
const max = windexes.length > 0
? Math.max.apply(null, windexes)
: 0;
return zIndex < max;
}
/*
* Updates window styles and attributes
* FIXME: Backward compability with themes
* @deprecated
*/
_updateDOM() {
this._updateAttributes();
this._updateStyles();
}
/**
* Updates the window buttons in DOM
* @private
*/
_updateButtons() {
const hideButton = action =>
this.$header.querySelector(`.osjs-window-button[data-action=${action}]`)
.style.display = 'none';
const buttonmap = {
maximizable: 'maximize',
minimizable: 'minimize',
closeable: 'close'
};
if (this.attributes.controls) {
Object.keys(buttonmap)
.forEach(key => {
if (!this.attributes[key]) {
hideButton(buttonmap[key]);
}
});
} else {
Array.from(this.$header.querySelectorAll('.osjs-window-button'))
.forEach(el => el.style.display = 'none');
}
}
/**
* Updates window title in DOM
* @private
*/
_updateTitle() {
if (this.$title) {
const escapedTitle = escapeHtml(this.state.title);
if (this.$title.innerHTML !== escapedTitle) {
this.$title.innerHTML = escapedTitle;
}
}
}
/**
* Updates window icon decoration in DOM
* @private
*/
_updateIconStyles() {
if (this.$icon) {
const iconSource = `url(${this.state.icon})`;
if (this.$icon.style.backgroundImage !== iconSource) {
this.$icon.style.backgroundImage = iconSource;
}
}
}
/**
* Updates window header decoration in DOM
* @private
*/
_updateHeaderStyles() {
if (this.$header) {
const headerDisplay = this.attributes.header ? undefined : 'none';
if (this.$header.style.display !== headerDisplay) {
this.$header.style.display = headerDisplay;
}
}
}
/**
* Updates window data in DOM
* @private
*/
_updateAttributes() {
if (this.$element) {
const attrs = createDOMAttributes(this.id, this.state, this.attributes);
const applyAttrs = Object.keys(attrs).filter(k => attrs[k] !== this._lastAttributes[k]);
if (applyAttrs.length > 0) {
applyAttrs.forEach(a => this.$element.setAttribute(`data-${a}`, String(attrs[a])));
this._lastAttributes = attrs;
}
}
}
/**
* Updates window style in DOM
* @private
*/
_updateStyles() {
if (this.$element) {
const cssText = createCssText(createDOMStyles(this.state, this.attributes));
if (cssText !== this._lastCssText) {
this.$element.style.cssText = cssText;
this._lastCssText = cssText;
}
}
}
}