src/dock.js

Summary

Maintainability
F
4 days
Test Coverage
const etch = require('etch');
const _ = require('underscore-plus');
const { CompositeDisposable, Emitter } = require('event-kit');
const PaneContainer = require('./pane-container');
const TextEditor = require('./text-editor');
const Grim = require('grim');

const $ = etch.dom;
const MINIMUM_SIZE = 100;
const DEFAULT_INITIAL_SIZE = 300;
const SHOULD_ANIMATE_CLASS = 'atom-dock-should-animate';
const VISIBLE_CLASS = 'atom-dock-open';
const RESIZE_HANDLE_RESIZABLE_CLASS = 'atom-dock-resize-handle-resizable';
const TOGGLE_BUTTON_VISIBLE_CLASS = 'atom-dock-toggle-button-visible';
const CURSOR_OVERLAY_VISIBLE_CLASS = 'atom-dock-cursor-overlay-visible';

// Extended: A container at the edges of the editor window capable of holding items.
// You should not create a Dock directly. Instead, access one of the three docks of the workspace
// via {Workspace::getLeftDock}, {Workspace::getRightDock}, and {Workspace::getBottomDock}
// or add an item to a dock via {Workspace::open}.
module.exports = class Dock {
  constructor(params) {
    this.handleResizeHandleDragStart = this.handleResizeHandleDragStart.bind(
      this
    );
    this.handleResizeToFit = this.handleResizeToFit.bind(this);
    this.handleMouseMove = this.handleMouseMove.bind(this);
    this.handleMouseUp = this.handleMouseUp.bind(this);
    this.handleDrag = _.throttle(this.handleDrag.bind(this), 30);
    this.handleDragEnd = this.handleDragEnd.bind(this);
    this.handleToggleButtonDragEnter = this.handleToggleButtonDragEnter.bind(
      this
    );
    this.toggle = this.toggle.bind(this);

    this.location = params.location;
    this.widthOrHeight = getWidthOrHeight(this.location);
    this.config = params.config;
    this.applicationDelegate = params.applicationDelegate;
    this.deserializerManager = params.deserializerManager;
    this.notificationManager = params.notificationManager;
    this.viewRegistry = params.viewRegistry;
    this.didActivate = params.didActivate;

    this.emitter = new Emitter();

    this.paneContainer = new PaneContainer({
      location: this.location,
      config: this.config,
      applicationDelegate: this.applicationDelegate,
      deserializerManager: this.deserializerManager,
      notificationManager: this.notificationManager,
      viewRegistry: this.viewRegistry
    });

    this.state = {
      size: null,
      visible: false,
      shouldAnimate: false
    };

    this.subscriptions = new CompositeDisposable(
      this.emitter,
      this.paneContainer.onDidActivatePane(() => {
        this.show();
        this.didActivate(this);
      }),
      this.paneContainer.observePanes(pane => {
        pane.onDidAddItem(this.handleDidAddPaneItem.bind(this));
        pane.onDidRemoveItem(this.handleDidRemovePaneItem.bind(this));
      }),
      this.paneContainer.onDidChangeActivePane(item =>
        params.didChangeActivePane(this, item)
      ),
      this.paneContainer.onDidChangeActivePaneItem(item =>
        params.didChangeActivePaneItem(this, item)
      ),
      this.paneContainer.onDidDestroyPaneItem(item =>
        params.didDestroyPaneItem(item)
      )
    );
  }

  // This method is called explicitly by the object which adds the Dock to the document.
  elementAttached() {
    // Re-render when the dock is attached to make sure we remeasure sizes defined in CSS.
    etch.updateSync(this);
  }

  getElement() {
    // Because this code is included in the snapshot, we have to make sure we don't touch the DOM
    // during initialization. Therefore, we defer initialization of the component (which creates a
    // DOM element) until somebody asks for the element.
    if (this.element == null) {
      etch.initialize(this);
    }
    return this.element;
  }

  getLocation() {
    return this.location;
  }

  destroy() {
    this.subscriptions.dispose();
    this.paneContainer.destroy();
    window.removeEventListener('mousemove', this.handleMouseMove);
    window.removeEventListener('mouseup', this.handleMouseUp);
    window.removeEventListener('drag', this.handleDrag);
    window.removeEventListener('dragend', this.handleDragEnd);
  }

  setHovered(hovered) {
    if (hovered === this.state.hovered) return;
    this.setState({ hovered });
  }

  setDraggingItem(draggingItem) {
    if (draggingItem === this.state.draggingItem) return;
    this.setState({ draggingItem });
  }

  // Extended: Show the dock and focus its active {Pane}.
  activate() {
    this.getActivePane().activate();
  }

  // Extended: Show the dock without focusing it.
  show() {
    this.setState({ visible: true });
  }

  // Extended: Hide the dock and activate the {WorkspaceCenter} if the dock was
  // was previously focused.
  hide() {
    this.setState({ visible: false });
  }

  // Extended: Toggle the dock's visibility without changing the {Workspace}'s
  // active pane container.
  toggle() {
    const state = { visible: !this.state.visible };
    if (!state.visible) state.hovered = false;
    this.setState(state);
  }

  // Extended: Check if the dock is visible.
  //
  // Returns a {Boolean}.
  isVisible() {
    return this.state.visible;
  }

  setState(newState) {
    const prevState = this.state;
    const nextState = Object.assign({}, prevState, newState);

    // Update the `shouldAnimate` state. This needs to be written to the DOM before updating the
    // class that changes the animated property. Normally we'd have to defer the class change a
    // frame to ensure the property is animated (or not) appropriately, however we luck out in this
    // case because the drag start always happens before the item is dragged into the toggle button.
    if (nextState.visible !== prevState.visible) {
      // Never animate toggling visibility...
      nextState.shouldAnimate = false;
    } else if (
      !nextState.visible &&
      nextState.draggingItem &&
      !prevState.draggingItem
    ) {
      // ...but do animate if you start dragging while the panel is hidden.
      nextState.shouldAnimate = true;
    }

    this.state = nextState;

    const { hovered, visible } = this.state;

    // Render immediately if the dock becomes visible or the size changes in case people are
    // measuring after opening, for example.
    if (this.element != null) {
      if ((visible && !prevState.visible) || this.state.size !== prevState.size)
        etch.updateSync(this);
      else etch.update(this);
    }

    if (hovered !== prevState.hovered) {
      this.emitter.emit('did-change-hovered', hovered);
    }
    if (visible !== prevState.visible) {
      this.emitter.emit('did-change-visible', visible);
    }
  }

  render() {
    const innerElementClassList = ['atom-dock-inner', this.location];
    if (this.state.visible) innerElementClassList.push(VISIBLE_CLASS);

    const maskElementClassList = ['atom-dock-mask'];
    if (this.state.shouldAnimate)
      maskElementClassList.push(SHOULD_ANIMATE_CLASS);

    const cursorOverlayElementClassList = [
      'atom-dock-cursor-overlay',
      this.location
    ];
    if (this.state.resizing)
      cursorOverlayElementClassList.push(CURSOR_OVERLAY_VISIBLE_CLASS);

    const shouldBeVisible = this.state.visible || this.state.showDropTarget;
    const size = Math.max(
      MINIMUM_SIZE,
      this.state.size ||
        (this.state.draggingItem &&
          getPreferredSize(this.state.draggingItem, this.location)) ||
        DEFAULT_INITIAL_SIZE
    );

    // We need to change the size of the mask...
    const maskStyle = {
      [this.widthOrHeight]: `${shouldBeVisible ? size : 0}px`
    };
    // ...but the content needs to maintain a constant size.
    const wrapperStyle = { [this.widthOrHeight]: `${size}px` };

    return $(
      'atom-dock',
      { className: this.location },
      $.div(
        { ref: 'innerElement', className: innerElementClassList.join(' ') },
        $.div(
          {
            className: maskElementClassList.join(' '),
            style: maskStyle
          },
          $.div(
            {
              ref: 'wrapperElement',
              className: `atom-dock-content-wrapper ${this.location}`,
              style: wrapperStyle
            },
            $(DockResizeHandle, {
              location: this.location,
              onResizeStart: this.handleResizeHandleDragStart,
              onResizeToFit: this.handleResizeToFit,
              dockIsVisible: this.state.visible
            }),
            $(ElementComponent, { element: this.paneContainer.getElement() }),
            $.div({ className: cursorOverlayElementClassList.join(' ') })
          )
        ),
        $(DockToggleButton, {
          ref: 'toggleButton',
          onDragEnter: this.state.draggingItem
            ? this.handleToggleButtonDragEnter
            : null,
          location: this.location,
          toggle: this.toggle,
          dockIsVisible: shouldBeVisible,
          visible:
            // Don't show the toggle button if the dock is closed and empty...
            (this.state.hovered &&
              (this.state.visible || this.getPaneItems().length > 0)) ||
            // ...or if the item can't be dropped in that dock.
            (!shouldBeVisible &&
              this.state.draggingItem &&
              isItemAllowed(this.state.draggingItem, this.location))
        })
      )
    );
  }

  update(props) {
    // Since we're interopping with non-etch stuff, this method's actually never called.
    return etch.update(this);
  }

  handleDidAddPaneItem() {
    if (this.state.size == null) {
      this.setState({ size: this.getInitialSize() });
    }
  }

  handleDidRemovePaneItem() {
    // Hide the dock if you remove the last item.
    if (this.paneContainer.getPaneItems().length === 0) {
      this.setState({ visible: false, hovered: false, size: null });
    }
  }

  handleResizeHandleDragStart() {
    window.addEventListener('mousemove', this.handleMouseMove);
    window.addEventListener('mouseup', this.handleMouseUp);
    this.setState({ resizing: true });
  }

  handleResizeToFit() {
    const item = this.getActivePaneItem();
    if (item) {
      const size = getPreferredSize(item, this.getLocation());
      if (size != null) this.setState({ size });
    }
  }

  handleMouseMove(event) {
    if (event.buttons === 0) {
      // We missed the mouseup event. For some reason it happens on Windows
      this.handleMouseUp(event);
      return;
    }

    let size = 0;
    switch (this.location) {
      case 'left':
        size = event.pageX - this.element.getBoundingClientRect().left;
        break;
      case 'bottom':
        size = this.element.getBoundingClientRect().bottom - event.pageY;
        break;
      case 'right':
        size = this.element.getBoundingClientRect().right - event.pageX;
        break;
    }
    this.setState({ size });
  }

  handleMouseUp(event) {
    window.removeEventListener('mousemove', this.handleMouseMove);
    window.removeEventListener('mouseup', this.handleMouseUp);
    this.setState({ resizing: false });
  }

  handleToggleButtonDragEnter() {
    this.setState({ showDropTarget: true });
    window.addEventListener('drag', this.handleDrag);
    window.addEventListener('dragend', this.handleDragEnd);
  }

  handleDrag(event) {
    if (!this.pointWithinHoverArea({ x: event.pageX, y: event.pageY }, true)) {
      this.draggedOut();
    }
  }

  handleDragEnd() {
    this.draggedOut();
  }

  draggedOut() {
    this.setState({ showDropTarget: false });
    window.removeEventListener('drag', this.handleDrag);
    window.removeEventListener('dragend', this.handleDragEnd);
  }

  // Determine whether the cursor is within the dock hover area. This isn't as simple as just using
  // mouseenter/leave because we want to be a little more forgiving. For example, if the cursor is
  // over the footer, we want to show the bottom dock's toggle button. Also note that our criteria
  // for detecting entry are different than detecting exit but, in order for us to avoid jitter, the
  // area considered when detecting exit MUST fully encompass the area considered when detecting
  // entry.
  pointWithinHoverArea(point, detectingExit) {
    const dockBounds = this.refs.innerElement.getBoundingClientRect();

    // Copy the bounds object since we can't mutate it.
    const bounds = {
      top: dockBounds.top,
      right: dockBounds.right,
      bottom: dockBounds.bottom,
      left: dockBounds.left
    };

    // To provide a minimum target, expand the area toward the center a bit.
    switch (this.location) {
      case 'right':
        bounds.left = Math.min(bounds.left, bounds.right - 2);
        break;
      case 'bottom':
        bounds.top = Math.min(bounds.top, bounds.bottom - 1);
        break;
      case 'left':
        bounds.right = Math.max(bounds.right, bounds.left + 2);
        break;
    }

    // Further expand the area to include all panels that are closer to the edge than the dock.
    switch (this.location) {
      case 'right':
        bounds.right = Number.POSITIVE_INFINITY;
        break;
      case 'bottom':
        bounds.bottom = Number.POSITIVE_INFINITY;
        break;
      case 'left':
        bounds.left = Number.NEGATIVE_INFINITY;
        break;
    }

    // If we're in this area, we know we're within the hover area without having to take further
    // measurements.
    if (rectContainsPoint(bounds, point)) return true;

    // If we're within the toggle button, we're definitely in the hover area. Unfortunately, we
    // can't do this measurement conditionally (e.g. only if the toggle button is visible) because
    // our knowledge of the toggle's button is incomplete due to CSS animations. (We may think the
    // toggle button isn't visible when in actuality it is, but is animating to its hidden state.)
    //
    // Since `point` is always the current mouse position, one possible optimization would be to
    // remove it as an argument and determine whether we're inside the toggle button using
    // mouseenter/leave events on it. This class would still need to keep track of the mouse
    // position (via a mousemove listener) for the other measurements, though.
    const toggleButtonBounds = this.refs.toggleButton.getBounds();
    if (rectContainsPoint(toggleButtonBounds, point)) return true;

    // The area used when detecting exit is actually larger than when detecting entrances. Expand
    // our bounds and recheck them.
    if (detectingExit) {
      const hoverMargin = 20;
      switch (this.location) {
        case 'right':
          bounds.left =
            Math.min(bounds.left, toggleButtonBounds.left) - hoverMargin;
          break;
        case 'bottom':
          bounds.top =
            Math.min(bounds.top, toggleButtonBounds.top) - hoverMargin;
          break;
        case 'left':
          bounds.right =
            Math.max(bounds.right, toggleButtonBounds.right) + hoverMargin;
          break;
      }
      if (rectContainsPoint(bounds, point)) return true;
    }

    return false;
  }

  getInitialSize() {
    // The item may not have been activated yet. If that's the case, just use the first item.
    const activePaneItem =
      this.paneContainer.getActivePaneItem() ||
      this.paneContainer.getPaneItems()[0];
    // If there are items, we should have an explicit width; if not, we shouldn't.
    return activePaneItem
      ? getPreferredSize(activePaneItem, this.location) || DEFAULT_INITIAL_SIZE
      : null;
  }

  serialize() {
    return {
      deserializer: 'Dock',
      size: this.state.size,
      paneContainer: this.paneContainer.serialize(),
      visible: this.state.visible
    };
  }

  deserialize(serialized, deserializerManager) {
    this.paneContainer.deserialize(
      serialized.paneContainer,
      deserializerManager
    );
    this.setState({
      size: serialized.size || this.getInitialSize(),
      // If no items could be deserialized, we don't want to show the dock (even if it was visible last time)
      visible:
        serialized.visible && this.paneContainer.getPaneItems().length > 0
    });
  }

  /*
  Section: Event Subscription
  */

  // Essential: Invoke the given callback when the visibility of the dock changes.
  //
  // * `callback` {Function} to be called when the visibility changes.
  //   * `visible` {Boolean} Is the dock now visible?
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidChangeVisible(callback) {
    return this.emitter.on('did-change-visible', callback);
  }

  // Essential: Invoke the given callback with the current and all future visibilities of the dock.
  //
  // * `callback` {Function} to be called when the visibility changes.
  //   * `visible` {Boolean} Is the dock now visible?
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  observeVisible(callback) {
    callback(this.isVisible());
    return this.onDidChangeVisible(callback);
  }

  // Essential: Invoke the given callback with all current and future panes items
  // in the dock.
  //
  // * `callback` {Function} to be called with current and future pane items.
  //   * `item` An item that is present in {::getPaneItems} at the time of
  //      subscription or that is added at some later time.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  observePaneItems(callback) {
    return this.paneContainer.observePaneItems(callback);
  }

  // Essential: Invoke the given callback when the active pane item changes.
  //
  // Because observers are invoked synchronously, it's important not to perform
  // any expensive operations via this method. Consider
  // {::onDidStopChangingActivePaneItem} to delay operations until after changes
  // stop occurring.
  //
  // * `callback` {Function} to be called when the active pane item changes.
  //   * `item` The active pane item.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidChangeActivePaneItem(callback) {
    return this.paneContainer.onDidChangeActivePaneItem(callback);
  }

  // Essential: Invoke the given callback when the active pane item stops
  // changing.
  //
  // Observers are called asynchronously 100ms after the last active pane item
  // change. Handling changes here rather than in the synchronous
  // {::onDidChangeActivePaneItem} prevents unneeded work if the user is quickly
  // changing or closing tabs and ensures critical UI feedback, like changing the
  // highlighted tab, gets priority over work that can be done asynchronously.
  //
  // * `callback` {Function} to be called when the active pane item stopts
  //   changing.
  //   * `item` The active pane item.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidStopChangingActivePaneItem(callback) {
    return this.paneContainer.onDidStopChangingActivePaneItem(callback);
  }

  // Essential: Invoke the given callback with the current active pane item and
  // with all future active pane items in the dock.
  //
  // * `callback` {Function} to be called when the active pane item changes.
  //   * `item` The current active pane item.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  observeActivePaneItem(callback) {
    return this.paneContainer.observeActivePaneItem(callback);
  }

  // Extended: Invoke the given callback when a pane is added to the dock.
  //
  // * `callback` {Function} to be called panes are added.
  //   * `event` {Object} with the following keys:
  //     * `pane` The added pane.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidAddPane(callback) {
    return this.paneContainer.onDidAddPane(callback);
  }

  // Extended: Invoke the given callback before a pane is destroyed in the
  // dock.
  //
  // * `callback` {Function} to be called before panes are destroyed.
  //   * `event` {Object} with the following keys:
  //     * `pane` The pane to be destroyed.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onWillDestroyPane(callback) {
    return this.paneContainer.onWillDestroyPane(callback);
  }

  // Extended: Invoke the given callback when a pane is destroyed in the dock.
  //
  // * `callback` {Function} to be called panes are destroyed.
  //   * `event` {Object} with the following keys:
  //     * `pane` The destroyed pane.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidDestroyPane(callback) {
    return this.paneContainer.onDidDestroyPane(callback);
  }

  // Extended: Invoke the given callback with all current and future panes in the
  // dock.
  //
  // * `callback` {Function} to be called with current and future panes.
  //   * `pane` A {Pane} that is present in {::getPanes} at the time of
  //      subscription or that is added at some later time.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  observePanes(callback) {
    return this.paneContainer.observePanes(callback);
  }

  // Extended: Invoke the given callback when the active pane changes.
  //
  // * `callback` {Function} to be called when the active pane changes.
  //   * `pane` A {Pane} that is the current return value of {::getActivePane}.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidChangeActivePane(callback) {
    return this.paneContainer.onDidChangeActivePane(callback);
  }

  // Extended: Invoke the given callback with the current active pane and when
  // the active pane changes.
  //
  // * `callback` {Function} to be called with the current and future active#
  //   panes.
  //   * `pane` A {Pane} that is the current return value of {::getActivePane}.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  observeActivePane(callback) {
    return this.paneContainer.observeActivePane(callback);
  }

  // Extended: Invoke the given callback when a pane item is added to the dock.
  //
  // * `callback` {Function} to be called when pane items are added.
  //   * `event` {Object} with the following keys:
  //     * `item` The added pane item.
  //     * `pane` {Pane} containing the added item.
  //     * `index` {Number} indicating the index of the added item in its pane.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidAddPaneItem(callback) {
    return this.paneContainer.onDidAddPaneItem(callback);
  }

  // Extended: Invoke the given callback when a pane item is about to be
  // destroyed, before the user is prompted to save it.
  //
  // * `callback` {Function} to be called before pane items are destroyed.
  //   * `event` {Object} with the following keys:
  //     * `item` The item to be destroyed.
  //     * `pane` {Pane} containing the item to be destroyed.
  //     * `index` {Number} indicating the index of the item to be destroyed in
  //       its pane.
  //
  // Returns a {Disposable} on which `.dispose` can be called to unsubscribe.
  onWillDestroyPaneItem(callback) {
    return this.paneContainer.onWillDestroyPaneItem(callback);
  }

  // Extended: Invoke the given callback when a pane item is destroyed.
  //
  // * `callback` {Function} to be called when pane items are destroyed.
  //   * `event` {Object} with the following keys:
  //     * `item` The destroyed item.
  //     * `pane` {Pane} containing the destroyed item.
  //     * `index` {Number} indicating the index of the destroyed item in its
  //       pane.
  //
  // Returns a {Disposable} on which `.dispose` can be called to unsubscribe.
  onDidDestroyPaneItem(callback) {
    return this.paneContainer.onDidDestroyPaneItem(callback);
  }

  // Extended: Invoke the given callback when the hovered state of the dock changes.
  //
  // * `callback` {Function} to be called when the hovered state changes.
  //   * `hovered` {Boolean} Is the dock now hovered?
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidChangeHovered(callback) {
    return this.emitter.on('did-change-hovered', callback);
  }

  /*
  Section: Pane Items
  */

  // Essential: Get all pane items in the dock.
  //
  // Returns an {Array} of items.
  getPaneItems() {
    return this.paneContainer.getPaneItems();
  }

  // Essential: Get the active {Pane}'s active item.
  //
  // Returns an pane item {Object}.
  getActivePaneItem() {
    return this.paneContainer.getActivePaneItem();
  }

  // Deprecated: Get the active item if it is a {TextEditor}.
  //
  // Returns a {TextEditor} or `undefined` if the current active item is not a
  // {TextEditor}.
  getActiveTextEditor() {
    Grim.deprecate(
      'Text editors are not allowed in docks. Use atom.workspace.getActiveTextEditor() instead.'
    );

    const activeItem = this.getActivePaneItem();
    if (activeItem instanceof TextEditor) {
      return activeItem;
    }
  }

  // Save all pane items.
  saveAll() {
    this.paneContainer.saveAll();
  }

  confirmClose(options) {
    return this.paneContainer.confirmClose(options);
  }

  /*
  Section: Panes
  */

  // Extended: Get all panes in the dock.
  //
  // Returns an {Array} of {Pane}s.
  getPanes() {
    return this.paneContainer.getPanes();
  }

  // Extended: Get the active {Pane}.
  //
  // Returns a {Pane}.
  getActivePane() {
    return this.paneContainer.getActivePane();
  }

  // Extended: Make the next pane active.
  activateNextPane() {
    return this.paneContainer.activateNextPane();
  }

  // Extended: Make the previous pane active.
  activatePreviousPane() {
    return this.paneContainer.activatePreviousPane();
  }

  paneForURI(uri) {
    return this.paneContainer.paneForURI(uri);
  }

  paneForItem(item) {
    return this.paneContainer.paneForItem(item);
  }

  // Destroy (close) the active pane.
  destroyActivePane() {
    const activePane = this.getActivePane();
    if (activePane != null) {
      activePane.destroy();
    }
  }
};

class DockResizeHandle {
  constructor(props) {
    this.props = props;
    etch.initialize(this);
  }

  render() {
    const classList = ['atom-dock-resize-handle', this.props.location];
    if (this.props.dockIsVisible) classList.push(RESIZE_HANDLE_RESIZABLE_CLASS);

    return $.div({
      className: classList.join(' '),
      on: { mousedown: this.handleMouseDown }
    });
  }

  getElement() {
    return this.element;
  }

  getSize() {
    if (!this.size) {
      this.size = this.element.getBoundingClientRect()[
        getWidthOrHeight(this.props.location)
      ];
    }
    return this.size;
  }

  update(newProps) {
    this.props = Object.assign({}, this.props, newProps);
    return etch.update(this);
  }

  handleMouseDown(event) {
    if (event.detail === 2) {
      this.props.onResizeToFit();
    } else if (this.props.dockIsVisible) {
      this.props.onResizeStart();
    }
  }
}

class DockToggleButton {
  constructor(props) {
    this.props = props;
    etch.initialize(this);
  }

  render() {
    const classList = ['atom-dock-toggle-button', this.props.location];
    if (this.props.visible) classList.push(TOGGLE_BUTTON_VISIBLE_CLASS);

    return $.div(
      { className: classList.join(' ') },
      $.div(
        {
          ref: 'innerElement',
          className: `atom-dock-toggle-button-inner ${this.props.location}`,
          on: {
            click: this.handleClick,
            dragenter: this.props.onDragEnter
          }
        },
        $.span({
          ref: 'iconElement',
          className: `icon ${getIconName(
            this.props.location,
            this.props.dockIsVisible
          )}`
        })
      )
    );
  }

  getElement() {
    return this.element;
  }

  getBounds() {
    return this.refs.innerElement.getBoundingClientRect();
  }

  update(newProps) {
    this.props = Object.assign({}, this.props, newProps);
    return etch.update(this);
  }

  handleClick() {
    this.props.toggle();
  }
}

// An etch component that doesn't use etch, this component provides a gateway from JSX back into
// the mutable DOM world.
class ElementComponent {
  constructor(props) {
    this.element = props.element;
  }

  update(props) {
    this.element = props.element;
  }
}

function getWidthOrHeight(location) {
  return location === 'left' || location === 'right' ? 'width' : 'height';
}

function getPreferredSize(item, location) {
  switch (location) {
    case 'left':
    case 'right':
      return typeof item.getPreferredWidth === 'function'
        ? item.getPreferredWidth()
        : null;
    default:
      return typeof item.getPreferredHeight === 'function'
        ? item.getPreferredHeight()
        : null;
  }
}

function getIconName(location, visible) {
  switch (location) {
    case 'right':
      return visible ? 'icon-chevron-right' : 'icon-chevron-left';
    case 'bottom':
      return visible ? 'icon-chevron-down' : 'icon-chevron-up';
    case 'left':
      return visible ? 'icon-chevron-left' : 'icon-chevron-right';
    default:
      throw new Error(`Invalid location: ${location}`);
  }
}

function rectContainsPoint(rect, point) {
  return (
    point.x >= rect.left &&
    point.y >= rect.top &&
    point.x <= rect.right &&
    point.y <= rect.bottom
  );
}

// Is the item allowed in the given location?
function isItemAllowed(item, location) {
  if (typeof item.getAllowedLocations !== 'function') return true;
  return item.getAllowedLocations().includes(location);
}