src/workspace-element.js

Summary

Maintainability
D
2 days
Test Coverage
'use strict';

const { ipcRenderer } = require('electron');
const path = require('path');
const fs = require('fs-plus');
const { CompositeDisposable, Disposable } = require('event-kit');
const scrollbarStyle = require('scrollbar-style');
const _ = require('underscore-plus');

class WorkspaceElement extends HTMLElement {
  connectedCallback() {
    this.focus();
    this.htmlElement = document.querySelector('html');
    this.htmlElement.addEventListener('mouseleave', this.handleCenterLeave);
  }

  disconnectedCallback() {
    this.subscriptions.dispose();
    this.htmlElement.removeEventListener('mouseleave', this.handleCenterLeave);
  }

  initializeContent() {
    this.classList.add('workspace');
    this.setAttribute('tabindex', -1);

    this.verticalAxis = document.createElement('atom-workspace-axis');
    this.verticalAxis.classList.add('vertical');

    this.horizontalAxis = document.createElement('atom-workspace-axis');
    this.horizontalAxis.classList.add('horizontal');
    this.horizontalAxis.appendChild(this.verticalAxis);

    this.appendChild(this.horizontalAxis);
  }

  observeScrollbarStyle() {
    this.subscriptions.add(
      scrollbarStyle.observePreferredScrollbarStyle(style => {
        switch (style) {
          case 'legacy':
            this.classList.remove('scrollbars-visible-when-scrolling');
            this.classList.add('scrollbars-visible-always');
            break;
          case 'overlay':
            this.classList.remove('scrollbars-visible-always');
            this.classList.add('scrollbars-visible-when-scrolling');
            break;
        }
      })
    );
  }

  observeTextEditorFontConfig() {
    this.updateGlobalTextEditorStyleSheet();
    this.subscriptions.add(
      this.config.onDidChange(
        'editor.fontSize',
        this.updateGlobalTextEditorStyleSheet.bind(this)
      )
    );
    this.subscriptions.add(
      this.config.onDidChange(
        'editor.fontFamily',
        this.updateGlobalTextEditorStyleSheet.bind(this)
      )
    );
    this.subscriptions.add(
      this.config.onDidChange(
        'editor.lineHeight',
        this.updateGlobalTextEditorStyleSheet.bind(this)
      )
    );
  }

  updateGlobalTextEditorStyleSheet() {
    const styleSheetSource = `atom-workspace {
  --editor-font-size: ${this.config.get('editor.fontSize')}px;
  --editor-font-family: ${this.config.get('editor.fontFamily')};
  --editor-line-height: ${this.config.get('editor.lineHeight')};
}`;
    this.styleManager.addStyleSheet(styleSheetSource, {
      sourcePath: 'global-text-editor-styles',
      priority: -1
    });
  }

  initialize(model, { config, project, styleManager, viewRegistry }) {
    this.handleCenterEnter = this.handleCenterEnter.bind(this);
    this.handleCenterLeave = this.handleCenterLeave.bind(this);
    this.handleEdgesMouseMove = _.throttle(
      this.handleEdgesMouseMove.bind(this),
      100
    );
    this.handleDockDragEnd = this.handleDockDragEnd.bind(this);
    this.handleDragStart = this.handleDragStart.bind(this);
    this.handleDragEnd = this.handleDragEnd.bind(this);
    this.handleDrop = this.handleDrop.bind(this);

    this.model = model;
    this.viewRegistry = viewRegistry;
    this.project = project;
    this.config = config;
    this.styleManager = styleManager;
    if (this.viewRegistry == null) {
      throw new Error(
        'Must pass a viewRegistry parameter when initializing WorkspaceElements'
      );
    }
    if (this.project == null) {
      throw new Error(
        'Must pass a project parameter when initializing WorkspaceElements'
      );
    }
    if (this.config == null) {
      throw new Error(
        'Must pass a config parameter when initializing WorkspaceElements'
      );
    }
    if (this.styleManager == null) {
      throw new Error(
        'Must pass a styleManager parameter when initializing WorkspaceElements'
      );
    }

    this.subscriptions = new CompositeDisposable(
      new Disposable(() => {
        this.paneContainer.removeEventListener(
          'mouseenter',
          this.handleCenterEnter
        );
        this.paneContainer.removeEventListener(
          'mouseleave',
          this.handleCenterLeave
        );
        window.removeEventListener('mousemove', this.handleEdgesMouseMove);
        window.removeEventListener('dragend', this.handleDockDragEnd);
        window.removeEventListener('dragstart', this.handleDragStart);
        window.removeEventListener('dragend', this.handleDragEnd, true);
        window.removeEventListener('drop', this.handleDrop, true);
      }),
      ...[
        this.model.getLeftDock(),
        this.model.getRightDock(),
        this.model.getBottomDock()
      ].map(dock =>
        dock.onDidChangeHovered(hovered => {
          if (hovered) this.hoveredDock = dock;
          else if (dock === this.hoveredDock) this.hoveredDock = null;
          this.checkCleanupDockHoverEvents();
        })
      )
    );
    this.initializeContent();
    this.observeScrollbarStyle();
    this.observeTextEditorFontConfig();

    this.paneContainer = this.model.getCenter().paneContainer.getElement();
    this.verticalAxis.appendChild(this.paneContainer);
    this.addEventListener('focus', this.handleFocus.bind(this));

    this.addEventListener('mousewheel', this.handleMousewheel.bind(this), {
      capture: true
    });
    window.addEventListener('dragstart', this.handleDragStart);
    window.addEventListener('mousemove', this.handleEdgesMouseMove);

    this.panelContainers = {
      top: this.model.panelContainers.top.getElement(),
      left: this.model.panelContainers.left.getElement(),
      right: this.model.panelContainers.right.getElement(),
      bottom: this.model.panelContainers.bottom.getElement(),
      header: this.model.panelContainers.header.getElement(),
      footer: this.model.panelContainers.footer.getElement(),
      modal: this.model.panelContainers.modal.getElement()
    };

    this.horizontalAxis.insertBefore(
      this.panelContainers.left,
      this.verticalAxis
    );
    this.horizontalAxis.appendChild(this.panelContainers.right);

    this.verticalAxis.insertBefore(
      this.panelContainers.top,
      this.paneContainer
    );
    this.verticalAxis.appendChild(this.panelContainers.bottom);

    this.insertBefore(this.panelContainers.header, this.horizontalAxis);
    this.appendChild(this.panelContainers.footer);

    this.appendChild(this.panelContainers.modal);

    this.paneContainer.addEventListener('mouseenter', this.handleCenterEnter);
    this.paneContainer.addEventListener('mouseleave', this.handleCenterLeave);

    return this;
  }

  destroy() {
    this.subscriptions.dispose();
  }

  getModel() {
    return this.model;
  }

  handleDragStart(event) {
    if (!isTab(event.target)) return;
    const { item } = event.target;
    if (!item) return;
    this.model.setDraggingItem(item);
    window.addEventListener('dragend', this.handleDragEnd, { capture: true });
    window.addEventListener('drop', this.handleDrop, { capture: true });
  }

  handleDragEnd(event) {
    this.dragEnded();
  }

  handleDrop(event) {
    this.dragEnded();
  }

  dragEnded() {
    this.model.setDraggingItem(null);
    window.removeEventListener('dragend', this.handleDragEnd, true);
    window.removeEventListener('drop', this.handleDrop, true);
  }

  handleCenterEnter(event) {
    // Just re-entering the center isn't enough to hide the dock toggle buttons, since they poke
    // into the center and we want to give an affordance.
    this.cursorInCenter = true;
    this.checkCleanupDockHoverEvents();
  }

  handleCenterLeave(event) {
    // If the cursor leaves the center, we start listening to determine whether one of the docs is
    // being hovered.
    this.cursorInCenter = false;
    this.updateHoveredDock({ x: event.pageX, y: event.pageY });
    window.addEventListener('dragend', this.handleDockDragEnd);
  }

  handleEdgesMouseMove(event) {
    this.updateHoveredDock({ x: event.pageX, y: event.pageY });
  }

  handleDockDragEnd(event) {
    this.updateHoveredDock({ x: event.pageX, y: event.pageY });
  }

  updateHoveredDock(mousePosition) {
    // If we haven't left the currently hovered dock, don't change anything.
    if (
      this.hoveredDock &&
      this.hoveredDock.pointWithinHoverArea(mousePosition, true)
    )
      return;

    const docks = [
      this.model.getLeftDock(),
      this.model.getRightDock(),
      this.model.getBottomDock()
    ];
    const nextHoveredDock = docks.find(
      dock =>
        dock !== this.hoveredDock && dock.pointWithinHoverArea(mousePosition)
    );
    docks.forEach(dock => {
      dock.setHovered(dock === nextHoveredDock);
    });
  }

  checkCleanupDockHoverEvents() {
    if (this.cursorInCenter && !this.hoveredDock) {
      window.removeEventListener('dragend', this.handleDockDragEnd);
    }
  }

  handleMousewheel(event) {
    if (
      event.ctrlKey &&
      this.config.get('editor.zoomFontWhenCtrlScrolling') &&
      event.target.closest('atom-text-editor') != null
    ) {
      if (event.wheelDeltaY > 0) {
        this.model.increaseFontSize();
      } else if (event.wheelDeltaY < 0) {
        this.model.decreaseFontSize();
      }
      event.preventDefault();
      event.stopPropagation();
    }
  }

  handleFocus(event) {
    this.model.getActivePane().activate();
  }

  focusPaneViewAbove() {
    this.focusPaneViewInDirection('above');
  }

  focusPaneViewBelow() {
    this.focusPaneViewInDirection('below');
  }

  focusPaneViewOnLeft() {
    this.focusPaneViewInDirection('left');
  }

  focusPaneViewOnRight() {
    this.focusPaneViewInDirection('right');
  }

  focusPaneViewInDirection(direction, pane) {
    const activePane = this.model.getActivePane();
    const paneToFocus = this.nearestVisiblePaneInDirection(
      direction,
      activePane
    );
    paneToFocus && paneToFocus.focus();
  }

  moveActiveItemToPaneAbove(params) {
    this.moveActiveItemToNearestPaneInDirection('above', params);
  }

  moveActiveItemToPaneBelow(params) {
    this.moveActiveItemToNearestPaneInDirection('below', params);
  }

  moveActiveItemToPaneOnLeft(params) {
    this.moveActiveItemToNearestPaneInDirection('left', params);
  }

  moveActiveItemToPaneOnRight(params) {
    this.moveActiveItemToNearestPaneInDirection('right', params);
  }

  moveActiveItemToNearestPaneInDirection(direction, params) {
    const activePane = this.model.getActivePane();
    const nearestPaneView = this.nearestVisiblePaneInDirection(
      direction,
      activePane
    );
    if (nearestPaneView == null) {
      return;
    }
    if (params && params.keepOriginal) {
      activePane
        .getContainer()
        .copyActiveItemToPane(nearestPaneView.getModel());
    } else {
      activePane
        .getContainer()
        .moveActiveItemToPane(nearestPaneView.getModel());
    }
    nearestPaneView.focus();
  }

  nearestVisiblePaneInDirection(direction, pane) {
    const distance = function(pointA, pointB) {
      const x = pointB.x - pointA.x;
      const y = pointB.y - pointA.y;
      return Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));
    };

    const paneView = pane.getElement();
    const box = this.boundingBoxForPaneView(paneView);

    const paneViews = atom.workspace
      .getVisiblePanes()
      .map(otherPane => otherPane.getElement())
      .filter(otherPaneView => {
        const otherBox = this.boundingBoxForPaneView(otherPaneView);
        switch (direction) {
          case 'left':
            return otherBox.right.x <= box.left.x;
          case 'right':
            return otherBox.left.x >= box.right.x;
          case 'above':
            return otherBox.bottom.y <= box.top.y;
          case 'below':
            return otherBox.top.y >= box.bottom.y;
        }
      })
      .sort((paneViewA, paneViewB) => {
        const boxA = this.boundingBoxForPaneView(paneViewA);
        const boxB = this.boundingBoxForPaneView(paneViewB);
        switch (direction) {
          case 'left':
            return (
              distance(box.left, boxA.right) - distance(box.left, boxB.right)
            );
          case 'right':
            return (
              distance(box.right, boxA.left) - distance(box.right, boxB.left)
            );
          case 'above':
            return (
              distance(box.top, boxA.bottom) - distance(box.top, boxB.bottom)
            );
          case 'below':
            return (
              distance(box.bottom, boxA.top) - distance(box.bottom, boxB.top)
            );
        }
      });

    return paneViews[0];
  }

  boundingBoxForPaneView(paneView) {
    const boundingBox = paneView.getBoundingClientRect();

    return {
      left: { x: boundingBox.left, y: boundingBox.top },
      right: { x: boundingBox.right, y: boundingBox.top },
      top: { x: boundingBox.left, y: boundingBox.top },
      bottom: { x: boundingBox.left, y: boundingBox.bottom }
    };
  }

  runPackageSpecs(options = {}) {
    const activePaneItem = this.model.getActivePaneItem();
    const activePath =
      activePaneItem && typeof activePaneItem.getPath === 'function'
        ? activePaneItem.getPath()
        : null;
    let projectPath;
    if (activePath != null) {
      [projectPath] = this.project.relativizePath(activePath);
    } else {
      [projectPath] = this.project.getPaths();
    }
    if (projectPath) {
      let specPath = path.join(projectPath, 'spec');
      const testPath = path.join(projectPath, 'test');
      if (!fs.existsSync(specPath) && fs.existsSync(testPath)) {
        specPath = testPath;
      }

      ipcRenderer.send('run-package-specs', specPath, options);
    }
  }

  runBenchmarks() {
    const activePaneItem = this.model.getActivePaneItem();
    const activePath =
      activePaneItem && typeof activePaneItem.getPath === 'function'
        ? activePaneItem.getPath()
        : null;
    let projectPath;
    if (activePath) {
      [projectPath] = this.project.relativizePath(activePath);
    } else {
      [projectPath] = this.project.getPaths();
    }

    if (projectPath) {
      ipcRenderer.send('run-benchmarks', path.join(projectPath, 'benchmarks'));
    }
  }
}

function isTab(element) {
  let el = element;
  while (el != null) {
    if (el.getAttribute && el.getAttribute('is') === 'tabs-tab') return true;
    el = el.parentElement;
  }
  return false;
}

window.customElements.define('atom-workspace', WorkspaceElement);

function createWorkspaceElement() {
  return document.createElement('atom-workspace');
}

module.exports = {
  createWorkspaceElement
};