Files
atom/src/dock.js
Antonio Scandurra 8077f46fdf Programmatically detect when mouse approaches the edge of a dock
Previously, we would assign dock elements a minimum width/height of 2
pixels so that we could detect when the mouse approached the edge of a
hidden dock in order to show the chevron buttons. This, however, was
causing confusion for users, who expected that extra space to be
clickable in order to scroll editors located in the center dock.

With this commit we will instead register a global `mousemove` event on
the window right when attaching the workspace element to the DOM (the
event handler is debounced, so this shouldn't have any performance
consequence). Then, when mouse moves, we will programmatically detect
when it is approaching to the edge of a dock and show the chevron button
accordingly. This allows us to remove the `min-width` property from the
dock container element, which eliminates the confusing behavior
described above.
2018-01-12 17:12:55 +01:00

797 lines
26 KiB
JavaScript

'use strict'
const _ = require('underscore-plus')
const {CompositeDisposable, Emitter} = require('event-kit')
const PaneContainer = require('./pane-container')
const TextEditor = require('./text-editor')
const Grim = require('grim')
const MINIMUM_SIZE = 100
const DEFAULT_INITIAL_SIZE = 300
const SHOULD_ANIMATE_CLASS = 'atom-dock-should-animate'
const VISIBLE_CLASS = 'atom-dock-open'
const RESIZE_HANDLE_RESIZABLE_CLASS = 'atom-dock-resize-handle-resizable'
const TOGGLE_BUTTON_VISIBLE_CLASS = 'atom-dock-toggle-button-visible'
const CURSOR_OVERLAY_VISIBLE_CLASS = 'atom-dock-cursor-overlay-visible'
// Extended: A container at the edges of the editor window capable of holding items.
// You should not create a Dock directly. Instead, access one of the three docks of the workspace
// via {Workspace::getLeftDock}, {Workspace::getRightDock}, and {Workspace::getBottomDock}
// or add an item to a dock via {Workspace::open}.
module.exports = class Dock {
constructor (params) {
this.handleResizeHandleDragStart = this.handleResizeHandleDragStart.bind(this)
this.handleResizeToFit = this.handleResizeToFit.bind(this)
this.handleMouseMove = this.handleMouseMove.bind(this)
this.handleMouseUp = this.handleMouseUp.bind(this)
this.handleDrag = _.throttle(this.handleDrag.bind(this), 30)
this.handleDragEnd = this.handleDragEnd.bind(this)
this.location = params.location
this.widthOrHeight = getWidthOrHeight(this.location)
this.config = params.config
this.applicationDelegate = params.applicationDelegate
this.deserializerManager = params.deserializerManager
this.notificationManager = params.notificationManager
this.viewRegistry = params.viewRegistry
this.didActivate = params.didActivate
this.emitter = new Emitter()
this.paneContainer = new PaneContainer({
location: this.location,
config: this.config,
applicationDelegate: this.applicationDelegate,
deserializerManager: this.deserializerManager,
notificationManager: this.notificationManager,
viewRegistry: this.viewRegistry
})
this.state = {
size: null,
visible: false,
shouldAnimate: false
}
this.subscriptions = new CompositeDisposable(
this.emitter,
this.paneContainer.onDidActivatePane(() => {
this.show()
this.didActivate(this)
}),
this.paneContainer.observePanes(pane => {
pane.onDidAddItem(this.handleDidAddPaneItem.bind(this))
pane.onDidRemoveItem(this.handleDidRemovePaneItem.bind(this))
}),
this.paneContainer.onDidChangeActivePane((item) => params.didChangeActivePane(this, item)),
this.paneContainer.onDidChangeActivePaneItem((item) => params.didChangeActivePaneItem(this, item)),
this.paneContainer.onDidDestroyPaneItem((item) => params.didDestroyPaneItem(item))
)
}
// This method is called explicitly by the object which adds the Dock to the document.
elementAttached () {
// Re-render when the dock is attached to make sure we remeasure sizes defined in CSS.
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()
window.removeEventListener('mousemove', this.handleMouseMove)
window.removeEventListener('mouseup', this.handleMouseUp)
window.removeEventListener('drag', this.handleDrag)
window.removeEventListener('dragend', this.handleDragEnd)
}
setHovered (hovered) {
if (hovered === this.state.hovered) return
this.setState({hovered})
}
setDraggingItem (draggingItem) {
if (draggingItem === this.state.draggingItem) return
this.setState({draggingItem})
}
// Extended: Show the dock and focus its active {Pane}.
activate () {
this.getActivePane().activate()
}
// Extended: Show the dock without focusing it.
show () {
this.setState({visible: true})
}
// Extended: Hide the dock and activate the {WorkspaceCenter} if the dock was
// was previously focused.
hide () {
this.setState({visible: false})
}
// Extended: Toggle the dock's visibility without changing the {Workspace}'s
// active pane container.
toggle () {
const state = {visible: !this.state.visible}
if (!state.visible) state.hovered = false
this.setState(state)
}
// Extended: Check if the dock is visible.
//
// Returns a {Boolean}.
isVisible () {
return this.state.visible
}
setState (newState) {
const prevState = this.state
const nextState = Object.assign({}, prevState, newState)
// Update the `shouldAnimate` state. This needs to be written to the DOM before updating the
// class that changes the animated property. Normally we'd have to defer the class change a
// frame to ensure the property is animated (or not) appropriately, however we luck out in this
// case because the drag start always happens before the item is dragged into the toggle button.
if (nextState.visible !== prevState.visible) {
// Never animate toggling visibility...
nextState.shouldAnimate = false
} else if (!nextState.visible && nextState.draggingItem && !prevState.draggingItem) {
// ...but do animate if you start dragging while the panel is hidden.
nextState.shouldAnimate = true
}
this.state = nextState
this.render(this.state)
const {visible} = this.state
if (visible !== prevState.visible) {
this.emitter.emit('did-change-visible', visible)
}
}
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,
onResizeToFit: this.handleResizeToFit
})
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.visible) {
this.innerElement.classList.add(VISIBLE_CLASS)
} else {
this.innerElement.classList.remove(VISIBLE_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.visible || state.showDropTarget
const size = Math.max(MINIMUM_SIZE,
state.size ||
(state.draggingItem && getPreferredSize(state.draggingItem, this.location)) ||
DEFAULT_INITIAL_SIZE
)
// We need to change the size of the mask...
this.maskElement.style[this.widthOrHeight] = `${shouldBeVisible ? size : 0}px`
// ...but the content needs to maintain a constant size.
this.wrapperElement.style[this.widthOrHeight] = `${size}px`
this.resizeHandle.update({dockIsVisible: this.state.visible})
this.toggleButton.update({
dockIsVisible: shouldBeVisible,
visible:
// Don't show the toggle button if the dock is closed and empty...
(state.hovered && (this.state.visible || this.getPaneItems().length > 0)) ||
// ...or if the item can't be dropped in that dock.
(!shouldBeVisible && state.draggingItem && isItemAllowed(state.draggingItem, this.location))
})
}
handleDidAddPaneItem () {
if (this.state.size == null) {
this.setState({size: this.getInitialSize()})
}
}
handleDidRemovePaneItem () {
// Hide the dock if you remove the last item.
if (this.paneContainer.getPaneItems().length === 0) {
this.setState({visible: false, hovered: false, size: null})
}
}
handleResizeHandleDragStart () {
window.addEventListener('mousemove', this.handleMouseMove)
window.addEventListener('mouseup', this.handleMouseUp)
this.setState({resizing: true})
}
handleResizeToFit () {
const item = this.getActivePaneItem()
if (item) {
const size = getPreferredSize(item, this.getLocation())
if (size != null) this.setState({size})
}
}
handleMouseMove (event) {
if (event.buttons === 0) { // We missed the mouseup event. For some reason it happens on Windows
this.handleMouseUp(event)
return
}
let size = 0
switch (this.location) {
case 'left':
size = event.pageX - this.element.getBoundingClientRect().left
break
case 'bottom':
size = this.element.getBoundingClientRect().bottom - event.pageY
break
case 'right':
size = this.element.getBoundingClientRect().right - event.pageX
break
}
this.setState({size})
}
handleMouseUp (event) {
window.removeEventListener('mousemove', this.handleMouseMove)
window.removeEventListener('mouseup', this.handleMouseUp)
this.setState({resizing: false})
}
handleToggleButtonDragEnter () {
this.setState({showDropTarget: true})
window.addEventListener('drag', this.handleDrag)
window.addEventListener('dragend', this.handleDragEnd)
}
handleDrag (event) {
if (!this.pointWithinHoverArea({x: event.pageX, y: event.pageY}, 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 = this.state.hovered) {
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':
if (!this.isVisible()) bounds.left = bounds.right - 2
bounds.right = Number.POSITIVE_INFINITY
break
case 'bottom':
if (!this.isVisible()) bounds.top = bounds.bottom - 1
bounds.bottom = Number.POSITIVE_INFINITY
break
case 'left':
if (!this.isVisible()) bounds.right = bounds.left + 2
bounds.left = Number.NEGATIVE_INFINITY
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 () {
// The item may not have been activated yet. If that's the case, just use the first item.
const activePaneItem = this.paneContainer.getActivePaneItem() || this.paneContainer.getPaneItems()[0]
// If there are items, we should have an explicit width; if not, we shouldn't.
return activePaneItem
? getPreferredSize(activePaneItem, this.location) || DEFAULT_INITIAL_SIZE
: null
}
serialize () {
return {
deserializer: 'Dock',
size: this.state.size,
paneContainer: this.paneContainer.serialize(),
visible: this.state.visible
}
}
deserialize (serialized, deserializerManager) {
this.paneContainer.deserialize(serialized.paneContainer, deserializerManager)
this.setState({
size: serialized.size || this.getInitialSize(),
// If no items could be deserialized, we don't want to show the dock (even if it was visible last time)
visible: serialized.visible && (this.paneContainer.getPaneItems().length > 0)
})
}
/*
Section: Event Subscription
*/
// Essential: Invoke the given callback when the visibility of the dock changes.
//
// * `callback` {Function} to be called when the visibility changes.
// * `visible` {Boolean} Is the dock now visible?
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidChangeVisible (callback) {
return this.emitter.on('did-change-visible', callback)
}
// Essential: Invoke the given callback with the current and all future visibilities of the dock.
//
// * `callback` {Function} to be called when the visibility changes.
// * `visible` {Boolean} Is the dock now visible?
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
observeVisible (callback) {
callback(this.isVisible())
return this.onDidChangeVisible(callback)
}
// Essential: Invoke the given callback with all current and future panes items
// in the dock.
//
// * `callback` {Function} to be called with current and future pane items.
// * `item` An item that is present in {::getPaneItems} at the time of
// subscription or that is added at some later time.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
observePaneItems (callback) {
return this.paneContainer.observePaneItems(callback)
}
// Essential: Invoke the given callback when the active pane item changes.
//
// Because observers are invoked synchronously, it's important not to perform
// any expensive operations via this method. Consider
// {::onDidStopChangingActivePaneItem} to delay operations until after changes
// stop occurring.
//
// * `callback` {Function} to be called when the active pane item changes.
// * `item` The active pane item.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidChangeActivePaneItem (callback) {
return this.paneContainer.onDidChangeActivePaneItem(callback)
}
// Essential: Invoke the given callback when the active pane item stops
// changing.
//
// Observers are called asynchronously 100ms after the last active pane item
// change. Handling changes here rather than in the synchronous
// {::onDidChangeActivePaneItem} prevents unneeded work if the user is quickly
// changing or closing tabs and ensures critical UI feedback, like changing the
// highlighted tab, gets priority over work that can be done asynchronously.
//
// * `callback` {Function} to be called when the active pane item stopts
// changing.
// * `item` The active pane item.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidStopChangingActivePaneItem (callback) {
return this.paneContainer.onDidStopChangingActivePaneItem(callback)
}
// Essential: Invoke the given callback with the current active pane item and
// with all future active pane items in the dock.
//
// * `callback` {Function} to be called when the active pane item changes.
// * `item` The current active pane item.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
observeActivePaneItem (callback) {
return this.paneContainer.observeActivePaneItem(callback)
}
// Extended: Invoke the given callback when a pane is added to the dock.
//
// * `callback` {Function} to be called panes are added.
// * `event` {Object} with the following keys:
// * `pane` The added pane.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidAddPane (callback) {
return this.paneContainer.onDidAddPane(callback)
}
// Extended: Invoke the given callback before a pane is destroyed in the
// dock.
//
// * `callback` {Function} to be called before panes are destroyed.
// * `event` {Object} with the following keys:
// * `pane` The pane to be destroyed.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onWillDestroyPane (callback) {
return this.paneContainer.onWillDestroyPane(callback)
}
// Extended: Invoke the given callback when a pane is destroyed in the dock.
//
// * `callback` {Function} to be called panes are destroyed.
// * `event` {Object} with the following keys:
// * `pane` The destroyed pane.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidDestroyPane (callback) {
return this.paneContainer.onDidDestroyPane(callback)
}
// Extended: Invoke the given callback with all current and future panes in the
// dock.
//
// * `callback` {Function} to be called with current and future panes.
// * `pane` A {Pane} that is present in {::getPanes} at the time of
// subscription or that is added at some later time.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
observePanes (callback) {
return this.paneContainer.observePanes(callback)
}
// Extended: Invoke the given callback when the active pane changes.
//
// * `callback` {Function} to be called when the active pane changes.
// * `pane` A {Pane} that is the current return value of {::getActivePane}.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidChangeActivePane (callback) {
return this.paneContainer.onDidChangeActivePane(callback)
}
// Extended: Invoke the given callback with the current active pane and when
// the active pane changes.
//
// * `callback` {Function} to be called with the current and future active#
// panes.
// * `pane` A {Pane} that is the current return value of {::getActivePane}.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
observeActivePane (callback) {
return this.paneContainer.observeActivePane(callback)
}
// Extended: Invoke the given callback when a pane item is added to the dock.
//
// * `callback` {Function} to be called when pane items are added.
// * `event` {Object} with the following keys:
// * `item` The added pane item.
// * `pane` {Pane} containing the added item.
// * `index` {Number} indicating the index of the added item in its pane.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidAddPaneItem (callback) {
return this.paneContainer.onDidAddPaneItem(callback)
}
// Extended: Invoke the given callback when a pane item is about to be
// destroyed, before the user is prompted to save it.
//
// * `callback` {Function} to be called before pane items are destroyed.
// * `event` {Object} with the following keys:
// * `item` The item to be destroyed.
// * `pane` {Pane} containing the item to be destroyed.
// * `index` {Number} indicating the index of the item to be destroyed in
// its pane.
//
// Returns a {Disposable} on which `.dispose` can be called to unsubscribe.
onWillDestroyPaneItem (callback) {
return this.paneContainer.onWillDestroyPaneItem(callback)
}
// Extended: Invoke the given callback when a pane item is destroyed.
//
// * `callback` {Function} to be called when pane items are destroyed.
// * `event` {Object} with the following keys:
// * `item` The destroyed item.
// * `pane` {Pane} containing the destroyed item.
// * `index` {Number} indicating the index of the destroyed item in its
// pane.
//
// Returns a {Disposable} on which `.dispose` can be called to unsubscribe.
onDidDestroyPaneItem (callback) {
return this.paneContainer.onDidDestroyPaneItem(callback)
}
/*
Section: Pane Items
*/
// Essential: Get all pane items in the dock.
//
// Returns an {Array} of items.
getPaneItems () {
return this.paneContainer.getPaneItems()
}
// Essential: Get the active {Pane}'s active item.
//
// Returns an pane item {Object}.
getActivePaneItem () {
return this.paneContainer.getActivePaneItem()
}
// Deprecated: Get the active item if it is a {TextEditor}.
//
// Returns a {TextEditor} or `undefined` if the current active item is not a
// {TextEditor}.
getActiveTextEditor () {
Grim.deprecate('Text editors are not allowed in docks. Use atom.workspace.getActiveTextEditor() instead.')
const activeItem = this.getActivePaneItem()
if (activeItem instanceof TextEditor) { return activeItem }
}
// Save all pane items.
saveAll () {
this.paneContainer.saveAll()
}
confirmClose (options) {
return this.paneContainer.confirmClose(options)
}
/*
Section: Panes
*/
// Extended: Get all panes in the dock.
//
// Returns an {Array} of {Pane}s.
getPanes () {
return this.paneContainer.getPanes()
}
// Extended: Get the active {Pane}.
//
// Returns a {Pane}.
getActivePane () {
return this.paneContainer.getActivePane()
}
// Extended: Make the next pane active.
activateNextPane () {
return this.paneContainer.activateNextPane()
}
// Extended: Make the previous pane active.
activatePreviousPane () {
return this.paneContainer.activatePreviousPane()
}
paneForURI (uri) {
return this.paneContainer.paneForURI(uri)
}
paneForItem (item) {
return this.paneContainer.paneForItem(item)
}
// Destroy (close) the active pane.
destroyActivePane () {
const activePane = this.getActivePane()
if (activePane != null) {
activePane.destroy()
}
}
}
class DockResizeHandle {
constructor (props) {
this.handleMouseDown = this.handleMouseDown.bind(this)
this.element = document.createElement('div')
this.element.classList.add('atom-dock-resize-handle', props.location)
this.element.addEventListener('mousedown', this.handleMouseDown)
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.dockIsVisible) {
this.element.classList.add(RESIZE_HANDLE_RESIZABLE_CLASS)
} else {
this.element.classList.remove(RESIZE_HANDLE_RESIZABLE_CLASS)
}
}
handleMouseDown (event) {
if (event.detail === 2) {
this.props.onResizeToFit()
} else if (this.props.dockIsVisible) {
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
}
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.dockIsVisible)
}
handleClick () {
this.props.toggle()
}
handleDragEnter () {
this.props.onDragEnter()
}
}
function getWidthOrHeight (location) {
return location === 'left' || location === 'right' ? 'width' : 'height'
}
function getPreferredSize (item, location) {
switch (location) {
case 'left':
case 'right':
return typeof item.getPreferredWidth === 'function'
? item.getPreferredWidth()
: null
default:
return typeof item.getPreferredHeight === 'function'
? item.getPreferredHeight()
: null
}
}
function getIconName (location, visible) {
switch (location) {
case 'right': return visible ? 'icon-chevron-right' : 'icon-chevron-left'
case 'bottom': return visible ? 'icon-chevron-down' : 'icon-chevron-up'
case 'left': return visible ? 'icon-chevron-left' : 'icon-chevron-right'
default: throw new Error(`Invalid location: ${location}`)
}
}
function rectContainsPoint (rect, point) {
return (
point.x >= rect.left &&
point.y >= rect.top &&
point.x <= rect.right &&
point.y <= rect.bottom
)
}
// Is the item allowed in the given location?
function isItemAllowed (item, location) {
if (typeof item.getAllowedLocations !== 'function') return true
return item.getAllowedLocations().includes(location)
}