'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': bounds.right = Number.POSITIVE_INFINITY break case 'bottom': bounds.bottom = Number.POSITIVE_INFINITY break case 'left': 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) }