fix(composer): Show a scrollbar in the popout composer

Summary:
Fixes T1990

Change the ScrollRegion component so that you can optionally provide a getScrollbar prop that resolves to a ScrollRegion.Scrollbar component. This allows you to easily put the Scrollbar outside of the ScrollRegion if necessary.

Test Plan: Run tests

Reviewers: evan

Reviewed By: evan

Maniphest Tasks: T1990

Differential Revision: https://phab.nylas.com/D1665
This commit is contained in:
Ben Gotow 2015-06-23 15:21:25 -07:00
parent ffe87e4e9c
commit 8c764ca75d
5 changed files with 255 additions and 163 deletions

View file

@ -11,6 +11,7 @@ _ = require 'underscore'
{ResizableRegion,
InjectedComponentSet,
InjectedComponent,
ScrollRegion,
RetinaImg} = require 'nylas-component-kit'
FileUpload = require './file-upload'
@ -151,58 +152,46 @@ class ComposerView extends React.Component
_renderComposer: =>
<div className="composer-inner-wrap" onDragOver={@_onDragNoop} onDragLeave={@_onDragNoop} onDragEnd={@_onDragNoop} onDrop={@_onDrop}>
<div className="composer-cover"
style={display: (if @state.isSending then "block" else "none")}>
</div>
<div className="composer-content-wrap">
{@_renderBodyScrollbar()}
<div className="composer-participant-actions">
<span className="header-action"
style={display: @state.showcc and 'none' or 'inline'}
onClick={=> @_showAndFocusCc()}>Cc</span>
<div className="composer-centered">
<div className="composer-participant-actions">
<span className="header-action"
style={display: @state.showcc and 'none' or 'inline'}
onClick={=> @_showAndFocusCc()}>Cc</span>
<span className="header-action"
style={display: @state.showbcc and 'none' or 'inline'}
onClick={=> @_showAndFocusBcc()}>Bcc</span>
<span className="header-action"
style={display: @state.showbcc and 'none' or 'inline'}
onClick={=> @_showAndFocusBcc()}>Bcc</span>
<span className="header-action"
style={display: @state.showsubject and 'none' or 'initial'}
onClick={=> @setState {showsubject: true}}>Subject</span>
<span className="header-action"
style={display: @state.showsubject and 'none' or 'initial'}
onClick={=> @setState {showsubject: true}}>Subject</span>
<span className="header-action"
data-tooltip="Popout composer"
style={{display: ((@props.mode is "fullwindow") and 'none' or 'initial'), paddingLeft: "1.5em"}}
onClick={@_popoutComposer}>
<RetinaImg name="composer-popout.png"
mode={RetinaImg.Mode.ContentIsMask}
style={{position: "relative", top: "-2px"}}/>
</span>
<span className="header-action"
data-tooltip="Popout composer"
style={{display: ((@props.mode is "fullwindow") and 'none' or 'initial'), paddingLeft: "1.5em"}}
onClick={@_popoutComposer}>
<RetinaImg name="composer-popout.png"
mode={RetinaImg.Mode.ContentIsMask}
style={{position: "relative", top: "-2px"}}/>
</span>
</div>
{@_renderFields()}
<div className="compose-body" ref="composeBody" onClick={@_onClickComposeBody}>
{@_renderBody()}
{@_renderFooterRegions()}
</div>
</div>
{@_renderFields()}
<div className="compose-body"
ref="composeBody"
onClick={@_onClickComposeBody}>
<ContenteditableComponent ref="contentBody"
html={@state.body}
onChange={@_onChangeBody}
onFilePaste={@_onFilePaste}
style={@_precalcComposerCss}
initialSelectionSnapshot={@_recoveredSelection}
mode={{showQuotedText: @state.showQuotedText}}
onChangeMode={@_onChangeEditableMode}
onRequestScrollTo={@props.onRequestScrollTo}
tabIndex="109" />
{@_renderFooterRegions()}
</div>
</div>
<div className="composer-action-bar-wrap">
{@_renderActionsRegion()}
</div>
@ -263,6 +252,31 @@ class ComposerView extends React.Component
fields
_renderBodyScrollbar: =>
if @props.mode is "inline"
[]
else
<ScrollRegion.Scrollbar ref="scrollbar" getScrollRegion={ => @refs.scrollregion } />
_renderBody: =>
if @props.mode is "inline"
@_renderBodyContenteditable()
else
<ScrollRegion className="compose-body-scroll" ref="scrollregion" getScrollbar={ => @refs.scrollbar }>
{@_renderBodyContenteditable()}
</ScrollRegion>
_renderBodyContenteditable: =>
<ContenteditableComponent ref="contentBody"
html={@state.body}
onChange={@_onChangeBody}
onFilePaste={@_onFilePaste}
style={@_precalcComposerCss}
initialSelectionSnapshot={@_recoveredSelection}
mode={{showQuotedText: @state.showQuotedText}}
onChangeMode={@_onChangeEditableMode}
onRequestScrollTo={@props.onRequestScrollTo}
tabIndex="109" />
_renderFooterRegions: =>
return <div></div> unless @props.localId
@ -389,10 +403,10 @@ class ComposerView extends React.Component
Utils.isForwardedMessage(draft)
# This lets us click outside of the `contenteditable`'s `contentBody`
# and still focus on the contenteditable
# and simulate what happens when you click beneath the text *in* the
# contentEditable.
_onClickComposeBody: (event) =>
if event.target is React.findDOMNode(@refs.composeBody)
@focus("contentBody")
@refs.contentBody.selectEnd()
_onDraftChanged: =>
return unless @_proxy

View file

@ -99,6 +99,7 @@ class ContenteditableComponent extends React.Component
tabIndex={@props.tabIndex}
style={@props.style ? {}}
onBlur={@_onBlur}
onClick={@_onClick}
onPaste={@_onPaste}
onInput={@_onInput}
dangerouslySetInnerHTML={@_dangerouslySetInnerHTML()}></div>
@ -108,6 +109,21 @@ class ContenteditableComponent extends React.Component
focus: =>
@_editableNode().focus()
selectEnd: =>
range = document.createRange()
range.selectNodeContents(@_editableNode())
range.collapse(false)
@_editableNode().focus()
selection = window.getSelection()
selection.removeAllRanges()
selection.addRange(range)
_onClick: (event) ->
# We handle mouseDown, mouseMove, mouseUp, but we want to stop propagation
# of `click` to make it clear that we've handled the event.
# Note: Related to composer-view#_onClickComposeBody
event.stopPropagation()
_onInput: (event) =>
@_dragging = false

View file

@ -44,20 +44,24 @@
}
.composer-content-wrap {
position: relative;
z-index: 1;
width: 100%;
max-width: @compose-width;
margin: 0 auto;
padding: 0;
padding-top: 5px;
flex: 1;
display: flex;
position: relative;
flex-flow: column;
}
.composer-centered {
display:flex;
position: relative;
flex-direction: column;
flex: 1;
width: 100%;
max-width: @compose-width;
margin: 0 auto;
margin-top:@spacing-standard;
}
.text-actions {
text-align: right;
line-height: 1.4;
@ -133,6 +137,10 @@
}
}
.compose-body-scroll {
position:initial;
}
.compose-body {
flex: 1;
z-index: 1;
@ -200,10 +208,6 @@ body.is-blurred .composer-inner-wrap .tokenizing-field .token {
height: 100%;
}
.composer-content-wrap {
margin-top: @spacing-standard;
}
.composer-inner-wrap {
.composer-action-bar-wrap {

View file

@ -3,10 +3,109 @@ React = require 'react/addons'
{Utils} = require 'nylas-exports'
classNames = require 'classnames'
class Scrollbar extends React.Component
@displayName: 'Scrollbar'
@propTypes:
scrollTooltipComponent: React.PropTypes.func
getScrollRegion: React.PropTypes.func
constructor: (@props) ->
@state =
totalHeight: 0
trackHeight: 0
viewportHeight: 0
viewportScrollTop: 0
dragging: false
scrolling: false
componentWillUnmount: =>
@_onHandleUp({preventDefault: -> })
setStateFromScrollRegion: (state) ->
@setState(state)
render: ->
containerClasses = classNames
'scrollbar-track': true
'dragging': @state.dragging
'scrolling': @state.scrolling
tooltip = []
if @props.scrollTooltipComponent
tooltip = <@props.scrollTooltipComponent viewportCenter={@state.viewportScrollTop + @state.viewportHeight / 2} totalHeight={@state.totalHeight} />
<div className={containerClasses} style={@_scrollbarWrapStyles()} onMouseEnter={@recomputeDimensions}>
<div className="scrollbar-track-inner" ref="track" onClick={@_onScrollJump}>
<div className="scrollbar-handle" onMouseDown={@_onHandleDown} style={@_scrollbarHandleStyles()} ref="handle" onClick={@_onHandleClick} >
<div className="tooltip">{tooltip}</div>
</div>
</div>
</div>
recomputeDimensions: (options = {}) =>
if @props.getScrollRegion?
@props.getScrollRegion()._recomputeDimensions(options)
@_recomputeDimensions(options)
_recomputeDimensions: ({avoidForcingLayout}) =>
if not avoidForcingLayout
trackNode = React.findDOMNode(@refs.track)
trackHeight = trackNode.clientHeight
if trackHeight isnt @state.trackHeight
@setState({trackHeight})
_scrollbarHandleStyles: =>
handleHeight = @_getHandleHeight()
handleTop = (@state.viewportScrollTop / (@state.totalHeight - @state.viewportHeight)) * (@state.trackHeight - handleHeight)
position:'relative'
height: handleHeight
top: handleTop
_scrollbarWrapStyles: =>
position:'absolute'
top: 0
bottom: 0
right: 0
zIndex: 2
_onHandleDown: (event) =>
handleNode = React.findDOMNode(@refs.handle)
@_trackOffset = React.findDOMNode(@refs.track).getBoundingClientRect().top
@_mouseOffsetWithinHandle = event.pageY - handleNode.getBoundingClientRect().top
window.addEventListener("mousemove", @_onHandleMove)
window.addEventListener("mouseup", @_onHandleUp)
@setState(dragging: true)
event.preventDefault()
_onHandleMove: (event) =>
trackY = event.pageY - @_trackOffset - @_mouseOffsetWithinHandle
trackPxToViewportPx = (@state.totalHeight - @state.viewportHeight) / (@state.trackHeight - @_getHandleHeight())
@props.getScrollRegion().scrollTop = trackY * trackPxToViewportPx
event.preventDefault()
_onHandleUp: (event) =>
window.removeEventListener("mousemove", @_onHandleMove)
window.removeEventListener("mouseup", @_onHandleUp)
@setState(dragging: false)
event.preventDefault()
_onHandleClick: (event) =>
# Avoid event propogating up to track
event.stopPropagation()
_onScrollJump: (event) =>
@_trackOffset = React.findDOMNode(@refs.track).getBoundingClientRect().top
@_mouseOffsetWithinHandle = @_getHandleHeight() / 2
@_onHandleMove(event)
_getHandleHeight: =>
Math.min(@state.totalHeight, Math.max(40, (@state.trackHeight / @state.totalHeight) * @state.trackHeight))
###
The ScrollRegion component attaches a custom scrollbar.
###
class ScrollRegion extends React.Component
@displayName: "ScrollRegion"
@ -16,13 +115,13 @@ class ScrollRegion extends React.Component
className: React.PropTypes.string
scrollTooltipComponent: React.PropTypes.func
children: React.PropTypes.oneOfType([React.PropTypes.element, React.PropTypes.array])
getScrollbar: React.PropTypes.func
constructor: (@props) ->
@state =
totalHeight:0
viewportHeight: 0
viewportScrollTop: 0
dragging: false
scrolling: false
Object.defineProperty(@, 'scrollTop', {
@ -31,10 +130,7 @@ class ScrollRegion extends React.Component
})
componentDidMount: =>
@_recomputeDimensions()
componentWillUnmount: =>
@_onHandleUp()
@recomputeDimensions()
shouldComponentUpdate: (newProps, newState) =>
# Because this component renders @props.children, it needs to update
@ -48,20 +144,17 @@ class ScrollRegion extends React.Component
'dragging': @state.dragging
'scrolling': @state.scrolling
scrollbar = []
if not @props.getScrollbar
scrollbar = <Scrollbar
ref="scrollbar"
scrollTooltipComponent={@props.scrollTooltipComponent}
getScrollRegion={@_getSelf} />
otherProps = _.omit(@props, _.keys(@constructor.propTypes))
tooltip = []
if @props.scrollTooltipComponent
tooltip = <@props.scrollTooltipComponent viewportCenter={@state.viewportScrollTop + @state.viewportHeight / 2} totalHeight={@state.totalHeight} />
<div className={containerClasses} {...otherProps}>
<div className="scrollbar-track" style={@_scrollbarWrapStyles()} onMouseEnter={@_recomputeDimensions}>
<div className="scrollbar-track-inner" ref="track" onClick={@_onScrollJump}>
<div className="scrollbar-handle" onMouseDown={@_onHandleDown} style={@_scrollbarHandleStyles()} ref="handle" onClick={@_onHandleClick} >
<div className="tooltip">{tooltip}</div>
</div>
</div>
</div>
{scrollbar}
<div className="scroll-region-content" onScroll={@_onScroll} ref="content">
<div className="scroll-region-content-inner">
{@props.children}
@ -76,29 +169,15 @@ class ScrollRegion extends React.Component
adjustment = Utils.scrollAdjustmentToMakeNodeVisibleInContainer(node, container)
@scrollTop += adjustment if adjustment isnt 0
_scrollbarWrapStyles: =>
position:'absolute'
top: 0
bottom: 0
right: 0
zIndex: 2
recomputeDimensions: (options = {}) =>
scrollbar = @props.getScrollbar?() ? @refs.scrollbar
scrollbar._recomputeDimensions(options)
@_recomputeDimensions(options)
_scrollbarHandleStyles: =>
handleHeight = @_getHandleHeight()
handleTop = (@state.viewportScrollTop / (@state.totalHeight - @state.viewportHeight)) * (@state.trackHeight - handleHeight)
position:'relative'
height: handleHeight
top: handleTop
_getHandleHeight: =>
Math.min(@state.totalHeight, Math.max(40, (@state.trackHeight / @state.totalHeight) * @state.trackHeight))
_recomputeDimensions: ({avoidForcingLayout} = {}) =>
_recomputeDimensions: ({avoidForcingLayout}) =>
return unless @refs.content
contentNode = React.findDOMNode(@refs.content)
trackNode = React.findDOMNode(@refs.track)
viewportScrollTop = contentNode.scrollTop
# While we're scrolling, calls to contentNode.scrollHeight / clientHeight
@ -111,58 +190,37 @@ class ScrollRegion extends React.Component
viewportHeight = @state.viewportHeight ? contentNode.clientHeight
else
totalHeight = contentNode.scrollHeight
trackHeight = trackNode.clientHeight
viewportHeight = contentNode.clientHeight
if @state.totalHeight != totalHeight or
@state.trackHeight != trackHeight or
@state.viewportHeight != viewportHeight or
@state.viewportScrollTop != viewportScrollTop
@setState({totalHeight, trackHeight, viewportScrollTop, viewportHeight})
@_setSharedState({totalHeight, viewportScrollTop, viewportHeight})
_onHandleDown: (event) =>
handleNode = React.findDOMNode(@refs.handle)
@_trackOffset = React.findDOMNode(@refs.track).getBoundingClientRect().top
@_mouseOffsetWithinHandle = event.pageY - handleNode.getBoundingClientRect().top
window.addEventListener("mousemove", @_onHandleMove)
window.addEventListener("mouseup", @_onHandleUp)
@setState(dragging: true)
_onHandleMove: (event) =>
trackY = event.pageY - @_trackOffset - @_mouseOffsetWithinHandle
trackPxToViewportPx = (@state.totalHeight - @state.viewportHeight) / (@state.trackHeight - @_getHandleHeight())
contentNode = React.findDOMNode(@refs.content)
contentNode.scrollTop = trackY * trackPxToViewportPx
_onHandleUp: (event) =>
window.removeEventListener("mousemove", @_onHandleMove)
window.removeEventListener("mouseup", @_onHandleUp)
@setState(dragging: false)
_onHandleClick: (event) =>
# Avoid event propogating up to track
event.stopPropagation()
_onScrollJump: (event) =>
@_trackOffset = React.findDOMNode(@refs.track).getBoundingClientRect().top
@_mouseOffsetWithinHandle = @_getHandleHeight() / 2
@_onHandleMove(event)
_setSharedState: (state) ->
scrollbar = @props.getScrollbar?() ? @refs.scrollbar
scrollbar.setStateFromScrollRegion(state)
@setState(state)
_onScroll: (event) =>
if not @state.scrolling
@_recomputeDimensions()
@setState(scrolling: true)
@recomputeDimensions()
@_setSharedState(scrolling: true)
else
@_recomputeDimensions({avoidForcingLayout: true})
@recomputeDimensions({avoidForcingLayout: true})
@props.onScroll?(event)
@_onScrollEnd ?= _.debounce =>
@setState(scrolling: false)
@_setSharedState(scrolling: false)
@props.onScrollEnd?(event)
, 250
@_onScrollEnd()
_getSelf: =>
@
ScrollRegion.Scrollbar = Scrollbar
module.exports = ScrollRegion

View file

@ -54,54 +54,34 @@
.scroll-region-content-inner {
transform:translate3d(0,0,0);
}
.scrollbar-track {
opacity: 0;
transition: opacity 0.3s;
transition-delay: 0.5s;
padding:3px;
width:17px;
background: @list-bg;
border-left: 1px solid @border-color-divider;
&:hover {
opacity: 1;
transition-delay: 0s;
}
/* Used to read the track height with padding applied. */
.scrollbar-track-inner {
height:100%;
}
.scrollbar-handle {
background-color: lighten(@gray, 40%);
border:1px solid lighten(@gray, 30%);
border-radius:8px;
.tooltip {
opacity: 0;
display:none;
transition: opacity 0.3s;
top: 50%;
transform: translate(-100%, -50%);
position: absolute;
pointer-events: none;
}
}
}
}
.scroll-region.scrolling {
.scroll-region.scrolling {
.scroll-region-content-inner {
pointer-events: none;
}
}
.scrollbar-track {
.scrollbar-track {
opacity: 0;
transition: opacity 0.3s;
transition-delay: 0.5s;
padding:3px;
width:17px;
background: @list-bg;
border-left: 1px solid @border-color-divider;
&:hover {
opacity: 1;
transition-delay: 0s;
}
}
.scroll-region.dragging {
.scrollbar-track {
&.scrolling {
opacity: 1;
transition-delay: 0s;
}
&.dragging {
opacity: 1;
.scrollbar-handle {
cursor: default;
@ -113,4 +93,24 @@
}
}
}
/* Used to read the track height with padding applied. */
.scrollbar-track-inner {
height:100%;
}
.scrollbar-handle {
background-color: lighten(@gray, 40%);
border:1px solid lighten(@gray, 30%);
border-radius:8px;
.tooltip {
opacity: 0;
display:none;
transition: opacity 0.3s;
top: 50%;
transform: translate(-100%, -50%);
position: absolute;
pointer-events: none;
}
}
}