Home Reference Source

src/desktop.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 Application from './application';
import {handleTabOnTextarea} from './utils/dom';
import {matchKeyCombo} from './utils/input';
import {DesktopIconView} from './adapters/ui/iconview';
import {
  isDroppingImage,
  applyBackgroundStyles,
  createPanelSubtraction,
  isVisible
} from './utils/desktop';
import Window from './window';
import Search from './search';
import merge from 'deepmerge';
import logger from './logger';

/**
 * TODO: typedef
 * @typedef {Object} DesktopContextMenuEntry
 */

/**
 * @typedef {Object} DesktopIconViewSettings
 */

/**
 * TODO: typedef
 * @typedef {Object} DesktopSettings
 * @property {DesktopIconViewSettings} [iconview]
 */

/**
 * Desktop Options
 *
 * @typedef {Object} DeskopOptions
 * @property {object[]} [contextmenu={}] Default Context menu items
 */

/**
 * Desktop Viewport Rectangle
 *
 * @typedef {Object} DesktopViewportRectangle
 * @property {number} left
 * @property {number} top
 * @property {number} right
 * @property {number} bottom
 */

/**
 * Desktop Class
 */
export default class Desktop extends EventEmitter {

  /**
   * Create Desktop
   *
   * @param {Core} core Core reference
   * @param {DesktopOptions} [options={}] Options
   */
  constructor(core, options = {}) {
    super('Desktop');

    /**
     * Core instance reference
     * @type {Core}
     * @readonly
     */
    this.core = core;

    /**
     * Desktop Options
     * @type {DeskopOptions}
     * @readonly
     */
    this.options = {
      contextmenu: [],
      ...options
    };

    /**
     * Theme DOM elements
     * @type {Element[]}
     */
    this.$theme = [];

    /**
     * Icon DOM elements
     * @type {Element[]}
     */
    this.$icons = [];

    /**
     * Default context menu entries
     * @type {DesktopContextMenuEntry[]}
     */
    this.contextmenuEntries = [];

    /**
     * Search instance
     * @type {Search|null}
     * @readonly
     */
    this.search = core.config('search.enabled') ? new Search(core, options.search || {}) : null;

    /**
     * Icon View instance
     * @type {DesktopIconView}
     * @readonly
     */
    this.iconview = new DesktopIconView(this.core);

    /**
     * Keyboard context dom element
     * @type {Element|null}
     */
    this.keyboardContext = null;

    /**
     * Desktop subtraction rectangle
     * TODO: typedef
     * @type {DesktopViewportRectangle}
     */
    this.subtract = {
      left: 0,
      top: 0,
      right: 0,
      bottom: 0
    };
  }

  /**
   * Destroy Desktop
   */
  destroy() {
    if (this.search) {
      this.search = this.search.destroy();
    }

    if (this.iconview) {
      this.iconview.destroy();
    }

    this._removeIcons();
    this._removeTheme();

    super.destroy();
  }

  /**
   * Initializes Desktop
   */
  init() {
    this.initConnectionEvents();
    this.initUIEvents();
    this.initDragEvents();
    this.initKeyboardEvents();
    this.initGlobalKeyboardEvents();
    this.initMouseEvents();
    this.initBaseEvents();
    this.initLocales();
    this.initDeveloperTray();
  }

  /**
   * Initializes connection events
   */
  initConnectionEvents() {
    this.core.on('osjs/core:disconnect', ev => {
      logger.warn('Connection closed', ev);

      const _ = this.core.make('osjs/locale').translate;
      this.core.make('osjs/notification', {
        title: _('LBL_CONNECTION_LOST'),
        message: _('LBL_CONNECTION_LOST_MESSAGE')
      });
    });

    this.core.on('osjs/core:connect', (ev, reconnected) => {
      logger.debug('Connection opened');

      if (reconnected) {
        const _ = this.core.make('osjs/locale').translate;
        this.core.make('osjs/notification', {
          title: _('LBL_CONNECTION_RESTORED'),
          message: _('LBL_CONNECTION_RESTORED_MESSAGE')
        });
      }
    });

    this.core.on('osjs/core:connection-failed', (ev) => {
      logger.warn('Connection failed');

      const _ = this.core.make('osjs/locale').translate;
      this.core.make('osjs/notification', {
        title: _('LBL_CONNECTION_FAILED'),
        message: _('LBL_CONNECTION_FAILED_MESSAGE')
      });
    });
  }

  /**
   * Initializes user interface events
   */
  initUIEvents() {
    this.core.on(['osjs/panel:create', 'osjs/panel:destroy'], (panel, panels = []) => {
      this.subtract = createPanelSubtraction(panel, panels);

      try {
        this._updateCSS();
        this._clampWindows();
      } catch (e) {
        logger.warn('Panel event error', e);
      }

      this.core.emit('osjs/desktop:transform', this.getRect());
    });

    this.core.on('osjs/window:transitionend', (...args) => {
      this.emit('theme:window:transitionend', ...args);
    });

    this.core.on('osjs/window:change', (...args) => {
      this.emit('theme:window:change', ...args);
    });
  }

  /**
   * Initializes development tray icons
   */
  initDeveloperTray() {
    if (!this.core.config('development')) {
      return;
    }

    // Creates tray
    const tray = this.core.make('osjs/tray').create({
      title: 'OS.js developer tools'
    }, (ev) => this.onDeveloperMenu(ev));

    this.core.on('destroy', () => tray.destroy());
  }

  /**
   * Initializes drag-and-drop events
   */
  initDragEvents() {
    const {droppable} = this.core.make('osjs/dnd');

    droppable(this.core.$contents, {
      strict: true,
      ondrop: (ev, data, files) => {
        const droppedImage = isDroppingImage(data);
        if (droppedImage) {
          this.onDropContextMenu(ev, data);
        }
      }
    });
  }

  /**
   * Initializes keyboard events
   */
  initKeyboardEvents() {
    const forwardKeyEvent = (n, e) => {
      const w = Window.lastWindow();
      if (isVisible(w)) {
        w.emit(n, e, w);
      }
    };

    const isWithinContext = (target) => this.keyboardContext &&
      this.keyboardContext.contains(target);

    const isWithinWindow = (w, target) => w &&
      w.$element.contains(target);

    const isWithin = (w, target) => isWithinWindow(w, target) ||
      isWithinContext(target);

    ['keydown', 'keyup', 'keypress'].forEach(n => {
      this.core.$root.addEventListener(n, e => forwardKeyEvent(n, e));
    });

    this.core.$root.addEventListener('keydown', e => {
      if (!e.target) {
        return;
      }

      if (e.keyCode === 114) { // F3
        e.preventDefault();

        if (this.search) {
          this.search.show();
        }
      } else if (e.keyCode === 9) { // Tab
        const {tagName} = e.target;
        const isInput = ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].indexOf(tagName) !== -1;
        const w = Window.lastWindow();

        if (isWithin(w, e.target)) {
          if (isInput) {
            if (tagName === 'TEXTAREA') {
              handleTabOnTextarea(e);
            }
          } else {
            e.preventDefault();
          }
        } else {
          e.preventDefault();
        }
      }
    });
  }

  /**
   * Initializes global keyboard events
   */
  initGlobalKeyboardEvents() {
    let keybindings = [];

    const defaults = this.core.config('desktop.settings.keybindings', {});

    const reload = () => {
      keybindings = this.core.make('osjs/settings')
        .get('osjs/desktop', 'keybindings', defaults);
    };

    window.addEventListener('keydown', ev => {
      Object.keys(keybindings).some(eventName => {
        const combo = keybindings[eventName];
        const result = matchKeyCombo(combo, ev);
        if (result) {
          this.core.emit('osjs/desktop:keybinding:' + eventName, ev);
        }
      });
    });

    this.core.on('osjs/settings:load', reload);
    this.core.on('osjs/settings:save', reload);
    this.core.on('osjs/core:started', reload);

    const closeBindingName = 'osjs/desktop:keybinding:close-window';
    const closeBindingCallback = () => {
      const w = Window.lastWindow();
      if (isVisible(w)) {
        w.close();
      }
    };
    this.core.on(closeBindingName, closeBindingCallback);
  }

  /**
   * Initializes mouse events
   */
  initMouseEvents() {
    // Custom context menu
    this.core.$contents.addEventListener('contextmenu', ev => {
      if (ev.target === this.core.$contents) {
        this.onContextMenu(ev);
      }
    });

    // A hook to prevent iframe events when dragging mouse
    window.addEventListener('mousedown', () => {
      let moved = false;

      const onmousemove = () => {
        if (!moved) {
          moved = true;

          this.core.$root.setAttribute('data-mousemove', String(true));
        }
      };

      const onmouseup = () => {
        moved = false;

        this.core.$root.setAttribute('data-mousemove', String(false));
        window.removeEventListener('mousemove', onmousemove);
        window.removeEventListener('mouseup', onmouseup);
      };

      window.addEventListener('mousemove', onmousemove);
      window.addEventListener('mouseup', onmouseup);
    });
  }

  /**
   * Initializes base events
   */
  initBaseEvents() {
    // Resize hook
    let resizeDebounce;
    window.addEventListener('resize', () => {
      clearTimeout(resizeDebounce);
      resizeDebounce = setTimeout(() => {
        this._updateCSS();
        this._clampWindows(true);
      }, 200);
    });

    // Prevent navigation
    history.pushState(null, null, document.URL);
    window.addEventListener('popstate', () => {
      history.pushState(null, null, document.URL);
    });

    // Prevents background scrolling on iOS
    this.core.$root.addEventListener('touchmove', e => e.preventDefault());
  }

  /**
   * Initializes locales
   */
  initLocales() {
    // Right-to-left support triggers
    const rtls = this.core.config('locale.rtl');
    const checkRTL = () => {
      const locale = this.core.make('osjs/locale')
        .getLocale()
        .split('_')[0]
        .toLowerCase();

      const isRtl = rtls.indexOf(locale) !== -1;
      this.core.$root.setAttribute('data-dir', isRtl ? 'rtl' : 'ltr');
    };
    this.core.on('osjs/settings:load', checkRTL);
    this.core.on('osjs/settings:save', checkRTL);
    this.core.on('osjs/core:started', checkRTL);
  }

  /**
   * Starts desktop services
   */
  start() {
    if (this.search) {
      this.search.init();
    }

    this._updateCSS();
  }

  /**
   * Update CSS
   * @private
   */
  _updateCSS() {
    const mobile = this.core.config('windows.mobile');
    const isMobile = !mobile ? false : this.core.$root.offsetWidth <= mobile;
    this.core.$root.setAttribute('data-mobile', isMobile);

    if (this.core.$contents) {
      this.core.$contents.style.top = `${this.subtract.top}px`;
      this.core.$contents.style.left = `${this.subtract.left}px`;
      this.core.$contents.style.right = `${this.subtract.right}px`;
      this.core.$contents.style.bottom = `${this.subtract.bottom}px`;
    }
  }

  _clampWindows(resize) {
    if (resize && !this.core.config('windows.clampToViewport')) {
      return;
    }

    Window.getWindows().forEach(w => w.clampToViewport());
  }

  /**
   * Adds something to the default contextmenu entries
   * @param {DesktopContextMenuEntry[]} entries
   */
  addContextMenu(entries) {
    this.contextmenuEntries = this.contextmenuEntries.concat(entries);
  }

  /**
   * Applies settings and updates desktop
   * @param {DesktopSettings} [settings] Use this set instead of loading from settings
   * @return {DesktopSettings} New settings
   */
  applySettings(settings) {
    const lockSettings = this.core.config('desktop.lock');
    const defaultSettings = this.core.config('desktop.settings');
    let newSettings;

    if (lockSettings) {
      newSettings = defaultSettings;
    } else {
      const userSettings = settings
        ? settings
        : this.core.make('osjs/settings').get('osjs/desktop');

      newSettings = merge(defaultSettings, userSettings, {
        arrayMerge: (dest, source) => source
      });
    }

    const applyOverlays = (test, list) => {
      if (this.core.has(test)) {
        const instance = this.core.make(test);
        instance.removeAll();
        list.forEach(item => instance.create(item));
      }
    };

    const applyCss = ({font, background}) => {
      this.core.$root.style.fontFamily = `${font}, sans-serif`;

      applyBackgroundStyles(this.core, background);

    };

    applyCss(newSettings);

    // TODO: Multiple panels
    applyOverlays('osjs/panels', (newSettings.panels || []).slice(-1));
    applyOverlays('osjs/widgets', newSettings.widgets);

    this.applyTheme(newSettings.theme);
    this.applyIcons(newSettings.icons);

    this.applyIconView(newSettings.iconview);

    this.core.emit('osjs/desktop:applySettings');

    return {...newSettings};
  }

  /**
   * Removes current style theme from DOM
   * @private
   */
  _removeTheme() {
    this.emit('theme:destroy');

    this.off([
      'theme:init',
      'theme:destroy',
      'theme:window:change',
      'theme:window:transitionend'
    ]);

    this.$theme.forEach(el => {
      if (el && el.parentNode) {
        el.remove();
      }
    });

    this.$theme = [];
  }

  /**
   * Removes current icon theme from DOM
   * @private
   */
  _removeIcons() {
    this.$icons.forEach(el => {
      if (el && el.parentNode) {
        el.remove();
      }
    });

    this.$icons = [];
  }

  /**
   * Adds or removes the icon view
   * @param {DesktopIconViewSettings} settings
   */
  applyIconView(settings) {
    if (!this.iconview) {
      return;
    }

    if (settings.enabled) {
      this.iconview.render(settings.path);
    } else {
      this.iconview.destroy();
    }
  }

  /**
   * Sets the current icon theme from settings
   * @param {string} name Icon theme name
   * @return {Promise<undefined>}
   */
  applyIcons(name) {
    name = name || this.core.config('desktop.icons');

    return this._applyTheme(name)
      .then(({elements, errors, callback, metadata}) => {
        this._removeIcons();

        this.$icons = Object.values(elements);

        this.emit('icons:init');
      });
  }

  /**
   * Sets the current style theme from settings
   * @param {string} name Theme name
   * @return {Promise<undefined>}
   */
  applyTheme(name) {
    name = name || this.core.config('desktop.theme');

    return this._applyTheme(name)
      .then(({elements, errors, callback, metadata}) => {
        this._removeTheme();

        if (callback && metadata) {
          try {
            callback(this.core, this, {}, metadata);
          } catch (e) {
            logger.warn('Exception while calling theme callback', e);
          }
        }

        this.$theme = Object.values(elements);

        this.emit('theme:init');
      });
  }

  /**
   * Apply theme wrapper
   * @private
   * @param {string} name Theme name
   * @return {Promise<undefined>}
   */
  _applyTheme(name) {
    return this.core.make('osjs/packages')
      .launch(name)
      .then(result => {
        if (result.errors.length) {
          logger.error(result.errors);
        }

        return result;
      });
  }

  /**
   * Apply settings by key
   * @private
   * @param {string} k Key
   * @param {*} v Value
   * @return {Promise<boolean>}
   */
  _applySettingsByKey(k, v) {
    return this.core.make('osjs/settings')
      .set('osjs/desktop', k, v)
      .save()
      .then(() => this.applySettings());
  }

  /**
   * Create drop context menu entries
   * @param {Object} data Drop data
   * @return {Object[]}
   */
  createDropContextMenu(data) {
    const _ = this.core.make('osjs/locale').translate;
    const settings = this.core.make('osjs/settings');
    const desktop = this.core.make('osjs/desktop');
    const droppedImage = isDroppingImage(data);
    const menu = [];

    const setWallpaper = () => settings
      .set('osjs/desktop', 'background.src', data)
      .save()
      .then(() => desktop.applySettings());

    if (droppedImage) {
      menu.push({
        label: _('LBL_DESKTOP_SET_AS_WALLPAPER'),
        onclick: setWallpaper
      });
    }

    return menu;
  }

  /**
   * When developer menu is shown
   * @param {Event} ev
   */
  onDeveloperMenu(ev) {
    const _ = this.core.make('osjs/locale').translate;
    const s = this.core.make('osjs/settings').get();

    const storageItems = Object.keys(s)
      .map(k => ({
        label: k,
        onclick: () => {
          this.core.make('osjs/settings')
            .clear(k)
            .then(() => this.applySettings());
        }
      }));

    this.core.make('osjs/contextmenu').show({
      position: ev,
      menu: [
        {
          label: _('LBL_KILL_ALL'),
          onclick: () => Application.destroyAll()
        },
        {
          label: _('LBL_APPLICATIONS'),
          items: Application.getApplications().map(proc => ({
            label: `${proc.metadata.name} (${proc.pid})`,
            items: [
              {
                label: _('LBL_KILL'),
                onclick: () => proc.destroy()
              },
              {
                label: _('LBL_RELOAD'),
                onclick: () => proc.relaunch()
              }
            ]
          }))
        },
        {
          label: 'Clear Storage',
          items: storageItems
        }
      ]
    });
  }

  /**
   * When drop menu is shown
   * @param {Event} ev
   * @param {Object} data
   */
  onDropContextMenu(ev, data) {
    const menu = this.createDropContextMenu(data);

    this.core.make('osjs/contextmenu', {
      position: ev,
      menu
    });
  }

  /**
   * When context menu is shown
   * @param {Event} ev
   */
  onContextMenu(ev) {
    const lockSettings = this.core.config('desktop.lock');
    const extras = [].concat(...this.contextmenuEntries.map(e => typeof e === 'function' ? e() : e));
    const config = this.core.config('desktop.contextmenu');
    const hasIconview = this.core.make('osjs/settings').get('osjs/desktop', 'iconview.enabled');

    if (config === false || config.enabled === false) {
      return;
    }

    const useDefaults = config === true || config.defaults; // NOTE: Backward compability

    const _ = this.core.make('osjs/locale').translate;
    const __ = this.core.make('osjs/locale').translatableFlat;

    const themes = this.core.make('osjs/packages')
      .getPackages(p => p.type === 'theme');

    const defaultItems = lockSettings ? [] : [{
      label: _('LBL_DESKTOP_SELECT_WALLPAPER'),
      onclick: () => {
        this.core.make('osjs/dialog', 'file', {
          mime: ['^image']
        }, (btn, file) => {
          if (btn === 'ok') {
            this._applySettingsByKey('background.src', file);
          }
        });
      }
    }, {
      label: _('LBL_DESKTOP_SELECT_THEME'),
      items: themes.map(t => ({
        label: __(t.title, t.name),
        onclick: () => {
          this._applySettingsByKey('theme', t.name);
        }
      }))
    }];

    if (hasIconview && this.iconview) {
      defaultItems.push({
        label: _('LBL_REFRESH'),
        onclick: () => this.iconview.iconview.reload()
      });
    }

    const base = useDefaults === 'function'
      ? config.defaults(this, defaultItems)
      : (useDefaults ? defaultItems : []);

    const provided = typeof this.options.contextmenu === 'function'
      ? this.options.contextmenu(this, defaultItems)
      : this.options.contextmenu || [];

    const menu = [
      ...base,
      ...provided,
      ...extras
    ];

    if (menu.length) {
      this.core.make('osjs/contextmenu').show({
        menu,
        position: ev
      });
    }
  }

  /**
   * Sets the keyboard context.
   *
   * Used for tabbing and other special events
   *
   * @param {Element} [ctx]
   */
  setKeyboardContext(ctx) {
    this.keyboardContext = ctx;
  }

  /**
   * Gets the rectangle of available space
   *
   * This is based on any panels etc taking up space
   *
   * @return {DesktopViewportRectangle}
   */
  getRect() {
    const root = this.core.$root;
    // FIXME: Is this now wrong because panels are not on the root anymore ?!
    const {left, top, right, bottom} = this.subtract;
    const width = root.offsetWidth - left - right;
    const height = root.offsetHeight - top - bottom;

    return {width, height, top, bottom, left, right};
  }
}