diff --git a/package.json b/package.json index 334963f56..8642f6ade 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "solarized-dark-syntax": "1.1.2", "solarized-light-syntax": "1.1.2", "about": "1.7.6", - "archive-view": "0.63.1", + "archive-view": "0.63.2", "autocomplete-atom-api": "0.10.1", "autocomplete-css": "0.16.1", "autocomplete-html": "0.7.3", diff --git a/spec/pane-axis-element-spec.coffee b/spec/pane-axis-element-spec.coffee index 702e9c5fc..e6aa2ed5f 100644 --- a/spec/pane-axis-element-spec.coffee +++ b/spec/pane-axis-element-spec.coffee @@ -7,12 +7,13 @@ buildPane = -> applicationDelegate: atom.applicationDelegate, config: atom.config, deserializerManager: atom.deserializers, - notificationManager: atom.notifications + notificationManager: atom.notifications, + viewRegistry: atom.views }) describe "PaneAxisElement", -> it "correctly subscribes and unsubscribes to the underlying model events on attach/detach", -> - container = new PaneContainer(config: atom.config, applicationDelegate: atom.applicationDelegate) + container = new PaneContainer(config: atom.config, applicationDelegate: atom.applicationDelegate, viewRegistry: atom.views) axis = new PaneAxis axis.setContainer(container) axisElement = atom.views.getView(axis) diff --git a/spec/pane-container-element-spec.coffee b/spec/pane-container-element-spec.coffee index fe57e89af..55692c8e2 100644 --- a/spec/pane-container-element-spec.coffee +++ b/spec/pane-container-element-spec.coffee @@ -2,6 +2,13 @@ PaneContainer = require '../src/pane-container' PaneAxisElement = require '../src/pane-axis-element' PaneAxis = require '../src/pane-axis' +params = + location: 'center' + config: atom.config + confirm: atom.confirm.bind(atom) + viewRegistry: atom.views + applicationDelegate: atom.applicationDelegate + describe "PaneContainerElement", -> describe "when panes are added or removed", -> it "inserts or removes resize elements", -> @@ -42,7 +49,7 @@ describe "PaneContainerElement", -> ] it "transfers focus to the next pane if a focused pane is removed", -> - container = new PaneContainer(config: atom.config, confirm: atom.confirm.bind(atom)) + container = new PaneContainer(params) containerElement = atom.views.getView(container) leftPane = container.getActivePane() leftPaneElement = atom.views.getView(leftPane) @@ -58,7 +65,7 @@ describe "PaneContainerElement", -> describe "when a pane is split", -> it "builds appropriately-oriented atom-pane-axis elements", -> - container = new PaneContainer(config: atom.config, confirm: atom.confirm.bind(atom)) + container = new PaneContainer(params) containerElement = atom.views.getView(container) pane1 = container.getActivePane() @@ -84,7 +91,7 @@ describe "PaneContainerElement", -> [container, containerElement] = [] beforeEach -> - container = new PaneContainer(config: atom.config, confirm: atom.confirm.bind(atom)) + container = new PaneContainer(params) containerElement = atom.views.getView(container) document.querySelector('#jasmine-content').appendChild(containerElement) @@ -201,7 +208,7 @@ describe "PaneContainerElement", -> [leftPane, rightPane] = [] beforeEach -> - container = new PaneContainer(config: atom.config, confirm: atom.confirm.bind(atom)) + container = new PaneContainer(params) leftPane = container.getActivePane() rightPane = leftPane.splitRight() @@ -258,7 +265,7 @@ describe "PaneContainerElement", -> element.cloneNode(true) element - container = new PaneContainer(config: atom.config, confirm: atom.confirm.bind(atom)) + container = new PaneContainer(params) [item1, item2, item3, item4, item5, item6, item7, item8, item9] = [buildElement('1'), buildElement('2'), buildElement('3'), diff --git a/spec/pane-container-spec.coffee b/spec/pane-container-spec.coffee index 07b9f4763..153ef2bda 100644 --- a/spec/pane-container-spec.coffee +++ b/spec/pane-container-spec.coffee @@ -7,9 +7,11 @@ describe "PaneContainer", -> beforeEach -> confirm = spyOn(atom.applicationDelegate, 'confirm').andReturn(0) params = { + location: 'center', config: atom.config, deserializerManager: atom.deserializers - applicationDelegate: atom.applicationDelegate + applicationDelegate: atom.applicationDelegate, + viewRegistry: atom.views } describe "serialization", -> diff --git a/spec/pane-element-spec.coffee b/spec/pane-element-spec.coffee index f5a059c49..d1725d11a 100644 --- a/spec/pane-element-spec.coffee +++ b/spec/pane-element-spec.coffee @@ -6,7 +6,12 @@ describe "PaneElement", -> beforeEach -> spyOn(atom.applicationDelegate, "open") - container = new PaneContainer(config: atom.config, confirm: atom.confirm.bind(atom)) + container = new PaneContainer + location: 'center' + config: atom.config + confirm: atom.confirm.bind(atom) + viewRegistry: atom.views + applicationDelegate: atom.applicationDelegate containerElement = atom.views.getView(container) pane = container.getActivePane() paneElement = atom.views.getView(pane) diff --git a/spec/pane-spec.coffee b/spec/pane-spec.coffee index 596b1ecea..53bccbf13 100644 --- a/spec/pane-spec.coffee +++ b/spec/pane-spec.coffee @@ -51,7 +51,10 @@ describe "Pane", -> [container, pane1, pane2] = [] beforeEach -> - container = new PaneContainer(config: atom.config, applicationDelegate: atom.applicationDelegate) + container = new PaneContainer + location: 'center' + config: atom.config + applicationDelegate: atom.applicationDelegate container.getActivePane().splitRight() [pane1, pane2] = container.getPanes() diff --git a/spec/workspace-spec.js b/spec/workspace-spec.js index d2f922b57..2237dc33e 100644 --- a/spec/workspace-spec.js +++ b/spec/workspace-spec.js @@ -24,6 +24,8 @@ describe('Workspace', () => { setDocumentEdited = spyOn(atom.applicationDelegate, 'setWindowDocumentEdited') atom.project.setPaths([atom.project.getDirectories()[0].resolve('dir')]) waits(1) + + waitsForPromise(() => atom.workspace.itemLocationStore.clear()) }) afterEach(() => temp.cleanupSync()) @@ -117,6 +119,62 @@ describe('Workspace', () => { expect(atom.workspace.getTextEditors().length).toBe(0) }) }) + + describe('where a dock contains an editor', () => { + afterEach(() => { + atom.workspace.getRightDock().paneContainer.destroy() + }) + + it('constructs the view with the same panes', () => { + const pane1 = atom.workspace.getRightDock().getActivePane() + const pane2 = pane1.splitRight({copyActiveItem: true}) + const pane3 = pane2.splitRight({copyActiveItem: true}) + let pane4 = null + + waitsForPromise(() => + atom.workspace.open(null, {location: 'right'}).then(editor => editor.setText('An untitled editor.')) + ) + + waitsForPromise(() => + atom.workspace.open('b', {location: 'right'}).then(editor => pane2.activateItem(editor.copy())) + ) + + waitsForPromise(() => + atom.workspace.open('../sample.js', {location: 'right'}).then(editor => pane3.activateItem(editor)) + ) + + runs(() => { + pane3.activeItem.setCursorScreenPosition([2, 4]) + pane4 = pane2.splitDown() + }) + + waitsForPromise(() => + atom.workspace.open('../sample.txt', {location: 'right'}).then(editor => pane4.activateItem(editor)) + ) + + runs(() => { + pane4.getActiveItem().setCursorScreenPosition([0, 2]) + pane2.activate() + + simulateReload() + + expect(atom.workspace.getTextEditors().length).toBe(5) + const [editor1, editor2, untitledEditor, editor3, editor4] = atom.workspace.getTextEditors() + const firstDirectory = atom.project.getDirectories()[0] + expect(firstDirectory).toBeDefined() + expect(editor1.getPath()).toBe(firstDirectory.resolve('b')) + expect(editor2.getPath()).toBe(firstDirectory.resolve('../sample.txt')) + expect(editor2.getCursorScreenPosition()).toEqual([0, 2]) + expect(editor3.getPath()).toBe(firstDirectory.resolve('b')) + expect(editor4.getPath()).toBe(firstDirectory.resolve('../sample.js')) + expect(editor4.getCursorScreenPosition()).toEqual([2, 4]) + expect(untitledEditor.getPath()).toBeUndefined() + expect(untitledEditor.getText()).toBe('An untitled editor.') + + expect(atom.workspace.getRightDock().getActiveTextEditor().getPath()).toBe(editor3.getPath()) + }) + }) + }) }) describe('::open(uri, options)', () => { @@ -202,6 +260,25 @@ describe('Workspace', () => { ]) }) }) + + it('finds items in docks', () => { + const dock = atom.workspace.getRightDock() + const ITEM_URI = 'atom://test' + const item = { + getURI: () => ITEM_URI, + getDefaultLocation: jasmine.createSpy().andReturn('left'), + getElement: () => document.createElement('div') + } + dock.getActivePane().addItem(item) + expect(dock.getPaneItems()).toHaveLength(1) + waitsForPromise(() => atom.workspace.open(ITEM_URI, {searchAllPanes: true})) + runs(() => { + expect(item.getDefaultLocation).not.toHaveBeenCalled() + expect(atom.workspace.getPaneItems()).toHaveLength(1) + expect(dock.getPaneItems()).toHaveLength(1) + expect(dock.getPaneItems()[0]).toBe(item) + }) + }) }) describe('when the active pane does not have an editor for the given uri', () => { @@ -218,6 +295,46 @@ describe('Workspace', () => { expect(workspace.getActivePane().activate).toHaveBeenCalled() }) }) + + it("uses the location specified by the model's `getDefaultLocation()` method", () => { + const item = { + getDefaultLocation: jasmine.createSpy().andReturn('right'), + getElement: () => document.createElement('div') + } + const opener = jasmine.createSpy().andReturn(item) + const dock = atom.workspace.getRightDock() + spyOn(atom.workspace.itemLocationStore, 'load').andReturn(Promise.resolve()) + spyOn(atom.workspace, 'getOpeners').andReturn([opener]) + expect(dock.getPaneItems()).toHaveLength(0) + waitsForPromise(() => atom.workspace.open('a')) + runs(() => { + expect(dock.getPaneItems()).toHaveLength(1) + expect(opener).toHaveBeenCalled() + expect(item.getDefaultLocation).toHaveBeenCalled() + }) + }) + + it('prefers the last location the user used for that item', () => { + const ITEM_URI = 'atom://test' + const item = { + getURI: () => ITEM_URI, + getDefaultLocation: jasmine.createSpy().andReturn('left'), + getElement: () => document.createElement('div') + } + const opener = uri => uri === ITEM_URI ? item : null + const dock = atom.workspace.getRightDock() + spyOn(atom.workspace.itemLocationStore, 'load').andCallFake(uri => + uri === 'atom://test' ? Promise.resolve('right') : Promise.resolve() + ) + spyOn(atom.workspace, 'getOpeners').andReturn([opener]) + expect(dock.getPaneItems()).toHaveLength(0) + waitsForPromise(() => atom.workspace.open(ITEM_URI)) + runs(() => { + expect(dock.getPaneItems()).toHaveLength(1) + expect(dock.getPaneItems()[0]).toBe(item) + expect(item.getDefaultLocation).not.toHaveBeenCalled() + }) + }) }) }) }) @@ -249,6 +366,22 @@ describe('Workspace', () => { expect(workspace.getActivePaneItem()).toBe(editor1) }) }) + + it('activates the dock with the matching item', () => { + const dock = atom.workspace.getRightDock() + const ITEM_URI = 'atom://test' + const item = { + getURI: () => ITEM_URI, + getDefaultLocation: jasmine.createSpy().andReturn('left'), + getElement: () => document.createElement('div') + } + dock.getActivePane().addItem(item) + spyOn(dock, 'activate') + waitsForPromise(() => atom.workspace.open(ITEM_URI, {searchAllPanes: true})) + runs(() => { + expect(dock.activate).toHaveBeenCalled() + }) + }) }) describe('when no editor for the given uri is open in any pane', () => { @@ -685,9 +818,13 @@ describe('Workspace', () => { }) ) - it('creates a notification', () => { - const open = () => workspace.open('file1', workspace.getActivePane()) - expect(open).toThrow() + it('rejects the promise', () => { + waitsFor((done) => { + workspace.open('file1').catch(error => { + expect(error.message).toBe('I dont even know what is happening right now!!') + done() + }) + }) }) }) }) @@ -1986,24 +2123,24 @@ i = /test/; #FIXME\ const pane1 = atom.workspace.getActivePane() const pane2 = pane1.splitRight({copyActiveItem: true}) - expect(atom.workspace.getPanes().length).toBe(2) + expect(atom.workspace.getCenter().getPanes().length).toBe(2) expect(pane2.getItems().length).toBe(1) atom.workspace.closeActivePaneItemOrEmptyPaneOrWindow() - expect(atom.workspace.getPanes().length).toBe(2) + expect(atom.workspace.getCenter().getPanes().length).toBe(2) expect(pane2.getItems().length).toBe(0) atom.workspace.closeActivePaneItemOrEmptyPaneOrWindow() - expect(atom.workspace.getPanes().length).toBe(1) + expect(atom.workspace.getCenter().getPanes().length).toBe(1) expect(pane1.getItems().length).toBe(1) atom.workspace.closeActivePaneItemOrEmptyPaneOrWindow() - expect(atom.workspace.getPanes().length).toBe(1) + expect(atom.workspace.getCenter().getPanes().length).toBe(1) expect(pane1.getItems().length).toBe(0) atom.workspace.closeActivePaneItemOrEmptyPaneOrWindow() - expect(atom.workspace.getPanes().length).toBe(1) + expect(atom.workspace.getCenter().getPanes().length).toBe(1) atom.workspace.closeActivePaneItemOrEmptyPaneOrWindow() expect(atom.close).toHaveBeenCalled() diff --git a/src/atom-environment.coffee b/src/atom-environment.coffee index de516bb17..33a40f0cf 100644 --- a/src/atom-environment.coffee +++ b/src/atom-environment.coffee @@ -39,6 +39,7 @@ Panel = require './panel' PaneContainer = require './pane-container' PaneAxis = require './pane-axis' Pane = require './pane' +Dock = require './dock' Project = require './project' TextEditor = require './text-editor' TextBuffer = require 'text-buffer' @@ -49,9 +50,7 @@ AutoUpdateManager = require './auto-update-manager' WorkspaceElement = require './workspace-element' PanelContainerElement = require './panel-container-element' PanelElement = require './panel-element' -PaneContainerElement = require './pane-container-element' PaneAxisElement = require './pane-axis-element' -PaneElement = require './pane-element' {createGutterView} = require './gutter-component-helpers' # Essential: Atom global for dealing with packages, themes, menus, and the window. @@ -274,6 +273,7 @@ class AtomEnvironment extends Model @deserializers.add(PaneContainer) @deserializers.add(PaneAxis) @deserializers.add(Pane) + @deserializers.add(Dock) @deserializers.add(Project) @deserializers.add(TextEditor) @deserializers.add(TextBuffer) @@ -288,12 +288,8 @@ class AtomEnvironment extends Model new PanelContainerElement().initialize(model, env) @views.addViewProvider Panel, (model, env) -> new PanelElement().initialize(model, env) - @views.addViewProvider PaneContainer, (model, env) -> - new PaneContainerElement().initialize(model, env) @views.addViewProvider PaneAxis, (model, env) -> new PaneAxisElement().initialize(model, env) - @views.addViewProvider Pane, (model, env) -> - new PaneElement().initialize(model, env) @views.addViewProvider(Gutter, createGutterView) registerDefaultOpeners: -> diff --git a/src/dock.js b/src/dock.js new file mode 100644 index 000000000..c04bbcb50 --- /dev/null +++ b/src/dock.js @@ -0,0 +1,758 @@ +'use strict' + +const _ = require('underscore-plus') +const {CompositeDisposable} = require('event-kit') +const PaneContainer = require('./pane-container') +const TextEditor = require('./text-editor') + +const MINIMUM_SIZE = 100 +const DEFAULT_INITIAL_SIZE = 300 +const SHOULD_ANIMATE_CLASS = 'atom-dock-should-animate' +const OPEN_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 {::getLeftDock}, {::getRightDock}, and {::getBottomDock} or add an item to a dock via +// {Workspace::open}. +module.exports = class Dock { + constructor (params) { + this.handleResizeHandleDragStart = this.handleResizeHandleDragStart.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.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.paneContainer = new PaneContainer({ + location: this.location, + config: this.config, + applicationDelegate: this.applicationDelegate, + deserializerManager: this.deserializerManager, + notificationManager: this.notificationManager, + viewRegistry: this.viewRegistry + }) + + this.state = { + open: false, + shouldAnimate: false + } + + this.subscriptions = new CompositeDisposable( + this.paneContainer.observePanes(pane => { + pane.onDidAddItem(this.handleDidAddPaneItem.bind(this)) + }), + this.paneContainer.observePanes(pane => { + pane.onDidRemoveItem(this.handleDidRemovePaneItem.bind(this)) + }) + ) + } + + // 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. + this.render(this.state) + } + + getElement () { + if (!this.element) this.render(this.state) + return this.element + } + + getLocation () { + return this.location + } + + destroy () { + this.subscriptions.dispose() + this.paneContainer.destroy() + this.resizeHandle.destroy() + this.toggleButton.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}) + } + + activate () { + this.setState({open: true}) + } + + hide () { + this.setState({open: false}) + } + + toggle () { + this.setState({open: !this.state.open}) + } + + isOpen () { + return this.state.open + } + + 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.open !== prevState.open) { + // Never animate toggling visiblity... + nextState.shouldAnimate = false + } else if (!nextState.open && nextState.draggingItem && !prevState.draggingItem) { + // ...but do animate if you start dragging while the panel is hidden. + nextState.shouldAnimate = true + } + + this.state = nextState + this.render(this.state) + } + + render (state) { + if (this.element == null) { + this.element = document.createElement('atom-dock') + this.element.classList.add(this.location) + this.innerElement = document.createElement('div') + this.innerElement.classList.add('atom-dock-inner', this.location) + this.maskElement = document.createElement('div') + this.maskElement.classList.add('atom-dock-mask') + this.wrapperElement = document.createElement('div') + this.wrapperElement.classList.add('atom-dock-content-wrapper', this.location) + this.resizeHandle = new DockResizeHandle({ + location: this.location, + onResizeStart: this.handleResizeHandleDragStart, + toggle: this.toggle.bind(this) + }) + this.toggleButton = new DockToggleButton({ + onDragEnter: this.handleToggleButtonDragEnter.bind(this), + location: this.location, + toggle: this.toggle.bind(this) + }) + this.cursorOverlayElement = document.createElement('div') + this.cursorOverlayElement.classList.add('atom-dock-cursor-overlay', this.location) + + // Add the children to the DOM tree + this.element.appendChild(this.innerElement) + this.innerElement.appendChild(this.maskElement) + this.maskElement.appendChild(this.wrapperElement) + this.wrapperElement.appendChild(this.resizeHandle.getElement()) + this.wrapperElement.appendChild(this.paneContainer.getElement()) + this.wrapperElement.appendChild(this.cursorOverlayElement) + // The toggle button must be rendered outside the mask because (1) it shouldn't be masked and + // (2) if we made the mask larger to avoid masking it, the mask would block mouse events. + this.innerElement.appendChild(this.toggleButton.getElement()) + } + + if (state.open) { + this.innerElement.classList.add(OPEN_CLASS) + } else { + this.innerElement.classList.remove(OPEN_CLASS) + } + + if (state.shouldAnimate) { + this.maskElement.classList.add(SHOULD_ANIMATE_CLASS) + } else { + this.maskElement.classList.remove(SHOULD_ANIMATE_CLASS) + } + + if (state.resizing) { + this.cursorOverlayElement.classList.add(CURSOR_OVERLAY_VISIBLE_CLASS) + } else { + this.cursorOverlayElement.classList.remove(CURSOR_OVERLAY_VISIBLE_CLASS) + } + + const shouldBeVisible = state.open || state.showDropTarget + const size = Math.max(MINIMUM_SIZE, state.size == null ? this.getInitialSize() : state.size) + + // We need to change the size of the mask... + this.maskElement.style[this.widthOrHeight] = `${shouldBeVisible ? size : this.resizeHandle.getSize()}px` + // ...but the content needs to maintain a constant size. + this.wrapperElement.style[this.widthOrHeight] = `${size}px` + + this.resizeHandle.update({dockIsOpen: this.state.open}) + this.toggleButton.update({ + open: shouldBeVisible, + visible: + // Don't show the toggle button if the dock is closed and empty... + (state.hovered && (this.state.open || this.getPaneItems().length > 0)) || + // ...or if the item can't be dropped in that dock. + (!shouldBeVisible && state.draggingItem && isItemAllowed(state.draggingItem, this.location)) + }) + } + + handleDidAddPaneItem () { + // Show the dock if you drop an item into it. + this.setState({open: true}) + } + + handleDidRemovePaneItem () { + // Hide the dock if you remove the last item. + if (this.paneContainer.getPaneItems().length === 0) { + this.setState({open: false, hovered: false}) + } + } + + handleResizeHandleDragStart () { + window.addEventListener('mousemove', this.handleMouseMove) + window.addEventListener('mouseup', this.handleMouseUp) + this.setState({resizing: true}) + } + + 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}, false)) { + 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. + pointWithinHoverArea (point, includeButtonWidth) { + const dockBounds = this.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 + } + + // Include all panels that are closer to the edge than the dock in our calculations. + switch (this.location) { + case 'right': + bounds.right = Number.POSITIVE_INFINITY + break + case 'bottom': + bounds.bottom = Number.POSITIVE_INFINITY + break + case 'left': + bounds.left = 0 + break + } + + // The area used when detecting "leave" events is actually larger than when detecting entrances. + if (includeButtonWidth) { + const hoverMargin = 20 + const {width, height} = this.toggleButton.getBounds() + switch (this.location) { + case 'right': + bounds.left -= width + hoverMargin + break + case 'bottom': + bounds.top -= height + hoverMargin + break + case 'left': + bounds.right += width + hoverMargin + break + } + } + return rectContainsPoint(bounds, point) + } + + getInitialSize () { + let initialSize + // 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 (activePaneItem != null) { + initialSize = getPreferredInitialSize(activePaneItem, this.location) + } + return initialSize == null ? DEFAULT_INITIAL_SIZE : initialSize + } + + serialize () { + return { + deserializer: 'Dock', + size: this.state.size, + paneContainer: this.paneContainer.serialize(), + open: this.state.open + } + } + + deserialize (serialized, deserializerManager) { + this.paneContainer.deserialize(serialized.paneContainer, deserializerManager) + this.setState({ + size: serialized.size, + // If no items could be deserialized, we don't want to show the dock (even if it was open last time) + open: serialized.open && (this.paneContainer.getPaneItems().length > 0) + }) + } + + // PaneContainer-delegating methods + + /* + Section: Event Subscription + */ + + // Essential: Invoke the given callback with all current and future text + // editors in the dock. + // + // * `callback` {Function} to be called with current and future text editors. + // * `editor` An {TextEditor} that is present in {::getTextEditors} at the time + // of subscription or that is added at some later time. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeTextEditors (callback) { + for (const textEditor of this.getTextEditors()) { + callback(textEditor) + } + return this.onDidAddTextEditor(({textEditor}) => callback(textEditor)) + } + + // 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) + } + + /* + 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() + } + + // Essential: Get all text editors in the dock. + // + // Returns an {Array} of {TextEditor}s. + getTextEditors () { + return this.paneContainer.getTextEditors() + } + + // Essential: Get the active item if it is an {TextEditor}. + // + // Returns an {TextEditor} or `undefined` if the current active item is not an + // {TextEditor}. + getActiveTextEditor () { + 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() + } + + 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.handleMouseDown = this.handleMouseDown.bind(this) + this.handleClick = this.handleClick.bind(this) + + this.element = document.createElement('div') + this.element.classList.add('atom-dock-resize-handle', props.location) + this.element.addEventListener('mousedown', this.handleMouseDown) + this.element.addEventListener('click', this.handleClick) + this.props = props + this.update(props) + } + + 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) + + if (this.props.dockIsOpen) { + this.element.classList.add(RESIZE_HANDLE_RESIZABLE_CLASS) + } else { + this.element.classList.remove(RESIZE_HANDLE_RESIZABLE_CLASS) + } + } + + destroy () { + this.element.removeEventListener('mousedown', this.handleMouseDown) + this.element.removeEventListener('click', this.handleClick) + } + + handleClick () { + if (!this.props.dockIsOpen) { + this.props.toggle() + } + } + + handleMouseDown () { + if (this.props.dockIsOpen) { + this.props.onResizeStart() + } + } +} + +class DockToggleButton { + constructor (props) { + this.handleClick = this.handleClick.bind(this) + this.handleDragEnter = this.handleDragEnter.bind(this) + + this.element = document.createElement('div') + this.element.classList.add('atom-dock-toggle-button', props.location) + this.element.classList.add(props.location) + this.innerElement = document.createElement('div') + this.innerElement.classList.add('atom-dock-toggle-button-inner', props.location) + this.innerElement.addEventListener('click', this.handleClick) + this.innerElement.addEventListener('dragenter', this.handleDragEnter) + this.iconElement = document.createElement('span') + this.innerElement.appendChild(this.iconElement) + this.element.appendChild(this.innerElement) + + this.props = props + this.update(props) + } + + getElement () { + return this.element + } + + getBounds () { + if (this.bounds == null) { + this.bounds = this.element.getBoundingClientRect() + } + return this.bounds + } + + destroy () { + this.innerElement.removeEventListener('click', this.handleClick) + this.innerElement.removeEventListener('dragenter', this.handleDragEnter) + } + + update (newProps) { + this.props = Object.assign({}, this.props, newProps) + + if (this.props.visible) { + this.element.classList.add(TOGGLE_BUTTON_VISIBLE_CLASS) + } else { + this.element.classList.remove(TOGGLE_BUTTON_VISIBLE_CLASS) + } + + this.iconElement.className = 'icon ' + getIconName(this.props.location, this.props.open) + } + + handleClick () { + this.props.toggle() + } + + handleDragEnter () { + this.props.onDragEnter() + } +} + +function getWidthOrHeight (location) { + return location === 'left' || location === 'right' ? 'width' : 'height' +} + +function getPreferredInitialSize (item, location) { + switch (location) { + case 'left': + case 'right': + return typeof item.getPreferredInitialWidth === 'function' + ? item.getPreferredInitialWidth() + : null + default: + return typeof item.getPreferredInitialHeight === 'function' + ? item.getPreferredInitialHeight() + : null + } +} + +function getIconName (location, open) { + switch (location) { + case 'right': return open ? 'icon-chevron-right' : 'icon-chevron-left' + case 'bottom': return open ? 'icon-chevron-down' : 'icon-chevron-up' + case 'left': return open ? '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) +} diff --git a/src/pane-container.coffee b/src/pane-container.coffee index 20d14389d..54d1d6cbe 100644 --- a/src/pane-container.coffee +++ b/src/pane-container.coffee @@ -3,6 +3,7 @@ Model = require './model' Pane = require './pane' ItemRegistry = require './item-registry' +PaneContainerElement = require './pane-container-element' module.exports = class PaneContainer extends Model @@ -14,18 +15,23 @@ class PaneContainer extends Model constructor: (params) -> super - {@config, applicationDelegate, notificationManager, deserializerManager} = params + {@config, applicationDelegate, notificationManager, deserializerManager, @viewRegistry, @location} = params @emitter = new Emitter @subscriptions = new CompositeDisposable @itemRegistry = new ItemRegistry - @setRoot(new Pane({container: this, @config, applicationDelegate, notificationManager, deserializerManager})) + @setRoot(new Pane({container: this, @config, applicationDelegate, notificationManager, deserializerManager, @viewRegistry})) @setActivePane(@getRoot()) @monitorPaneItems() initialize: -> @monitorActivePaneItem() + getLocation: -> @location + + getElement: -> + @element ?= new PaneContainerElement().initialize(this, {views: @viewRegistry}) + serialize: (params) -> deserializer: 'PaneContainer' version: @serializationVersion diff --git a/src/pane.coffee b/src/pane.coffee index c55c9f043..5dfc59374 100644 --- a/src/pane.coffee +++ b/src/pane.coffee @@ -4,6 +4,7 @@ Grim = require 'grim' Model = require './model' PaneAxis = require './pane-axis' TextEditor = require './text-editor' +PaneElement = require './pane-element' # Extended: A container for presenting content in the center of the workspace. # Panes can contain multiple items, one of which is *active* at a given time. @@ -20,7 +21,7 @@ class Pane extends Model activeItem: undefined focused: false - @deserialize: (state, {deserializers, applicationDelegate, config, notifications}) -> + @deserialize: (state, {deserializers, applicationDelegate, config, notifications, views}) -> {items, activeItemIndex, activeItemURI, activeItemUri} = state activeItemURI ?= activeItemUri items = items.map (itemState) -> deserializers.deserialize(itemState) @@ -34,6 +35,7 @@ class Pane extends Model new Pane(extend(state, { deserializerManager: deserializers, notificationManager: notifications, + viewRegistry: views, config, applicationDelegate })) @@ -42,7 +44,7 @@ class Pane extends Model { @activeItem, @focused, @applicationDelegate, @notificationManager, @config, - @deserializerManager + @deserializerManager, @viewRegistry } = params @emitter = new Emitter @@ -55,6 +57,9 @@ class Pane extends Model @addItemsToStack(params?.itemStackIndices ? []) @setFlexScale(params?.flexScale ? 1) + getElement: -> + @element ?= new PaneElement().initialize(this, {views: @viewRegistry, @applicationDelegate}) + serialize: -> itemsToBeSerialized = compact(@items.map((item) -> item if typeof item.serialize is 'function')) itemStackIndices = (itemsToBeSerialized.indexOf(item) for item in @itemStack when typeof item.serialize is 'function') @@ -819,7 +824,7 @@ class Pane extends Model @parent.replaceChild(this, new PaneAxis({@container, orientation, children: [this], @flexScale})) @setFlexScale(1) - newPane = new Pane(extend({@applicationDelegate, @notificationManager, @deserializerManager, @config}, params)) + newPane = new Pane(extend({@applicationDelegate, @notificationManager, @deserializerManager, @config, @viewRegistry}, params)) switch side when 'before' then @parent.insertChildBefore(this, newPane) when 'after' then @parent.insertChildAfter(this, newPane) @@ -841,17 +846,21 @@ class Pane extends Model else this - # If the parent is a horizontal axis, returns its last child if it is a pane; - # otherwise returns a new pane created by splitting this pane rightward. - findOrCreateRightmostSibling: -> + findRightmostSibling: -> if @parent.orientation is 'horizontal' rightmostSibling = last(@parent.children) if rightmostSibling instanceof PaneAxis - @splitRight() + this else rightmostSibling else - @splitRight() + this + + # If the parent is a horizontal axis, returns its last child if it is a pane; + # otherwise returns a new pane created by splitting this pane rightward. + findOrCreateRightmostSibling: -> + rightmostSibling = @findRightmostSibling() + if rightmostSibling is this then @splitRight() else rightmostSibling # If the parent is a vertical axis, returns its first child if it is a pane; # otherwise returns this pane. @@ -865,17 +874,21 @@ class Pane extends Model else this - # If the parent is a vertical axis, returns its last child if it is a pane; - # otherwise returns a new pane created by splitting this pane bottomward. - findOrCreateBottommostSibling: -> + findBottommostSibling: -> if @parent.orientation is 'vertical' bottommostSibling = last(@parent.children) if bottommostSibling instanceof PaneAxis - @splitDown() + this else bottommostSibling else - @splitDown() + this + + # If the parent is a vertical axis, returns its last child if it is a pane; + # otherwise returns a new pane created by splitting this pane bottomward. + findOrCreateBottommostSibling: -> + bottommostSibling = @findBottommostSibling() + if bottommostSibling is this then @splitDown() else bottommostSibling close: -> @destroy() if @confirmClose() diff --git a/src/panel-container-element.js b/src/panel-container-element.js index dbc595186..c51321181 100644 --- a/src/panel-container-element.js +++ b/src/panel-container-element.js @@ -9,6 +9,12 @@ class PanelContainerElement extends HTMLElement { this.subscriptions = new CompositeDisposable() } + attachedCallback () { + if (this.model.dock) { + this.model.dock.elementAttached() + } + } + initialize (model, {views}) { this.model = model this.views = views @@ -19,6 +25,12 @@ class PanelContainerElement extends HTMLElement { this.subscriptions.add(this.model.onDidAddPanel(this.panelAdded.bind(this))) this.subscriptions.add(this.model.onDidDestroy(this.destroyed.bind(this))) this.classList.add(this.model.getLocation()) + + // Add the dock. + if (this.model.dock != null) { + this.appendChild(this.model.dock.getElement()) + } + return this } diff --git a/src/panel-container.js b/src/panel-container.js index 377b4cd97..943fda4c4 100644 --- a/src/panel-container.js +++ b/src/panel-container.js @@ -3,11 +3,12 @@ const {Emitter, CompositeDisposable} = require('event-kit') module.exports = class PanelContainer { - constructor ({location} = {}) { + constructor ({location, dock} = {}) { this.location = location this.emitter = new Emitter() this.subscriptions = new CompositeDisposable() this.panels = [] + this.dock = dock } destroy () { diff --git a/src/project.coffee b/src/project.coffee index b9d8be32d..3145513c4 100644 --- a/src/project.coffee +++ b/src/project.coffee @@ -30,8 +30,8 @@ class Project extends Model @consumeServices(packageManager) destroyed: -> - buffer.destroy() for buffer in @buffers - repository?.destroy() for repository in @repositories + buffer.destroy() for buffer in @buffers.slice() + repository?.destroy() for repository in @repositories.slice() @rootDirectories = [] @repositories = [] diff --git a/src/register-default-commands.coffee b/src/register-default-commands.coffee index 3b3849f12..d5b741c40 100644 --- a/src/register-default-commands.coffee +++ b/src/register-default-commands.coffee @@ -57,6 +57,9 @@ module.exports = ({commandRegistry, commandInstaller, config, notificationManage 'application:open-license': -> @getModel().openLicense() 'window:run-package-specs': -> @runPackageSpecs() 'window:run-benchmarks': -> @runBenchmarks() + 'window:toggle-left-dock': -> @getModel().getLeftDock().toggle() + 'window:toggle-right-dock': -> @getModel().getRightDock().toggle() + 'window:toggle-bottom-dock': -> @getModel().getBottomDock().toggle() 'window:focus-next-pane': -> @getModel().activateNextPane() 'window:focus-previous-pane': -> @getModel().activatePreviousPane() 'window:focus-pane-above': -> @focusPaneViewAbove() diff --git a/src/workspace-center.js b/src/workspace-center.js new file mode 100644 index 000000000..efaf0654c --- /dev/null +++ b/src/workspace-center.js @@ -0,0 +1,286 @@ +'use strict' + +const TextEditor = require('./text-editor') + +module.exports = class WorkspaceCenter { + constructor (paneContainer) { + this.paneContainer = paneContainer + } + + activate () {} + + getLocation () { + return 'center' + } + + /* + Section: Event Subscription + */ + + // Essential: Invoke the given callback with all current and future text + // editors in the workspace center. + // + // * `callback` {Function} to be called with current and future text editors. + // * `editor` An {TextEditor} that is present in {::getTextEditors} at the time + // of subscription or that is added at some later time. + // + // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. + observeTextEditors (callback) { + for (let textEditor of this.getTextEditors()) { callback(textEditor) } + return this.onDidAddTextEditor(({textEditor}) => callback(textEditor)) + } + + // Essential: Invoke the given callback with all current and future panes items + // in the workspace center. + // + // * `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 workspace center. + // + // * `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 workspace + // center. + // + // * `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 + // workspace center. + // + // * `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 + // workspace center. + // + // * `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 + // workspace center. + // + // * `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 + // workspace center. + // + // * `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) + } + + /* + Section: Pane Items + */ + + // Essential: Get all pane items in the workspace center. + // + // 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() + } + + // Essential: Get all text editors in the workspace center. + // + // Returns an {Array} of {TextEditor}s. + getTextEditors () { + return this.getPaneItems().filter(item => item instanceof TextEditor) + } + + // Essential: Get the active item if it is an {TextEditor}. + // + // Returns an {TextEditor} or `undefined` if the current active item is not an + // {TextEditor}. + getActiveTextEditor () { + 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 workspace center. + // + // 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() + } + } +} diff --git a/src/workspace-element.js b/src/workspace-element.js index ae810cecd..a2fe39d5a 100644 --- a/src/workspace-element.js +++ b/src/workspace-element.js @@ -5,8 +5,9 @@ const {ipcRenderer} = require('electron') const path = require('path') const fs = require('fs-plus') -const {CompositeDisposable} = require('event-kit') +const {CompositeDisposable, Disposable} = require('event-kit') const scrollbarStyle = require('scrollbar-style') +const _ = require('underscore-plus') class WorkspaceElement extends HTMLElement { attachedCallback () { @@ -64,6 +65,14 @@ class WorkspaceElement extends HTMLElement { } initialize (model, {views, workspace, project, config, styles}) { + 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.views = views this.workspace = workspace @@ -76,7 +85,17 @@ class WorkspaceElement extends HTMLElement { if (this.config == null) { throw new Error('Must pass a config parameter when initializing WorskpaceElements') } if (this.styles == null) { throw new Error('Must pass a styles parameter when initializing WorskpaceElements') } - this.subscriptions = new CompositeDisposable() + this.subscriptions = new CompositeDisposable( + new Disposable(() => { + window.removeEventListener('mouseenter', this.handleCenterEnter) + window.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.initializeContent() this.observeScrollbarStyle() this.observeTextEditorFontConfig() @@ -86,6 +105,7 @@ class WorkspaceElement extends HTMLElement { this.addEventListener('focus', this.handleFocus.bind(this)) this.addEventListener('mousewheel', this.handleMousewheel.bind(this), true) + window.addEventListener('dragstart', this.handleDragStart) this.panelContainers = { top: this.views.getView(this.model.panelContainers.top), @@ -108,11 +128,88 @@ class WorkspaceElement extends HTMLElement { this.appendChild(this.panelContainers.modal) + this.paneContainer.addEventListener('mouseenter', this.handleCenterEnter) + this.paneContainer.addEventListener('mouseleave', this.handleCenterLeave) + return this } 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, true) + window.addEventListener('drop', this.handleDrop, 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('mousemove', this.handleEdgesMouseMove) + 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) { + // See if we've left the currently hovered dock's area. + if (this.model.hoveredDock) { + const hideToggleButton = !this.model.hoveredDock.pointWithinHoverArea(mousePosition, true) + if (hideToggleButton) { + this.model.setHoveredDock(null) + } + } + // See if we've moved over a dock. + if (this.model.hoveredDock == null) { + const hoveredDock = _.values(this.model.docks).find( + dock => dock.pointWithinHoverArea(mousePosition, false) + ) + if (hoveredDock != null) { + this.model.setHoveredDock(hoveredDock) + } + } + this.checkCleanupDockHoverEvents() + } + + checkCleanupDockHoverEvents () { + if (this.cursorInCenter && !this.model.hoveredDock) { + window.removeEventListener('mousemove', this.handleEdgesMouseMove) + 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) { @@ -182,3 +279,12 @@ class WorkspaceElement extends HTMLElement { } module.exports = document.registerElement('atom-workspace', {prototype: WorkspaceElement.prototype}) + +function isTab (element) { + let el = element + while (el != null) { + if (el.getAttribute('is') === 'tabs-tab') { return true } + el = el.parentElement + } + return false +} diff --git a/src/workspace.js b/src/workspace.js index 1916c24c6..6c9621b28 100644 --- a/src/workspace.js +++ b/src/workspace.js @@ -1,4 +1,4 @@ -'use strict' +'use babel' const _ = require('underscore-plus') const url = require('url') @@ -7,12 +7,15 @@ const {Emitter, Disposable, CompositeDisposable} = require('event-kit') const fs = require('fs-plus') const {Directory} = require('pathwatcher') const DefaultDirectorySearcher = require('./default-directory-searcher') +const Dock = require('./dock') const Model = require('./model') +const StateStore = require('./state-store') const TextEditor = require('./text-editor') const PaneContainer = require('./pane-container') const Panel = require('./panel') const PanelContainer = require('./panel-container') const Task = require('./task') +const WorkspaceCenter = require('./workspace-center') // Essential: Represents the state of the user interface for the entire window. // An instance of this class is available via the `atom.workspace` global. @@ -42,27 +45,39 @@ module.exports = class Workspace extends Model { this.assert = params.assert this.deserializerManager = params.deserializerManager this.textEditorRegistry = params.textEditorRegistry + this.hoveredDock = null + this.draggingItem = false + this.itemLocationStore = new StateStore('AtomPreviousItemLocations', 1) this.emitter = new Emitter() this.openers = [] this.destroyedItemURIs = [] this.paneContainer = new PaneContainer({ + location: 'center', config: this.config, applicationDelegate: this.applicationDelegate, notificationManager: this.notificationManager, - deserializerManager: this.deserializerManager + deserializerManager: this.deserializerManager, + viewRegistry: this.viewRegistry }) this.paneContainer.onDidDestroyPaneItem(this.didDestroyPaneItem) this.defaultDirectorySearcher = new DefaultDirectorySearcher() this.consumeServices(this.packageManager) + this.center = new WorkspaceCenter(this.paneContainer) + this.docks = { + left: this.createDock('left'), + right: this.createDock('right'), + bottom: this.createDock('bottom') + } + this.panelContainers = { top: new PanelContainer({location: 'top'}), - left: new PanelContainer({location: 'left'}), - right: new PanelContainer({location: 'right'}), - bottom: new PanelContainer({location: 'bottom'}), + left: new PanelContainer({location: 'left', dock: this.docks.left}), + right: new PanelContainer({location: 'right', dock: this.docks.right}), + bottom: new PanelContainer({location: 'bottom', dock: this.docks.bottom}), header: new PanelContainer({location: 'header'}), footer: new PanelContainer({location: 'footer'}), modal: new PanelContainer({location: 'modal'}) @@ -73,7 +88,19 @@ module.exports = class Workspace extends Model { initialize () { this.paneContainer.initialize() - this.didChangeActivePaneItem() + } + + createDock (location) { + const dock = new Dock({ + location, + config: this.config, + applicationDelegate: this.applicationDelegate, + deserializerManager: this.deserializerManager, + notificationManager: this.notificationManager, + viewRegistry: this.viewRegistry + }) + dock.onDidDestroyPaneItem(this.didDestroyPaneItem) + return dock } reset (packageManager) { @@ -85,18 +112,27 @@ module.exports = class Workspace extends Model { _.values(this.panelContainers).forEach(panelContainer => { panelContainer.destroy() }) this.paneContainer = new PaneContainer({ + location: 'center', config: this.config, applicationDelegate: this.applicationDelegate, notificationManager: this.notificationManager, - deserializerManager: this.deserializerManager + deserializerManager: this.deserializerManager, + viewRegistry: this.viewRegistry }) this.paneContainer.onDidDestroyPaneItem(this.didDestroyPaneItem) + this.center = new WorkspaceCenter(this.paneContainer) + this.docks = { + left: this.createDock('left'), + right: this.createDock('right'), + bottom: this.createDock('bottom') + } + this.panelContainers = { top: new PanelContainer({location: 'top'}), - left: new PanelContainer({location: 'left'}), - right: new PanelContainer({location: 'right'}), - bottom: new PanelContainer({location: 'bottom'}), + left: new PanelContainer({location: 'left', dock: this.docks.left}), + right: new PanelContainer({location: 'right', dock: this.docks.right}), + bottom: new PanelContainer({location: 'bottom', dock: this.docks.bottom}), header: new PanelContainer({location: 'header'}), footer: new PanelContainer({location: 'footer'}), modal: new PanelContainer({location: 'modal'}) @@ -113,6 +149,7 @@ module.exports = class Workspace extends Model { this.subscribeToActiveItem() this.subscribeToFontSize() this.subscribeToAddedItems() + this.subscribeToMovedItems() } consumeServices ({serviceHub}) { @@ -130,7 +167,12 @@ module.exports = class Workspace extends Model { deserializer: 'Workspace', paneContainer: this.paneContainer.serialize(), packagesWithActiveGrammars: this.getPackageNamesWithActiveGrammars(), - destroyedItemURIs: this.destroyedItemURIs.slice() + destroyedItemURIs: this.destroyedItemURIs.slice(), + docks: { + left: this.docks.left.serialize(), + right: this.docks.right.serialize(), + bottom: this.docks.bottom.serialize() + } } } @@ -146,7 +188,13 @@ module.exports = class Workspace extends Model { if (state.destroyedItemURIs != null) { this.destroyedItemURIs = state.destroyedItemURIs } - return this.paneContainer.deserialize(state.paneContainer, deserializerManager) + this.paneContainer.deserialize(state.paneContainer, deserializerManager) + for (let location in this.docks) { + const serialized = state.docks && state.docks[location] + if (serialized) { + this.docks[location].deserialize(serialized, deserializerManager) + } + } } getPackageNamesWithActiveGrammars () { @@ -176,6 +224,19 @@ module.exports = class Workspace extends Model { return _.uniq(packageNames) } + setHoveredDock (hoveredDock) { + this.hoveredDock = hoveredDock + _.values(this.docks).forEach(dock => { + dock.setHovered(dock === hoveredDock) + }) + } + + setDraggingItem (draggingItem) { + _.values(this.docks).forEach(dock => { + dock.setDraggingItem(draggingItem) + }) + } + subscribeToActiveItem () { this.project.onDidChangePaths(this.updateWindowTitle) this.onDidChangeActivePaneItem(this.didChangeActivePaneItem) @@ -232,6 +293,19 @@ module.exports = class Workspace extends Model { }) } + subscribeToMovedItems () { + for (const paneContainer of this.getPaneContainers()) { + paneContainer.onDidAddPaneItem(({item}) => { + if (typeof item.getURI === 'function') { + const uri = item.getURI() + if (uri != null) { + this.itemLocationStore.save(item.getURI(), paneContainer.getLocation()) + } + } + }) + } + } + // Updates the application's title and proxy icon based on whichever file is // open. updateWindowTitle () { @@ -313,7 +387,11 @@ module.exports = class Workspace extends Model { // 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) } + observePaneItems (callback) { + return new CompositeDisposable( + ...this.getPaneContainers().map(container => container.observePaneItems(callback)) + ) + } // Essential: Invoke the given callback when the active pane item changes. // @@ -380,7 +458,11 @@ module.exports = class Workspace extends Model { // * `pane` The added pane. // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidAddPane (callback) { return this.paneContainer.onDidAddPane(callback) } + onDidAddPane (callback) { + return new CompositeDisposable( + ...this.getPaneContainers().map(container => container.onDidAddPane(callback)) + ) + } // Extended: Invoke the given callback before a pane is destroyed in the // workspace. @@ -390,7 +472,11 @@ module.exports = class Workspace extends Model { // * `pane` The pane to be destroyed. // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onWillDestroyPane (callback) { return this.paneContainer.onWillDestroyPane(callback) } + onWillDestroyPane (callback) { + return new CompositeDisposable( + ...this.getPaneContainers().map(container => container.onWillDestroyPane(callback)) + ) + } // Extended: Invoke the given callback when a pane is destroyed in the // workspace. @@ -400,7 +486,11 @@ module.exports = class Workspace extends Model { // * `pane` The destroyed pane. // // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe. - onDidDestroyPane (callback) { return this.paneContainer.onDidDestroyPane(callback) } + onDidDestroyPane (callback) { + return new CompositeDisposable( + ...this.getPaneContainers().map(container => container.onDidDestroyPane(callback)) + ) + } // Extended: Invoke the given callback with all current and future panes in the // workspace. @@ -410,7 +500,11 @@ module.exports = class Workspace extends Model { // 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) } + observePanes (callback) { + return new CompositeDisposable( + ...this.getPaneContainers().map(container => container.observePanes(callback)) + ) + } // Extended: Invoke the given callback when the active pane changes. // @@ -440,7 +534,11 @@ module.exports = class Workspace extends Model { // * `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) } + onDidAddPaneItem (callback) { + return new CompositeDisposable( + ...this.getPaneContainers().map(container => container.onDidAddPaneItem(callback)) + ) + } // Extended: Invoke the given callback when a pane item is about to be // destroyed, before the user is prompted to save it. @@ -453,7 +551,11 @@ module.exports = class Workspace extends Model { // its pane. // // Returns a {Disposable} on which `.dispose` can be called to unsubscribe. - onWillDestroyPaneItem (callback) { return this.paneContainer.onWillDestroyPaneItem(callback) } + onWillDestroyPaneItem (callback) { + return new CompositeDisposable( + ...this.getPaneContainers().map(container => container.onWillDestroyPaneItem(callback)) + ) + } // Extended: Invoke the given callback when a pane item is destroyed. // @@ -465,7 +567,11 @@ module.exports = class Workspace extends Model { // pane. // // Returns a {Disposable} on which `.dispose` can be called to unsubscribe. - onDidDestroyPaneItem (callback) { return this.paneContainer.onDidDestroyPaneItem(callback) } + onDidDestroyPaneItem (callback) { + return new CompositeDisposable( + ...this.getPaneContainers().map(container => container.onDidDestroyPaneItem(callback)) + ) + } // Extended: Invoke the given callback when a text editor is added to the // workspace. @@ -513,11 +619,16 @@ module.exports = class Workspace extends Model { // activate an existing item for the given URI on any pane. // If `false`, only the active pane will be searched for // an existing item for the same URI. Defaults to `false`. + // * `location` (optional) A {String} containing the name of the location + // in which this item should be opened (one of "left", "right", "bottom", + // or "center"). If omitted, Atom will fall back to the last location in + // which a user has placed an item with the same URI or, if this is a new + // URI, the default location specified by the item. NOTE: This option + // should almost always be omitted to honor user preference. // // Returns a {Promise} that resolves to the {TextEditor} for the file URI. - open (uri_, options = {}) { + async open (uri_, options = {}) { const uri = this.project.resolvePath(uri_) - const {searchAllPanes, split} = options if (!atom.config.get('core.allowPendingPaneItems')) { options.pending = false @@ -525,42 +636,117 @@ module.exports = class Workspace extends Model { // Avoid adding URLs as recent documents to work-around this Spotlight crash: // https://github.com/atom/atom/issues/10071 - if ((uri != null) && ((url.parse(uri).protocol == null) || (process.platform === 'win32'))) { + if (uri && (!url.parse(uri).protocol || process.platform === 'win32')) { this.applicationDelegate.addRecentDocument(uri) } - let pane - if (searchAllPanes) { pane = this.paneForURI(uri) } - if (pane == null) { - switch (split) { - case 'left': - pane = this.getActivePane().findLeftmostSibling() - break - case 'right': - pane = this.getActivePane().findOrCreateRightmostSibling() - break - case 'up': - pane = this.getActivePane().findTopmostSibling() - break - case 'down': - pane = this.getActivePane().findOrCreateBottommostSibling() - break - default: - pane = this.getActivePane() - break + let container, pane, item + + // Try to find an existing item with the given URI. + if (uri) { + if (options.pane) { + pane = options.pane + } else if (options.searchAllPanes) { + pane = this.paneForURI(uri) + } else { + // The `split` option affects where we search for the item. + pane = this.getActivePane() + switch (options.split) { + case 'left': + pane = pane.findLeftmostSibling() + break + case 'right': + pane = pane.findRightmostSibling() + break + case 'up': + pane = pane.findTopmostSibling() + break + case 'down': + pane = pane.findBottommostSibling() + break + } + } + + if (pane) item = pane.itemForURI(uri) + } + + // If an item is already present, yield the event loop to ensure this method + // is consistently asynchronous regardless of the workspace state. If no + // item is present, create one. + if (item) { + await Promise.resolve() + } else { + item = await this.createItemForURI(uri, options) + if (!item) return + + if (options.pane) { + pane = options.pane + } else { + let location = options.location + if (!location && !options.split && uri) { + location = await this.itemLocationStore.load(uri) + } + if (!location && typeof item.getDefaultLocation === 'function') { + location = item.getDefaultLocation() + } + + const allowedLocations = typeof item.getAllowedLocations === 'function' ? item.getAllowedLocations() : ALL_LOCATIONS + location = allowedLocations.includes(location) ? location : allowedLocations[0] + + container = this.docks[location] || this.getCenter() + pane = container.getActivePane() + switch (options.split) { + case 'left': + pane = pane.findLeftmostSibling() + break + case 'right': + pane = pane.findOrCreateRightmostSibling() + break + case 'up': + pane = pane.findTopmostSibling() + break + case 'down': + pane = pane.findOrCreateBottommostSibling() + break + } } } - let item - if (uri != null) { - item = pane.itemForURI(uri) - } - if (item == null) { - item = this.createItemForURI(uri, options) + if (!options.pending && (pane.getPendingItem() === item)) { + pane.clearPendingItem() } - return Promise.resolve(item) - .then(item => this.openItem(item, Object.assign({pane, uri}, options))) + this.itemOpened(item) + + if (options.activateItem !== false) { + pane.activateItem(item, {pending: options.pending}) + } + + if (options.activatePane !== false) { + pane.activate() + if (!container) { + container = this.getPaneContainers().find(container => container.getPanes().includes(pane)) + } + container.activate() + } + + let initialColumn = 0 + let initialLine = 0 + if (!Number.isNaN(options.initialLine)) { + initialLine = options.initialLine + } + if (!Number.isNaN(options.initialColumn)) { + initialColumn = options.initialColumn + } + if (initialLine >= 0 || initialColumn >= 0) { + if (typeof item.setCursorBufferPosition === 'function') { + item.setCursorBufferPosition([initialLine, initialColumn]) + } + } + + const index = pane.getActiveItemIndex() + this.emitter.emit('did-open', {uri, pane, item, index}) + return item } // Open Atom's license in the active pane. @@ -609,16 +795,8 @@ module.exports = class Workspace extends Model { return item } - openURIInPane (uri, pane, options = {}) { - let item - if (uri != null) { - item = pane.itemForURI(uri) - } - if (item == null) { - item = this.createItemForURI(uri, options) - } - return Promise.resolve(item) - .then(item => this.openItem(item, Object.assign({pane, uri}, options))) + openURIInPane (uri, pane) { + return this.open(uri, {pane}) } // Returns a {Promise} that resolves to the {TextEditor} (or other item) for the given URI. @@ -661,46 +839,6 @@ module.exports = class Workspace extends Model { } } - openItem (item, options = {}) { - const {pane} = options - - if (item == null) return undefined - if (pane.isDestroyed()) return item - - if (!options.pending && (pane.getPendingItem() === item)) { - pane.clearPendingItem() - } - - const activatePane = options.activatePane != null ? options.activatePane : true - const activateItem = options.activateItem != null ? options.activateItem : true - this.itemOpened(item) - if (activateItem) { - pane.activateItem(item, {pending: options.pending}) - } - if (activatePane) { - pane.activate() - } - - let initialColumn = 0 - let initialLine = 0 - if (!Number.isNaN(options.initialLine)) { - initialLine = options.initialLine - } - if (!Number.isNaN(options.initialColumn)) { - initialColumn = options.initialColumn - } - if ((initialLine >= 0) || (initialColumn >= 0)) { - if (typeof item.setCursorBufferPosition === 'function') { - item.setCursorBufferPosition([initialLine, initialColumn]) - } - } - - const index = pane.getActiveItemIndex() - const uri = options.uri == null && typeof item.getURI === 'function' ? item.getURI() : options.uri - this.emitter.emit('did-open', {uri, pane, item, index}) - return item - } - openTextFile (uri, options) { const filePath = this.project.resolvePath(uri) @@ -821,7 +959,7 @@ module.exports = class Workspace extends Model { // // Returns an {Array} of items. getPaneItems () { - return this.paneContainer.getPaneItems() + return _.flatten(this.getPaneContainers().map(container => container.getPaneItems())) } // Essential: Get the active {Pane}'s active item. @@ -849,11 +987,15 @@ module.exports = class Workspace extends Model { // Save all pane items. saveAll () { - return this.paneContainer.saveAll() + this.getPaneContainers().forEach(container => { + container.saveAll() + }) } confirmClose (options) { - return this.paneContainer.confirmClose(options) + return this.getPaneContainers() + .map(container => container.confirmClose(options)) + .every(saved => saved) } // Save the active pane item. @@ -891,7 +1033,7 @@ module.exports = class Workspace extends Model { // // Returns an {Array} of {Pane}s. getPanes () { - return this.paneContainer.getPanes() + return _.flatten(this.getPaneContainers().map(container => container.getPanes())) } // Extended: Get the active {Pane}. @@ -917,7 +1059,12 @@ module.exports = class Workspace extends Model { // // Returns a {Pane} or `undefined` if no pane exists for the given URI. paneForURI (uri) { - return this.paneContainer.paneForURI(uri) + for (let location of this.getPaneContainers()) { + const pane = location.paneForURI(uri) + if (pane != null) { + return pane + } + } } // Extended: Get the {Pane} containing the given item. @@ -926,7 +1073,12 @@ module.exports = class Workspace extends Model { // // Returns a {Pane} or `undefined` if no pane exists for the given item. paneForItem (item) { - return this.paneContainer.paneForItem(item) + for (let location of this.getPaneContainers()) { + const pane = location.paneForItem(item) + if (pane != null) { + return pane + } + } } // Destroy (close) the active pane. @@ -942,7 +1094,7 @@ module.exports = class Workspace extends Model { closeActivePaneItemOrEmptyPaneOrWindow () { if (this.getActivePaneItem() != null) { this.destroyActivePaneItem() - } else if (this.getPanes().length > 1) { + } else if (this.getCenter().getPanes().length > 1) { this.destroyActivePane() } else if (this.config.get('core.closeEmptyWindows')) { atom.close() @@ -1013,6 +1165,61 @@ module.exports = class Workspace extends Model { } } + /* + Section: Pane Locations + */ + + getCenter () { + return this.center + } + + getLeftDock () { + return this.docks.left + } + + getRightDock () { + return this.docks.right + } + + getBottomDock () { + return this.docks.bottom + } + + getPaneContainers () { + return [this.getCenter(), ..._.values(this.docks)] + } + + toggle (uri) { + let foundItems = false + + // If any visible item has the given URI, hide it + for (const location of this.getPaneContainers()) { + const isCenter = location === this.getCenter() + if (isCenter || location.isOpen()) { + for (const pane of location.getPanes()) { + const activeItem = pane.getActiveItem() + if (activeItem != null && typeof activeItem.getURI === 'function') { + const itemURI = activeItem.getURI() + if (itemURI === uri) { + foundItems = true + // We can't really hide the center so we just destroy the item. + if (isCenter) { + pane.destroyItem(activeItem) + } else { + location.hide() + } + } + } + } + } + } + + // If no visible items had the URI, show it. + if (!foundItems) { + this.open(uri, {searchAllPanes: true}) + } + } + /* Section: Panels @@ -1421,3 +1628,5 @@ module.exports = class Workspace extends Model { } } } + +const ALL_LOCATIONS = ['center', 'left', 'right', 'bottom'] diff --git a/static/atom.less b/static/atom.less index 6a8c688e2..caa1e1c6b 100644 --- a/static/atom.less +++ b/static/atom.less @@ -18,6 +18,7 @@ // Core components @import "cursors"; @import "panels"; +@import "docks"; @import "panes"; @import "syntax"; @import "text-editor-light"; diff --git a/static/docks.less b/static/docks.less new file mode 100644 index 000000000..583409726 --- /dev/null +++ b/static/docks.less @@ -0,0 +1,215 @@ +@import 'ui-variables'; +@import 'syntax-variables'; + +@atom-dock-toggle-button-size: 50px; +@atom-dock-resize-handle-size: 4px; + +// Dock -------------- + +// The actual dock element is used as a kind of placeholder in the DOM, relative +// to which its children can be positioned. +atom-dock { + display: flex; + position: relative; +} + +.atom-dock-inner { + display: flex; + + &.bottom { width: 100%; } + &.left, &.right { height: 100%; } + + // Make sure to center the toggle buttons + &.bottom { flex-direction: column; } + align-items: center; + + // Position the docks flush with their side of the editor. + &.right { right: 0; } + &.bottom { bottom: 0; } + &.left { left: 0; } + + // Position the docks flush with their side of the editor. + &.right { right: 0; } + &.bottom { bottom: 0; } + &.left { left: 0; } + + &:not(.atom-dock-open) { + // The dock should only take up space when it's active (i.e. it shouldn't + // take up space when you're dragging something into it). + position: absolute; + z-index: 10; // An arbitrary number. Seems high enough. ¯\_(ツ)_/¯ + } +} + +.atom-dock-mask { + position: relative; + background-color: @tool-panel-background-color; + overflow: hidden; // Mask the content. + + // This shouldn't technically be necessary. Apparently, there's a bug in + // Chrome whereby the 100% width (in the bottom dock) and height (in left and + // right docks) won't actually take effect when the docks are given more + // space because another dock is hidden. Unsetting and resetting the width + // will correct the issue, as will changing its "display." However, only this + // seems to fix it without an actual runtime change occurring. + flex: 1; + + // One of these will be overridden by the component with an explicit size. + // Which depends on the position of the dock. + width: 100%; + height: 100%; + + transition: none; + &.atom-dock-should-animate { + transition: width 0.2s ease-out, height 0.2s ease-out; + } +} + +.atom-dock-content-wrapper { + position: absolute; + display: flex; + flex: 1; + align-items: stretch; + width: 100%; + height: 100%; + + // The contents of the dock should be "stuck" to the moving edge of the mask, + // so it looks like they're sliding in (instead of being unmasked in place). + &.right { left: 0; } + &.bottom { top: 0; } + &.left { right: 0; } + + // Use flex-direction to put the resize handle in the correct place. + &.left { flex-direction: row-reverse; } + &.bottom { flex-direction: column; } + &.right { flex-direction: row; } +} + +// Toggle button -------------- + +.atom-dock-toggle-button { + position: absolute; + overflow: hidden; // Mask half of the circle. + + // Must be > .scrollbar-content and inactive atom-dock + z-index: 11; + + // Position the toggle button target at the edge of the dock. It's important + // that this is absolutely positioned so that it doesn't expand the area of + // its container (which would block mouse events). + &.right { right: 100%; } + &.bottom { bottom: 100%; } + &.left { left: 100%; } + + width: @atom-dock-toggle-button-size; + height: @atom-dock-toggle-button-size; + &.bottom { height: @atom-dock-toggle-button-size / 2; } + &.left, &.right { width: @atom-dock-toggle-button-size / 2; } + + .atom-dock-toggle-button-inner { + width: @atom-dock-toggle-button-size; + height: @atom-dock-toggle-button-size; + border-radius: @atom-dock-toggle-button-size / 2; + + position: absolute; + display: flex; + text-align: center; + justify-content: center; + align-items: center; + cursor: pointer; + + &.right { left: 0; } + &.bottom { top: 0; } + &.left { right: 0; } + } + + // Hide the button. + &:not(.atom-dock-toggle-button-visible) { + .atom-dock-toggle-button-inner { + &.right { transform: translateX(50%); } + &.bottom { transform: translateY(50%); } + &.left { transform: translateX(-50%); } + } + } + + // Center the icon. + @offset: 8px; + .atom-dock-toggle-button-inner { + &.right .icon { transform: translateX(-@offset); } + &.bottom .icon { transform: translateY(-@offset); } + &.left .icon { transform: translateX(@offset); } + } + + // Animate the icon. + .icon { + transition: opacity 0.1s ease-in 0.1s; // intro + opacity: 1; + + &::before { + // Shrink the icon element to the size of the character. + width: auto; + margin: 0; + } + } + &:not(.atom-dock-toggle-button-visible) .icon { + opacity: 0; + transition: opacity 0.2s ease-out 0s; // outro + } + + .atom-dock-toggle-button-inner { + background-color: @tool-panel-background-color; + border: 1px solid @pane-item-border-color; + transition: transform 0.2s ease-out 0s; // intro + } + + &:not(.atom-dock-toggle-button-visible) { + // Don't contribute to mouseenter/drag events when not visible. + pointer-events: none; + + .atom-dock-toggle-button-inner { + transition: transform 0.2s ease-out 0.1s; // outro + } + } +} + +// Resize handle -------------- + +.atom-dock-resize-handle { + width: auto; + height: auto; + flex: 0 0 auto; + cursor: pointer; + + // Use the resize cursor when the handle's resizable + &.atom-dock-resize-handle-resizable { + &.left, &.right { cursor: col-resize; } + &.bottom { cursor: row-resize; } + } + + &.left, &.right { width: @atom-dock-resize-handle-size; } + &.bottom { height: @atom-dock-resize-handle-size; } +} + +// Cursor overlay -------------- + +.atom-dock-cursor-overlay { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 4; + + &.left, + &.right { + cursor: col-resize; + } + + &.bottom { + cursor: row-resize; + } + + &:not(.atom-dock-cursor-overlay-visible) { + display: none; + } +}