Home Reference Source

src/window-behavior.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 {supportsPassive} from './utils/dom';
import {getEvent, matchKeyCombo} from './utils/input';
import {resizer, mover, getMediaQueryName, getCascadePosition} from './utils/windows';

const isPassive = supportsPassive();
const touchArg = isPassive ? {passive: true} : false;

/*
 * Map of available "actions"
 */
const actionMap = {
  maximize: (win) => win.maximize() ? null : win.restore(),
  minimize: (win) => win.minimize(),
  close: (win) => win.close()
};

/**
 * Default Window Behavior
 *
 * Controls certain events and their interaction with a window
 */
export default class WindowBehavior {
  /**
   * Create window behavior
   *
   * @param {Core} core Core reference
   */
  constructor(core) {
    /**
     * Core instance reference
     * @type {Core}
     * @readonly
     */
    this.core = core;

    /**
     * Last action
     * @type {string}
     */
    this.lastAction = null;

    /**
     * LoFi DOM Element
     * @type {Element}
     * @readonly
     */
    this.$lofi = document.createElement('div');
    this.$lofi.className = 'osjs-window-behavior-lofi';
  }

  /**
   * Initializes window behavior
   * @param {Window} win Window reference
   */
  init(win) {
    const ontouchstart = ev => this.mousedown(ev, win);
    const onmousedown = ev => this.mousedown(ev, win);
    const onclick = ev => this.click(ev, win);
    const ondblclick = ev => this.dblclick(ev, win);

    const onicondblclick = ev => {
      ev.stopPropagation();
      ev.preventDefault();
      this.iconDblclick(ev, win);
    };

    const oniconclick = ev => {
      ev.stopPropagation();
      ev.preventDefault();

      this.iconClick(ev, win);
    };

    const ontrasitionend = ev => {
      if (win) {
        win.emit('transitionend');
      }

      this.core.emit('osjs/window:transitionend', ev, win);
    };

    win.$element.addEventListener('touchstart', ontouchstart, touchArg);
    win.$element.addEventListener('mousedown', onmousedown);
    win.$element.addEventListener('click', onclick);
    win.$element.addEventListener('dblclick', ondblclick);
    win.$element.addEventListener('transitionend', ontrasitionend);

    if (win.$icon) {
      win.$icon.addEventListener('dblclick', onicondblclick);
      win.$icon.addEventListener('click', oniconclick);
    }

    win.on('resized,rendered', () => {
      win.setState('media', getMediaQueryName(win));
    });

    win.on('destroy', () => {
      if (win.$element) {
        win.$element.removeEventListener('touchstart', ontouchstart, touchArg);
        win.$element.removeEventListener('mousedown', onmousedown);
        win.$element.removeEventListener('click', onclick);
        win.$element.removeEventListener('dblclick', ondblclick);
        win.$element.removeEventListener('transitionend', ontrasitionend);
      }

      if (win.$icon) {
        win.$icon.removeEventListener('dblclick', onicondblclick);
        win.$icon.removeEventListener('click', oniconclick);
      }
    });

    const rect = {top: 0, left: 0};
    const {top, left} = getCascadePosition(win, rect, win.state.position);
    win.state.position.top = top;
    win.state.position.left = left;
    win.state.media = getMediaQueryName(win);
  }

  /**
   * Handles Mouse Click Event
   * @param {Event} ev Browser Event
   * @param {Window} win Window reference
   */
  click(ev, win) {
    if (this.lastAction) {
      return;
    }

    const target = ev.target;
    const hitButton = target.classList.contains('osjs-window-button');

    if (hitButton) {
      const action =  ev.target.getAttribute('data-action');
      actionMap[action](win);
    }
  }

  /**
   * Handles Mouse Double Click Event
   * @param {Event} ev Browser Event
   * @param {Window} win Window reference
   */
  dblclick(ev, win) {
    if (this.lastAction) {
      return;
    }

    const target = ev.target;
    const hitTitle = target.classList.contains('osjs-window-header');

    if (hitTitle) {
      if (win.state.maximized) {
        win.restore();
      } else if (win.state.minimized) {
        win.raise();
      } else {
        win.maximize();
      }
    }
  }

  /**
   * Handles Mouse Down Event
   * @param {Event} ev Browser Event
   * @param {Window} win Window reference
   */
  mousedown(ev, win) {
    let attributeSet = false;

    const {moveable, resizable} = win.attributes;
    const {maximized} = win.state;
    const {lofi, moveKeybinding} = this.core.config('windows');
    const {clientX, clientY, touch, target} = getEvent(ev);

    const checkMove = matchKeyCombo(moveKeybinding, ev)
      ? win.$element.contains(target)
      : target.classList.contains('osjs-window-header');

    const rect = this.core.has('osjs/desktop')
      ? this.core.make('osjs/desktop').getRect()
      : {top: 0, left: 0};

    const resize = target.classList.contains('osjs-window-resize')
      ? resizer(win, target)
      : null;

    const move = checkMove
      ? mover(win, {top: 0, left: 0})
      : null;

    let actionCallback;

    const mousemove = (ev) => {
      if (!isPassive) {
        ev.preventDefault();
      }

      if (maximized || (!moveable && move) || (!resizable && resize)) {
        return;
      }

      const transformedEvent = getEvent(ev);
      const posX = resize ? Math.max(rect.left, transformedEvent.clientX) : transformedEvent.clientX;
      const posY = resize ? Math.max(rect.top, transformedEvent.clientY) : transformedEvent.clientY;
      const diffX = posX - clientX;
      const diffY = posY - clientY;

      if (resize) {
        const {width, height, top, left} = resize(diffX, diffY);

        actionCallback = () => {
          win._setState('dimension', {width, height}, false);
          win._setState('position', {top, left}, false);
        };

        if (lofi) {
          this.$lofi.style.top = `${top}px`;
          this.$lofi.style.left = `${left}px`;
          this.$lofi.style.width = `${width}px`;
          this.$lofi.style.height = `${height}px`;
        } else {
          actionCallback();
        }

        this.lastAction = 'resize';
      } else if (move) {
        const position = move(diffX, diffY);

        actionCallback = () => {
          win._setState('position', position, false);
        };

        if (lofi) {
          this.$lofi.style.top = `${position.top}px`;
          this.$lofi.style.left = `${position.left}px`;
        } else {
          actionCallback();
        }

        this.lastAction = 'move';
      }

      if (this.lastAction) {
        win._setState(this.lastAction === 'move' ? 'moving' : 'resizing', true);

        if (!attributeSet) {
          this.core.$root.setAttribute('data-window-action', String(true));
          attributeSet = true;
        }
      }
    };

    const mouseup = () => {
      if (touch) {
        document.removeEventListener('touchmove', mousemove, touchArg);
        document.removeEventListener('touchend', mouseup, touchArg);
      } else {
        document.removeEventListener('mousemove', mousemove);
        document.removeEventListener('mouseup', mouseup);
      }

      if (lofi) {
        this.$lofi.remove();

        if (actionCallback) {
          actionCallback();
        }

        actionCallback = undefined;
      }

      if (this.lastAction === 'move') {
        win.emit('moved', {...win.state.position}, win);
        win._setState('moving', false);
      } else if (this.lastAction === 'resize') {
        win.emit('resized', {...win.state.dimension}, win);
        win._setState('resizing', false);
      }

      this.core.$root.setAttribute('data-window-action', String(false));
    };


    if (!win.focus()) {
      win.setNextZindex();
    }

    if (move || resize) {
      if (touch) {
        document.addEventListener('touchmove', mousemove, touchArg);
        document.addEventListener('touchend', mouseup, touchArg);
      } else {
        document.addEventListener('mousemove', mousemove);
        document.addEventListener('mouseup', mouseup);
      }
    }

    this.lastAction = null;

    if (this.core.has('osjs/contextmenu')) {
      this.core.make('osjs/contextmenu').hide();
    }

    if (lofi) {
      this.$lofi.style.zIndex = win.state.zIndex + 1;
      this.$lofi.style.top = `${win.state.position.top}px`;
      this.$lofi.style.left = `${win.state.position.left}px`;
      this.$lofi.style.width = `${win.state.dimension.width}px`;
      this.$lofi.style.height = `${win.state.dimension.height}px`;

      if (!this.$lofi.parentNode) {
        document.body.appendChild(this.$lofi);
      }
    }
  }

  /**
   * Handles Icon Double Click Event
   * @param {Event} ev Browser Event
   * @param {Window} win Window reference
   */
  iconDblclick(ev, win) {
    win.close();
  }

  /**
   * Handles Icon Click Event
   * @param {Event} ev Browser Event
   * @param {Window} win Window reference
   */
  iconClick(ev, win) {
    const {minimized, maximized} = win.state;
    const {minimizable, maximizable, closeable} = win.attributes;
    const _ = this.core.make('osjs/locale').translate;

    this.core.make('osjs/contextmenu', {
      position: ev,
      menu: [{
        label: minimized ? _('LBL_RAISE') : _('LBL_MINIMIZE'),
        disabled: !minimizable,
        onclick: () => minimized ? win.raise() : win.minimize()
      }, {
        label: maximized ? _('LBL_RESTORE') : _('LBL_MAXIMIZE'),
        disabled: !maximizable,
        onclick: () => maximized ? win.restore() : win.maximize()
      }, {
        label: _('LBL_CLOSE'),
        disabled: !closeable,
        onclick: () => win.close()
      }]
    });
  }
}