/ src / workspace-element.js
workspace-element.js
  1  'use strict';
  2  
  3  const { ipcRenderer } = require('electron');
  4  const path = require('path');
  5  const fs = require('fs-plus');
  6  const { CompositeDisposable, Disposable } = require('event-kit');
  7  const scrollbarStyle = require('scrollbar-style');
  8  const _ = require('underscore-plus');
  9  
 10  class WorkspaceElement extends HTMLElement {
 11    attachedCallback() {
 12      this.focus();
 13      this.htmlElement = document.querySelector('html');
 14      this.htmlElement.addEventListener('mouseleave', this.handleCenterLeave);
 15    }
 16  
 17    detachedCallback() {
 18      this.subscriptions.dispose();
 19      this.htmlElement.removeEventListener('mouseleave', this.handleCenterLeave);
 20    }
 21  
 22    initializeContent() {
 23      this.classList.add('workspace');
 24      this.setAttribute('tabindex', -1);
 25  
 26      this.verticalAxis = document.createElement('atom-workspace-axis');
 27      this.verticalAxis.classList.add('vertical');
 28  
 29      this.horizontalAxis = document.createElement('atom-workspace-axis');
 30      this.horizontalAxis.classList.add('horizontal');
 31      this.horizontalAxis.appendChild(this.verticalAxis);
 32  
 33      this.appendChild(this.horizontalAxis);
 34    }
 35  
 36    observeScrollbarStyle() {
 37      this.subscriptions.add(
 38        scrollbarStyle.observePreferredScrollbarStyle(style => {
 39          switch (style) {
 40            case 'legacy':
 41              this.classList.remove('scrollbars-visible-when-scrolling');
 42              this.classList.add('scrollbars-visible-always');
 43              break;
 44            case 'overlay':
 45              this.classList.remove('scrollbars-visible-always');
 46              this.classList.add('scrollbars-visible-when-scrolling');
 47              break;
 48          }
 49        })
 50      );
 51    }
 52  
 53    observeTextEditorFontConfig() {
 54      this.updateGlobalTextEditorStyleSheet();
 55      this.subscriptions.add(
 56        this.config.onDidChange(
 57          'editor.fontSize',
 58          this.updateGlobalTextEditorStyleSheet.bind(this)
 59        )
 60      );
 61      this.subscriptions.add(
 62        this.config.onDidChange(
 63          'editor.fontFamily',
 64          this.updateGlobalTextEditorStyleSheet.bind(this)
 65        )
 66      );
 67      this.subscriptions.add(
 68        this.config.onDidChange(
 69          'editor.lineHeight',
 70          this.updateGlobalTextEditorStyleSheet.bind(this)
 71        )
 72      );
 73    }
 74  
 75    updateGlobalTextEditorStyleSheet() {
 76      const styleSheetSource = `atom-workspace {
 77    --editor-font-size: ${this.config.get('editor.fontSize')}px;
 78    --editor-font-family: ${this.config.get('editor.fontFamily')};
 79    --editor-line-height: ${this.config.get('editor.lineHeight')};
 80  }`;
 81      this.styleManager.addStyleSheet(styleSheetSource, {
 82        sourcePath: 'global-text-editor-styles',
 83        priority: -1
 84      });
 85    }
 86  
 87    initialize(model, { config, project, styleManager, viewRegistry }) {
 88      this.handleCenterEnter = this.handleCenterEnter.bind(this);
 89      this.handleCenterLeave = this.handleCenterLeave.bind(this);
 90      this.handleEdgesMouseMove = _.throttle(
 91        this.handleEdgesMouseMove.bind(this),
 92        100
 93      );
 94      this.handleDockDragEnd = this.handleDockDragEnd.bind(this);
 95      this.handleDragStart = this.handleDragStart.bind(this);
 96      this.handleDragEnd = this.handleDragEnd.bind(this);
 97      this.handleDrop = this.handleDrop.bind(this);
 98  
 99      this.model = model;
100      this.viewRegistry = viewRegistry;
101      this.project = project;
102      this.config = config;
103      this.styleManager = styleManager;
104      if (this.viewRegistry == null) {
105        throw new Error(
106          'Must pass a viewRegistry parameter when initializing WorkspaceElements'
107        );
108      }
109      if (this.project == null) {
110        throw new Error(
111          'Must pass a project parameter when initializing WorkspaceElements'
112        );
113      }
114      if (this.config == null) {
115        throw new Error(
116          'Must pass a config parameter when initializing WorkspaceElements'
117        );
118      }
119      if (this.styleManager == null) {
120        throw new Error(
121          'Must pass a styleManager parameter when initializing WorkspaceElements'
122        );
123      }
124  
125      this.subscriptions = new CompositeDisposable(
126        new Disposable(() => {
127          this.paneContainer.removeEventListener(
128            'mouseenter',
129            this.handleCenterEnter
130          );
131          this.paneContainer.removeEventListener(
132            'mouseleave',
133            this.handleCenterLeave
134          );
135          window.removeEventListener('mousemove', this.handleEdgesMouseMove);
136          window.removeEventListener('dragend', this.handleDockDragEnd);
137          window.removeEventListener('dragstart', this.handleDragStart);
138          window.removeEventListener('dragend', this.handleDragEnd, true);
139          window.removeEventListener('drop', this.handleDrop, true);
140        }),
141        ...[
142          this.model.getLeftDock(),
143          this.model.getRightDock(),
144          this.model.getBottomDock()
145        ].map(dock =>
146          dock.onDidChangeHovered(hovered => {
147            if (hovered) this.hoveredDock = dock;
148            else if (dock === this.hoveredDock) this.hoveredDock = null;
149            this.checkCleanupDockHoverEvents();
150          })
151        )
152      );
153      this.initializeContent();
154      this.observeScrollbarStyle();
155      this.observeTextEditorFontConfig();
156  
157      this.paneContainer = this.model.getCenter().paneContainer.getElement();
158      this.verticalAxis.appendChild(this.paneContainer);
159      this.addEventListener('focus', this.handleFocus.bind(this));
160  
161      this.addEventListener('mousewheel', this.handleMousewheel.bind(this), true);
162      window.addEventListener('dragstart', this.handleDragStart);
163      window.addEventListener('mousemove', this.handleEdgesMouseMove);
164  
165      this.panelContainers = {
166        top: this.model.panelContainers.top.getElement(),
167        left: this.model.panelContainers.left.getElement(),
168        right: this.model.panelContainers.right.getElement(),
169        bottom: this.model.panelContainers.bottom.getElement(),
170        header: this.model.panelContainers.header.getElement(),
171        footer: this.model.panelContainers.footer.getElement(),
172        modal: this.model.panelContainers.modal.getElement()
173      };
174  
175      this.horizontalAxis.insertBefore(
176        this.panelContainers.left,
177        this.verticalAxis
178      );
179      this.horizontalAxis.appendChild(this.panelContainers.right);
180  
181      this.verticalAxis.insertBefore(
182        this.panelContainers.top,
183        this.paneContainer
184      );
185      this.verticalAxis.appendChild(this.panelContainers.bottom);
186  
187      this.insertBefore(this.panelContainers.header, this.horizontalAxis);
188      this.appendChild(this.panelContainers.footer);
189  
190      this.appendChild(this.panelContainers.modal);
191  
192      this.paneContainer.addEventListener('mouseenter', this.handleCenterEnter);
193      this.paneContainer.addEventListener('mouseleave', this.handleCenterLeave);
194  
195      return this;
196    }
197  
198    destroy() {
199      this.subscriptions.dispose();
200    }
201  
202    getModel() {
203      return this.model;
204    }
205  
206    handleDragStart(event) {
207      if (!isTab(event.target)) return;
208      const { item } = event.target;
209      if (!item) return;
210      this.model.setDraggingItem(item);
211      window.addEventListener('dragend', this.handleDragEnd, true);
212      window.addEventListener('drop', this.handleDrop, true);
213    }
214  
215    handleDragEnd(event) {
216      this.dragEnded();
217    }
218  
219    handleDrop(event) {
220      this.dragEnded();
221    }
222  
223    dragEnded() {
224      this.model.setDraggingItem(null);
225      window.removeEventListener('dragend', this.handleDragEnd, true);
226      window.removeEventListener('drop', this.handleDrop, true);
227    }
228  
229    handleCenterEnter(event) {
230      // Just re-entering the center isn't enough to hide the dock toggle buttons, since they poke
231      // into the center and we want to give an affordance.
232      this.cursorInCenter = true;
233      this.checkCleanupDockHoverEvents();
234    }
235  
236    handleCenterLeave(event) {
237      // If the cursor leaves the center, we start listening to determine whether one of the docs is
238      // being hovered.
239      this.cursorInCenter = false;
240      this.updateHoveredDock({ x: event.pageX, y: event.pageY });
241      window.addEventListener('dragend', this.handleDockDragEnd);
242    }
243  
244    handleEdgesMouseMove(event) {
245      this.updateHoveredDock({ x: event.pageX, y: event.pageY });
246    }
247  
248    handleDockDragEnd(event) {
249      this.updateHoveredDock({ x: event.pageX, y: event.pageY });
250    }
251  
252    updateHoveredDock(mousePosition) {
253      // If we haven't left the currently hovered dock, don't change anything.
254      if (
255        this.hoveredDock &&
256        this.hoveredDock.pointWithinHoverArea(mousePosition, true)
257      )
258        return;
259  
260      const docks = [
261        this.model.getLeftDock(),
262        this.model.getRightDock(),
263        this.model.getBottomDock()
264      ];
265      const nextHoveredDock = docks.find(
266        dock =>
267          dock !== this.hoveredDock && dock.pointWithinHoverArea(mousePosition)
268      );
269      docks.forEach(dock => {
270        dock.setHovered(dock === nextHoveredDock);
271      });
272    }
273  
274    checkCleanupDockHoverEvents() {
275      if (this.cursorInCenter && !this.hoveredDock) {
276        window.removeEventListener('dragend', this.handleDockDragEnd);
277      }
278    }
279  
280    handleMousewheel(event) {
281      if (
282        event.ctrlKey &&
283        this.config.get('editor.zoomFontWhenCtrlScrolling') &&
284        event.target.closest('atom-text-editor') != null
285      ) {
286        if (event.wheelDeltaY > 0) {
287          this.model.increaseFontSize();
288        } else if (event.wheelDeltaY < 0) {
289          this.model.decreaseFontSize();
290        }
291        event.preventDefault();
292        event.stopPropagation();
293      }
294    }
295  
296    handleFocus(event) {
297      this.model.getActivePane().activate();
298    }
299  
300    focusPaneViewAbove() {
301      this.focusPaneViewInDirection('above');
302    }
303  
304    focusPaneViewBelow() {
305      this.focusPaneViewInDirection('below');
306    }
307  
308    focusPaneViewOnLeft() {
309      this.focusPaneViewInDirection('left');
310    }
311  
312    focusPaneViewOnRight() {
313      this.focusPaneViewInDirection('right');
314    }
315  
316    focusPaneViewInDirection(direction, pane) {
317      const activePane = this.model.getActivePane();
318      const paneToFocus = this.nearestVisiblePaneInDirection(
319        direction,
320        activePane
321      );
322      paneToFocus && paneToFocus.focus();
323    }
324  
325    moveActiveItemToPaneAbove(params) {
326      this.moveActiveItemToNearestPaneInDirection('above', params);
327    }
328  
329    moveActiveItemToPaneBelow(params) {
330      this.moveActiveItemToNearestPaneInDirection('below', params);
331    }
332  
333    moveActiveItemToPaneOnLeft(params) {
334      this.moveActiveItemToNearestPaneInDirection('left', params);
335    }
336  
337    moveActiveItemToPaneOnRight(params) {
338      this.moveActiveItemToNearestPaneInDirection('right', params);
339    }
340  
341    moveActiveItemToNearestPaneInDirection(direction, params) {
342      const activePane = this.model.getActivePane();
343      const nearestPaneView = this.nearestVisiblePaneInDirection(
344        direction,
345        activePane
346      );
347      if (nearestPaneView == null) {
348        return;
349      }
350      if (params && params.keepOriginal) {
351        activePane
352          .getContainer()
353          .copyActiveItemToPane(nearestPaneView.getModel());
354      } else {
355        activePane
356          .getContainer()
357          .moveActiveItemToPane(nearestPaneView.getModel());
358      }
359      nearestPaneView.focus();
360    }
361  
362    nearestVisiblePaneInDirection(direction, pane) {
363      const distance = function(pointA, pointB) {
364        const x = pointB.x - pointA.x;
365        const y = pointB.y - pointA.y;
366        return Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));
367      };
368  
369      const paneView = pane.getElement();
370      const box = this.boundingBoxForPaneView(paneView);
371  
372      const paneViews = atom.workspace
373        .getVisiblePanes()
374        .map(otherPane => otherPane.getElement())
375        .filter(otherPaneView => {
376          const otherBox = this.boundingBoxForPaneView(otherPaneView);
377          switch (direction) {
378            case 'left':
379              return otherBox.right.x <= box.left.x;
380            case 'right':
381              return otherBox.left.x >= box.right.x;
382            case 'above':
383              return otherBox.bottom.y <= box.top.y;
384            case 'below':
385              return otherBox.top.y >= box.bottom.y;
386          }
387        })
388        .sort((paneViewA, paneViewB) => {
389          const boxA = this.boundingBoxForPaneView(paneViewA);
390          const boxB = this.boundingBoxForPaneView(paneViewB);
391          switch (direction) {
392            case 'left':
393              return (
394                distance(box.left, boxA.right) - distance(box.left, boxB.right)
395              );
396            case 'right':
397              return (
398                distance(box.right, boxA.left) - distance(box.right, boxB.left)
399              );
400            case 'above':
401              return (
402                distance(box.top, boxA.bottom) - distance(box.top, boxB.bottom)
403              );
404            case 'below':
405              return (
406                distance(box.bottom, boxA.top) - distance(box.bottom, boxB.top)
407              );
408          }
409        });
410  
411      return paneViews[0];
412    }
413  
414    boundingBoxForPaneView(paneView) {
415      const boundingBox = paneView.getBoundingClientRect();
416  
417      return {
418        left: { x: boundingBox.left, y: boundingBox.top },
419        right: { x: boundingBox.right, y: boundingBox.top },
420        top: { x: boundingBox.left, y: boundingBox.top },
421        bottom: { x: boundingBox.left, y: boundingBox.bottom }
422      };
423    }
424  
425    runPackageSpecs(options = {}) {
426      const activePaneItem = this.model.getActivePaneItem();
427      const activePath =
428        activePaneItem && typeof activePaneItem.getPath === 'function'
429          ? activePaneItem.getPath()
430          : null;
431      let projectPath;
432      if (activePath != null) {
433        [projectPath] = this.project.relativizePath(activePath);
434      } else {
435        [projectPath] = this.project.getPaths();
436      }
437      if (projectPath) {
438        let specPath = path.join(projectPath, 'spec');
439        const testPath = path.join(projectPath, 'test');
440        if (!fs.existsSync(specPath) && fs.existsSync(testPath)) {
441          specPath = testPath;
442        }
443  
444        ipcRenderer.send('run-package-specs', specPath, options);
445      }
446    }
447  
448    runBenchmarks() {
449      const activePaneItem = this.model.getActivePaneItem();
450      const activePath =
451        activePaneItem && typeof activePaneItem.getPath === 'function'
452          ? activePaneItem.getPath()
453          : null;
454      let projectPath;
455      if (activePath) {
456        [projectPath] = this.project.relativizePath(activePath);
457      } else {
458        [projectPath] = this.project.getPaths();
459      }
460  
461      if (projectPath) {
462        ipcRenderer.send('run-benchmarks', path.join(projectPath, 'benchmarks'));
463      }
464    }
465  }
466  
467  module.exports = document.registerElement('atom-workspace', {
468    prototype: WorkspaceElement.prototype
469  });
470  
471  function isTab(element) {
472    let el = element;
473    while (el != null) {
474      if (el.getAttribute && el.getAttribute('is') === 'tabs-tab') return true;
475      el = el.parentElement;
476    }
477    return false;
478  }