diff --git a/src/dock.js b/src/dock.js new file mode 100644 index 000000000..9bcea6e57 --- /dev/null +++ b/src/dock.js @@ -0,0 +1,506 @@ +'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 HANDLE_SIZE = 4 +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 use {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({ + config: this.config, + applicationDelegate: this.applicationDelegate, + deserializerManager: this.deserializerManager, + notificationManager: this.notificationManager, + views: 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)) + }) + ) + } + + // FIXME(matthewwithanm: This is kinda gross. We need to get a view for the pane container so we + // have to make sure that this is called after its view provider is registered. But we really + // don't have any guarantees about that. + getElement () { + 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}) + } + + toggle () { + this.setState({open: !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.viewRegistry.getView(this.resizeHandle)) + this.wrapperElement.appendChild(this.viewRegistry.getView(this.paneContainer)) + 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.viewRegistry.getView(this.toggleButton)) + } + + 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 : HANDLE_SIZE}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: state.hovered || (state.draggingItem && !shouldBeVisible) + }) + } + + handleDidAddPaneItem () { + // Show the dock if you drop an item into it. + if (this.paneContainer.getPaneItems().length === 1) { + this.setState({open: true}) + } + } + + handleDidRemovePaneItem () { + // Hide the dock if you remove the last item. + if (this.paneContainer.getPaneItems().length === 0) { + this.setState({open: 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 affordance = 20 + const toggleButtonSize = 50 / 2 // This needs to match the value in the CSS. + switch (this.location) { + case 'right': + bounds.left -= toggleButtonSize + affordance + break + case 'bottom': + bounds.top -= toggleButtonSize + affordance + break + case 'left': + bounds.right += toggleButtonSize + affordance + 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 + } + + // PaneContainer-delegating methods + + getPanes () { + return this.paneContainer.getPanes() + } + + observePanes (fn) { + return this.paneContainer.observePanes(fn) + } + + onDidAddPane (fn) { + return this.paneContainer.onDidAddPane(fn) + } + + onWillDestroyPane (fn) { + return this.paneContainer.onWillDestroyPane(fn) + } + + onDidDestroyPane (fn) { + return this.paneContainer.onDidDestroyPane(fn) + } + + paneForURI (uri) { + return this.paneContainer.paneForURI(uri) + } + + paneForItem (item) { + return this.paneContainer.paneForItem(item) + } + + getActivePane () { + return this.paneContainer.getActivePane() + } + + getPaneItems () { + return this.paneContainer.getPaneItems() + } + + getActivePaneItem () { + return this.paneContainer.getActivePaneItem() + } + + getTextEditors () { + return this.paneContainer.getTextEditors() + } + + getActiveTextEditor () { + const activeItem = this.getActivePaneItem() + if (activeItem instanceof TextEditor) { return activeItem } + } + + observePaneItems (fn) { + return this.paneContainer.observePaneItems(fn) + } + + onDidAddPaneItem (fn) { + return this.paneContainer.onDidAddPaneItem(fn) + } + + onWillDestroyPaneItem (fn) { + return this.paneContainer.onWillDestroyPaneItem(fn) + } + + onDidDestroyPaneItem (fn) { + return this.paneContainer.onDidDestroyPaneItem(fn) + } + + saveAll () { + this.paneContainer.saveAll() + } + + confirmClose (options) { + return this.paneContainer.confirmClose(options) + } +} + +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) + const widthOrHeight = getWidthOrHeight(props.location) + this.element.style[widthOrHeight] = `${HANDLE_SIZE}px` + this.props = props + this.update(props) + } + + 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) + } + + 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 + ) +} diff --git a/src/panel-container-element.js b/src/panel-container-element.js index dbc595186..2571d9875 100644 --- a/src/panel-container-element.js +++ b/src/panel-container-element.js @@ -19,6 +19,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.views.getView(this.model.dock)) + } + 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/workspace-element.js b/src/workspace-element.js index 65333e7d3..641e58992 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,86 @@ 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 + this.model.setDraggingItem(true) + window.addEventListener('dragend', this.handleDragEnd, true) + window.addEventListener('drop', this.handleDrop, true) + } + + handleDragEnd (event) { + this.dragEnded() + } + + handleDrop (event) { + this.dragEnded() + } + + dragEnded () { + this.model.setDraggingItem(false) + 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 +277,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 04b3f89e5..98e945b18 100644 --- a/src/workspace.js +++ b/src/workspace.js @@ -7,6 +7,7 @@ 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 TextEditor = require('./text-editor') const PaneContainer = require('./pane-container') @@ -42,6 +43,8 @@ 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.emitter = new Emitter() this.openers = [] @@ -59,12 +62,17 @@ module.exports = class Workspace extends Model { 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,6 +81,19 @@ module.exports = class Workspace extends Model { this.subscribeToEvents() } + 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) { this.packageManager = packageManager this.emitter.dispose() @@ -89,11 +110,18 @@ module.exports = class Workspace extends Model { }) 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'}) @@ -172,6 +200,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.updateWindowTitle() this.updateDocumentEdited() 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..c8761707a --- /dev/null +++ b/static/docks.less @@ -0,0 +1,214 @@ +@import 'ui-variables'; +@import 'syntax-variables'; + +@atom-dock-toggle-button-size: 50px; + +// 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%; + cursor: default; + -webkit-user-select: none; + white-space: nowrap; + + // 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; } + } +} + +// 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; + } +}