Mailspring/internal_packages/composer/lib/floating-toolbar-container.cjsx
Evan Morikawa 593e4fb78d fix(composer): don't blur when adding a link
Summary:
Refactor focusing behavior in floating toolbar controller

Fixes T3781
Fixes T3791

Test Plan: manual

Reviewers: bengotow

Reviewed By: bengotow

Maniphest Tasks: T3791, T3781

Differential Revision: https://phab.nylas.com/D2073
2015-09-25 16:14:01 -04:00

273 lines
9.3 KiB
CoffeeScript

_ = require 'underscore'
React = require 'react'
{Utils, DOMUtils} = require 'nylas-exports'
FloatingToolbar = require './floating-toolbar'
# This is responsible for the logic required to position a floating
# toolbar
class FloatingToolbarContainer extends React.Component
@displayName: "FloatingToolbarContainer"
@propTypes:
# A function we call when we would like to request to change the
# current selection
onSaveUrl: React.PropTypes.func
# When an extension wants to mutate the DOM, it passes `onDomMutator`
# a callback function. That callback is expecting to be passed the
# latest DOM object and may modify it in place.
onDomMutator: React.PropTypes.func
@innerPropTypes:
links: React.PropTypes.array
dragging: React.PropTypes.bool
selection: React.PropTypes.object
doubleDown: React.PropTypes.bool
editableNode: React.PropTypes.object
editableFocused: React.PropTypes.bool
constructor: (@props) ->
@state =
toolbarTop: 0
toolbarMode: "buttons"
toolbarLeft: 0
toolbarPos: "above"
editAreaWidth: 9999 # This will get set on first selection
toolbarVisible: false
linkHoveringOver: null
@_setToolbarState = _.debounce(@_setToolbarState, 10)
@innerProps =
links: []
dragging: false
selection: null
doubleDown: false
editableNode: null
toolbarFocus: false
editableFocused: null
shouldComponentUpdate: (nextProps, nextState) ->
not Utils.isEqualReact(nextProps, @props) or
not Utils.isEqualReact(nextState, @state)
# Some properties (like whether we're dragging or clicking the mouse)
# should in a strict-sense be props, but update in a way that's not
# performant to got through the full React re-rendering cycle,
# especially given the complexity of the composer component.
#
# We call these performance-optimized props & state innerProps and
# innerState.
componentWillReceiveInnerProps: (nextInnerProps) =>
@innerProps = _.extend @innerProps, nextInnerProps
@fullProps = _.extend(@innerProps, @props)
if "links" of nextInnerProps
@_refreshLinkHoverListeners()
@_setToolbarState()
componentWillReceiveProps: (nextProps) =>
@fullProps = _.extend(@innerProps, nextProps)
@_setToolbarState()
# The context menu, when activated, needs to make sure that the toolbar
# is closed. Unfortunately, since there's no onClose callback for the
# context menu, we can't hook up a reliable declarative state to the
# menu. We break our declarative pattern in this one case.
forceClose: ->
@setState toolbarVisible: false
render: ->
<FloatingToolbar
ref="floatingToolbar"
top={@state.toolbarTop}
left={@state.toolbarLeft}
pos={@state.toolbarPos}
mode={@state.toolbarMode}
visible={@state.toolbarVisible}
onSaveUrl={@props.onSaveUrl}
onMouseEnter={@_onEnterToolbar}
onChangeMode={@_onChangeMode}
onMouseLeave={@_onLeaveToolbar}
onDomMutator={@props.onDomMutator}
linkToModify={@state.linkToModify}
onChangeFocus={@_onChangeFocus}
editAreaWidth={@state.editAreaWidth}
contentPadding={@CONTENT_PADDING}
onDoneWithLink={@_onDoneWithLink}
onClickLinkEditBtn={@_onClickLinkEditBtn} />
# Called when a user clicks the "link" icon on the FloatingToolbar
_onClickLinkEditBtn: =>
@setState toolbarMode: "edit-link"
# A user could be done with a link because they're setting a new one, or
# clearing one, or just canceling.
_onDoneWithLink: =>
@componentWillReceiveInnerProps linkHoveringOver: null
@setState
toolbarMode: "buttons"
toolbarVisible: false
return
# We explicitly control the focus of the FloatingToolbar because we can
# do things like switch from "buttons" mode to "edit-link" mode (which
# natively fires focus change events) but not want to signify a "focus"
# change
_onChangeFocus: (focus) =>
@componentWillReceiveInnerProps toolbarFocus: focus
# We want the toolbar's state to be declaratively defined from other
# states.
_setToolbarState: =>
props = @fullProps ? {}
return if props.dragging or (props.doubleDown and not @state.toolbarVisible)
if props.toolbarFocus
@setState toolbarVisible: true
return
if @_shouldHideToolbar(props)
@setState
toolbarVisible: false
toolbarMode: "buttons"
return
if props.linkHoveringOver
url = props.linkHoveringOver.getAttribute('href')
rect = props.linkHoveringOver.getBoundingClientRect()
[left, top, editAreaWidth, toolbarPos] = @_getToolbarPos(rect)
@setState
toolbarVisible: true
toolbarMode: "edit-link"
toolbarTop: top
toolbarLeft: left
toolbarPos: toolbarPos
linkToModify: props.linkHoveringOver
editAreaWidth: editAreaWidth
else
# return if @state.toolbarMode is "edit-link"
rect = DOMUtils.getRangeInScope(props.editableNode)?.getBoundingClientRect()
if not rect or DOMUtils.isEmptyBoudingRect(rect)
@setState
toolbarVisible: false
toolbarMode: "buttons"
else
[left, top, editAreaWidth, toolbarPos] = @_getToolbarPos(rect)
@setState
toolbarVisible: true
toolbarTop: top
toolbarLeft: left
toolbarPos: toolbarPos
linkToModify: null
editAreaWidth: editAreaWidth
_shouldHideToolbar: (props) ->
return false if @state.toolbarMode is "edit-link"
return false if props.linkHoveringOver
return not props.editableFocused or
not props.selection or
props.selection.isCollapsed
_refreshLinkHoverListeners: ->
@_teardownLinkHoverListeners()
@_links = {}
links = Array.prototype.slice.call(@innerProps.links)
links.forEach (link) =>
link.hoverId = Utils.generateTempId()
@_links[link.hoverId] = {}
context = this
enterListener = (event) ->
link = this
context._onEnterLink.call(context, link, event)
leaveListener = (event) ->
link = this
context._onLeaveLink.call(context, link, event)
link.addEventListener "mouseenter", enterListener
link.addEventListener "mouseleave", leaveListener
@_links[link.hoverId].link = link
@_links[link.hoverId].enterListener = enterListener
@_links[link.hoverId].leaveListener = leaveListener
_onEnterLink: (link, event) =>
HOVER_IN_DELAY = 250
@_clearLinkTimeouts()
@_links[link.hoverId].enterTimeout = setTimeout =>
@componentWillReceiveInnerProps linkHoveringOver: link
, HOVER_IN_DELAY
_onLeaveLink: (link, event) =>
HOVER_OUT_DELAY = 500
@_clearLinkTimeouts()
@_links[link.hoverId].leaveTimeout = setTimeout =>
@componentWillReceiveInnerProps linkHoveringOver: null
, HOVER_OUT_DELAY
_onEnterToolbar: (event) =>
clearTimeout(@_clearTooltipTimeout) if @_clearTooltipTimeout?
# 1. Hover over a link until the toolbar appears.
# 2. The toolbar's link input will be UNfocused
# 3. Moving the mouse off the link and over the toolbar will cause
# _onLinkLeave to fire. Before the `leaveTimeout` fires, clear it
# since our mouse has safely made it to the tooltip.
@_clearLinkTimeouts()
# Called when the mouse leaves the "edit-link" mode toolbar.
#
# NOTE: The leave callback does NOT get called if the user has the input
# field focused. We don't want the make the box dissapear under the user
# when they're typing.
_onLeaveToolbar: (event) =>
HOVER_OUT_DELAY = 250
@_clearTooltipTimeout = setTimeout =>
# If we've hovered over a link until the toolbar appeared, then
# `linkHoverOver` will be set to that link. When we move the mouse
# onto the toolbar, `_onEnterToolbar` will make sure that
# `linkHoveringOver` doesn't get cleared. If we then move our mouse
# off of the toolbar, we need to remember to clear the hovering
# link.
@componentWillReceiveInnerProps linkHoveringOver: null
, 250
_clearLinkTimeouts: ->
for hoverId, linkData of @_links
clearTimeout(linkData.enterTimeout) if linkData.enterTimeout?
clearTimeout(linkData.leaveTimeout) if linkData.leaveTimeout?
_teardownLinkHoverListeners: =>
for hoverId, linkData of @_links
clearTimeout linkData.enterTimeout
clearTimeout linkData.leaveTimeout
linkData.link.removeEventListener "mouseenter", linkData.enterListener
linkData.link.removeEventListener "mouseleave", linkData.leaveListener
@_links = {}
CONTENT_PADDING: 15
_getToolbarPos: (referenceRect) =>
return [0,0,0,0] unless @innerProps.editableNode
TOP_PADDING = 10
BORDER_RADIUS_PADDING = 15
editArea = @innerProps.editableNode.getBoundingClientRect()
calcLeft = (referenceRect.left - editArea.left) + referenceRect.width/2
calcLeft = Math.min(Math.max(calcLeft, @CONTENT_PADDING+BORDER_RADIUS_PADDING), editArea.width - BORDER_RADIUS_PADDING)
calcTop = referenceRect.top - editArea.top - 48
toolbarPos = "above"
if calcTop < TOP_PADDING
calcTop = referenceRect.top - editArea.top + referenceRect.height + TOP_PADDING + 4
toolbarPos = "below"
return [calcLeft, calcTop, editArea.width, toolbarPos]
_focusedOnToolbar: =>
React.findDOMNode(@refs.floatingToolbar)?.contains(document.activeElement)
module.exports = FloatingToolbarContainer