mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-21 07:46:06 +08:00
feat(scrollbars): Custom scrollbars via ScrollRegion
Summary: ScrollRegion with support for tooltips shown as you scroll, custom tooltips for thread list and message list. fix(specs): all other scrollbars hidden to prevent incompatibility fix scrollbar sizing when used in conjunction with resizableregion Test Plan: Need to write tests and docs for ScrollRegion - no tests yet Reviewers: evan Reviewed By: evan Differential Revision: https://phab.nylas.com/D1597
This commit is contained in:
parent
bc916a2530
commit
fb3f7fc410
|
@ -16,6 +16,7 @@ module.exports =
|
|||
MultiselectList: require '../src/components/multiselect-list'
|
||||
MultiselectActionBar: require '../src/components/multiselect-action-bar'
|
||||
ResizableRegion: require '../src/components/resizable-region'
|
||||
ScrollRegion: require '../src/components/scroll-region'
|
||||
InjectedComponentSet: require '../src/components/injected-component-set'
|
||||
InjectedComponent: require '../src/components/injected-component'
|
||||
TokenizingTextField: require '../src/components/tokenizing-text-field'
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
React = require 'react'
|
||||
{Actions} = require("nylas-exports")
|
||||
{ScrollRegion} = require("nylas-component-kit")
|
||||
SidebarDividerItem = require("./account-sidebar-divider-item")
|
||||
SidebarTagItem = require("./account-sidebar-tag-item")
|
||||
SidebarSheetItem = require("./account-sidebar-sheet-item")
|
||||
|
@ -26,11 +27,11 @@ class AccountSidebar extends React.Component
|
|||
@unsubscribe() if @unsubscribe
|
||||
|
||||
render: =>
|
||||
<div id="account-sidebar" className="account-sidebar">
|
||||
<ScrollRegion id="account-sidebar" className="account-sidebar">
|
||||
<div className="account-sidebar-sections">
|
||||
{@_sections()}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollRegion>
|
||||
|
||||
_sections: =>
|
||||
return @state.sections.map (section) =>
|
||||
|
|
|
@ -4,8 +4,6 @@
|
|||
#account-sidebar {
|
||||
order: 1;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background-color: @source-list-bg;
|
||||
|
||||
section {
|
||||
|
|
|
@ -4,11 +4,46 @@ classNames = require 'classnames'
|
|||
MessageItem = require "./message-item"
|
||||
{Utils, Actions, MessageStore, ComponentRegistry} = require("nylas-exports")
|
||||
{Spinner,
|
||||
ScrollRegion,
|
||||
ResizableRegion,
|
||||
RetinaImg,
|
||||
InjectedComponentSet,
|
||||
InjectedComponent} = require('nylas-component-kit')
|
||||
|
||||
class MessageListScrollTooltip extends React.Component
|
||||
@displayName: 'MessageListScrollTooltip'
|
||||
@propTypes:
|
||||
viewportCenter: React.PropTypes.number.isRequired
|
||||
totalHeight: React.PropTypes.number.isRequired
|
||||
|
||||
componentWillMount: =>
|
||||
@setupForProps(@props)
|
||||
|
||||
componentWillReceiveProps: (newProps) =>
|
||||
@setupForProps(newProps)
|
||||
|
||||
shouldComponentUpdate: (newProps, newState) =>
|
||||
not _.isEqual(@state,newState)
|
||||
|
||||
setupForProps: (props) ->
|
||||
# Technically, we could have MessageList provide the currently visible
|
||||
# item index, but the DOM approach is simple and self-contained.
|
||||
#
|
||||
els = document.querySelectorAll('.message-item-wrap')
|
||||
idx = _.findIndex els, (el) -> el.offsetTop > props.viewportCenter
|
||||
if idx is -1
|
||||
idx = els.length
|
||||
|
||||
@setState
|
||||
idx: idx
|
||||
count: els.length
|
||||
|
||||
render: ->
|
||||
<div className="scroll-tooltip">
|
||||
{@state.idx} of {@state.count}
|
||||
</div>
|
||||
|
||||
|
||||
class MessageList extends React.Component
|
||||
@displayName: 'MessageList'
|
||||
@containerRequired: false
|
||||
|
@ -74,22 +109,23 @@ class MessageList extends React.Component
|
|||
"ready": @state.ready
|
||||
|
||||
<div className="message-list" id="message-list">
|
||||
<div tabIndex="-1"
|
||||
<ScrollRegion tabIndex="-1"
|
||||
className={wrapClass}
|
||||
scrollTooltipComponent={MessageListScrollTooltip}
|
||||
onScroll={_.debounce(@_cacheScrollPos, 100)}
|
||||
ref="messageWrap">
|
||||
|
||||
<InjectedComponentSet
|
||||
className="message-list-notification-bars"
|
||||
matching={role:"MessageListNotificationBar"}
|
||||
exposedProps={thread: @state.currentThread}/>
|
||||
<InjectedComponentSet
|
||||
className="message-list-headers"
|
||||
matching={role:"MessageListHeaders"}
|
||||
exposedProps={thread: @state.currentThread}/>
|
||||
|
||||
<div className="headers" style={position:'relative'}>
|
||||
<InjectedComponentSet
|
||||
className="message-list-notification-bars"
|
||||
matching={role:"MessageListNotificationBar"}
|
||||
exposedProps={thread: @state.currentThread}/>
|
||||
<InjectedComponentSet
|
||||
className="message-list-headers"
|
||||
matching={role:"MessageListHeaders"}
|
||||
exposedProps={thread: @state.currentThread}/>
|
||||
</div>
|
||||
{@_messageComponents()}
|
||||
</div>
|
||||
</ScrollRegion>
|
||||
{@_renderReplyArea()}
|
||||
<Spinner visible={!@state.ready} />
|
||||
</div>
|
||||
|
|
|
@ -12,6 +12,32 @@ classNames = require 'classnames'
|
|||
ThreadListParticipants = require './thread-list-participants'
|
||||
ThreadListStore = require './thread-list-store'
|
||||
|
||||
class ThreadListScrollTooltip extends React.Component
|
||||
@displayName: 'ThreadListScrollTooltip'
|
||||
@propTypes:
|
||||
viewportCenter: React.PropTypes.number.isRequired
|
||||
totalHeight: React.PropTypes.number.isRequired
|
||||
|
||||
componentWillMount: =>
|
||||
@setupForProps(@props)
|
||||
|
||||
componentWillReceiveProps: (newProps) =>
|
||||
@setupForProps(newProps)
|
||||
|
||||
shouldComponentUpdate: (newProps, newState) =>
|
||||
@state?.idx isnt newState.idx
|
||||
|
||||
setupForProps: (props) ->
|
||||
idx = Math.floor(ThreadListStore.view().count() / @props.totalHeight * @props.viewportCenter)
|
||||
@setState
|
||||
idx: idx
|
||||
item: ThreadListStore.view().get(idx)
|
||||
|
||||
render: ->
|
||||
<div className="scroll-tooltip">
|
||||
{timestamp(@state.item?.lastMessageTimestamp)}
|
||||
</div>
|
||||
|
||||
class ThreadList extends React.Component
|
||||
@displayName: 'ThreadList'
|
||||
|
||||
|
@ -99,6 +125,7 @@ class ThreadList extends React.Component
|
|||
commands={@commands}
|
||||
itemPropsProvider={@itemPropsProvider}
|
||||
className="thread-list"
|
||||
scrollTooltipComponent={ThreadListScrollTooltip}
|
||||
collection="thread" />
|
||||
|
||||
# Additional Commands
|
||||
|
|
|
@ -8,7 +8,9 @@
|
|||
.thread-list, .draft-list {
|
||||
order: 3;
|
||||
flex: 1;
|
||||
position:relative;
|
||||
position:absolute;
|
||||
width:100%;
|
||||
height:100%;
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
|
||||
.list-item {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
_ = require 'underscore'
|
||||
React = require 'react/addons'
|
||||
ScrollRegion = require './scroll-region'
|
||||
|
||||
RangeChunkSize = 10
|
||||
|
||||
|
@ -85,14 +86,11 @@ class ListTabular extends React.Component
|
|||
# If our view has been swapped out for an entirely different one,
|
||||
# reset our scroll position to the top.
|
||||
if prevProps.dataView isnt @props.dataView
|
||||
container = React.findDOMNode(@refs.container)
|
||||
container.scrollTop = 0
|
||||
@refs.container.scrollTop = 0
|
||||
@updateRangeState()
|
||||
|
||||
updateScrollState: =>
|
||||
window.requestAnimationFrame =>
|
||||
container = React.findDOMNode(@refs.container)
|
||||
|
||||
# Create an event that fires when we stop receiving scroll events.
|
||||
# There is no "scrollend" event, but we really need one.
|
||||
clearTimeout(@_scrollTimer) if @_scrollTimer
|
||||
|
@ -104,7 +102,7 @@ class ListTabular extends React.Component
|
|||
|
||||
# If we've shifted enough pixels from our previous scrollTop to require
|
||||
# new rows to be rendered, update our state!
|
||||
if Math.abs(@state.scrollTop - container.scrollTop) >= @_rowHeight() * RangeChunkSize
|
||||
if Math.abs(@state.scrollTop - @refs.container.scrollTop) >= @_rowHeight() * RangeChunkSize
|
||||
@updateRangeState()
|
||||
|
||||
onDoneReceivingScrollEvents: =>
|
||||
|
@ -113,8 +111,7 @@ class ListTabular extends React.Component
|
|||
@updateRangeState()
|
||||
|
||||
updateRangeState: =>
|
||||
container = @refs.container
|
||||
scrollTop = React.findDOMNode(container)?.scrollTop
|
||||
scrollTop = @refs.container.scrollTop
|
||||
|
||||
rowHeight = @_rowHeight()
|
||||
|
||||
|
@ -159,12 +156,12 @@ class ListTabular extends React.Component
|
|||
height: @props.dataView.count() * @_rowHeight()
|
||||
pointerEvents: if @state.scrollInProgress then 'none' else 'auto'
|
||||
|
||||
<div ref="container" onScroll={@updateScrollState} tabIndex="-1" className="list-container list-tabular">
|
||||
<ScrollRegion ref="container" onScroll={@updateScrollState} tabIndex="-1" className="list-container list-tabular" scrollTooltipComponent={@props.scrollTooltipComponent} >
|
||||
{@_headers()}
|
||||
<div className="list-rows" style={innerStyles}>
|
||||
{@_rows()}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollRegion>
|
||||
|
||||
_rowHeight: =>
|
||||
39
|
||||
|
|
|
@ -32,6 +32,7 @@ class MultiselectList extends React.Component
|
|||
columns: React.PropTypes.array.isRequired
|
||||
dataStore: React.PropTypes.object.isRequired
|
||||
itemPropsProvider: React.PropTypes.func.isRequired
|
||||
scrollTooltipComponent: React.PropTypes.func
|
||||
|
||||
constructor: (@props) ->
|
||||
@state = @_getStateFromStores()
|
||||
|
@ -120,6 +121,7 @@ class MultiselectList extends React.Component
|
|||
<ListTabular
|
||||
ref="list"
|
||||
columns={@props.columns}
|
||||
scrollTooltipComponent={@props.scrollTooltipComponent}
|
||||
dataView={@state.dataView}
|
||||
itemPropsProvider={@itemPropsProvider}
|
||||
onSelect={@_onClickItem}
|
||||
|
|
144
src/components/scroll-region.cjsx
Normal file
144
src/components/scroll-region.cjsx
Normal file
|
@ -0,0 +1,144 @@
|
|||
_ = require 'underscore'
|
||||
React = require 'react/addons'
|
||||
{Utils} = require 'nylas-exports'
|
||||
classNames = require 'classnames'
|
||||
|
||||
###
|
||||
The ScrollRegion component attaches a custom scrollbar.
|
||||
###
|
||||
|
||||
class ScrollRegion extends React.Component
|
||||
@displayName: "ScrollRegion"
|
||||
|
||||
@propTypes:
|
||||
onScroll: React.PropTypes.func
|
||||
className: React.PropTypes.string
|
||||
scrollTooltipComponent: React.PropTypes.func
|
||||
|
||||
constructor: (@props) ->
|
||||
@state =
|
||||
totalHeight:0
|
||||
viewportHeight: 0
|
||||
viewportOffset: 0
|
||||
dragging: false
|
||||
scrolling: false
|
||||
|
||||
Object.defineProperty(@, 'scrollTop', {
|
||||
get: -> React.findDOMNode(@refs.content).scrollTop
|
||||
set: (val) -> React.findDOMNode(@refs.content).scrollTop = val
|
||||
})
|
||||
|
||||
componentDidMount: =>
|
||||
@_recomputeDimensions()
|
||||
|
||||
componentDidUpdate: =>
|
||||
@_recomputeDimensions()
|
||||
|
||||
componentWillUnmount: =>
|
||||
@_onHandleUp()
|
||||
|
||||
shouldComponentUpdate: (newProps, newState) =>
|
||||
not Utils.isEqualReact(newProps, @props) or not Utils.isEqualReact(newState, @state)
|
||||
|
||||
render: =>
|
||||
containerClasses = "#{@props.className ? ''} " + classNames
|
||||
'scroll-region': true
|
||||
'dragging': @state.dragging
|
||||
'scrolling': @state.scrolling
|
||||
|
||||
otherProps = _.omit(@props, _.keys(@constructor.propTypes))
|
||||
|
||||
tooltip = []
|
||||
if @props.scrollTooltipComponent
|
||||
tooltip = <@props.scrollTooltipComponent viewportCenter={@state.viewportOffset + @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>
|
||||
<div className="scroll-region-content" onScroll={@_onScroll} ref="content">
|
||||
{@props.children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
_scrollbarWrapStyles: =>
|
||||
position:'absolute'
|
||||
top: 0
|
||||
bottom: 0
|
||||
right: 0
|
||||
zIndex: 2
|
||||
|
||||
_scrollbarHandleStyles: =>
|
||||
handleHeight = @_getHandleHeight()
|
||||
handleTop = (@state.viewportOffset / (@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: =>
|
||||
return unless @refs.content
|
||||
|
||||
contentNode = React.findDOMNode(@refs.content)
|
||||
trackNode = React.findDOMNode(@refs.track)
|
||||
|
||||
totalHeight = contentNode.scrollHeight
|
||||
trackHeight = trackNode.clientHeight
|
||||
viewportHeight = contentNode.clientHeight
|
||||
viewportOffset = contentNode.scrollTop
|
||||
|
||||
if @state.totalHeight != totalHeight or
|
||||
@state.trackHeight != trackHeight or
|
||||
@state.viewportOffset != viewportOffset or
|
||||
@state.viewportHeight != viewportHeight
|
||||
@setState({totalHeight, trackHeight, viewportOffset, 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) =>
|
||||
@_mouseOffsetWithinHandle = @_getHandleHeight() / 2
|
||||
@_onHandleMove(event)
|
||||
|
||||
_onScroll: (event) =>
|
||||
@_recomputeDimensions()
|
||||
@props.onScroll?(event)
|
||||
|
||||
if not @state.scrolling
|
||||
@setState(scrolling: true)
|
||||
|
||||
@_onStoppedScroll ?= _.debounce =>
|
||||
@setState(scrolling: false)
|
||||
, 250
|
||||
@_onStoppedScroll()
|
||||
|
||||
|
||||
module.exports = ScrollRegion
|
|
@ -71,11 +71,6 @@
|
|||
|
||||
|
||||
.list-container {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: scroll;
|
||||
|
||||
.list-item {
|
||||
font-size: @font-size-base;
|
||||
line-height: @line-height-computed;
|
||||
|
@ -107,6 +102,7 @@
|
|||
.list-tabular {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.list-tabular-item {
|
||||
position: relative;
|
||||
|
|
104
static/components/scroll-region.less
Normal file
104
static/components/scroll-region.less
Normal file
|
@ -0,0 +1,104 @@
|
|||
@import "ui-variables";
|
||||
|
||||
@tooltipBorderColor: rgba(54, 56, 57, 0.9);
|
||||
@tooltipBackground: -webkit-gradient(linear, left top, left bottom, from(rgba(99, 102, 103, 0.9)), to(rgba(82, 85, 86, 0.9)));
|
||||
|
||||
.scroll-tooltip {
|
||||
background: @tooltipBackground;
|
||||
color: white;
|
||||
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.25);
|
||||
padding: @padding-base-vertical @padding-base-horizontal @padding-base-vertical @padding-base-horizontal;
|
||||
border: 1px solid @tooltipBorderColor;
|
||||
border-radius: @border-radius-base;
|
||||
transform: translate(-15px, 0);
|
||||
position: relative;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.scroll-tooltip:after, .scroll-tooltip:before {
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
border: solid transparent;
|
||||
content: " ";
|
||||
height: 0;
|
||||
width: 0;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
.scroll-tooltip:after {
|
||||
border-color: transparent;
|
||||
border-left-color: lighten(rgba(99, 102, 103, 1), 3%);
|
||||
border-width: 8px;
|
||||
margin-top: -8px;
|
||||
}
|
||||
.scroll-tooltip:before {
|
||||
border-color: transparent;
|
||||
border-left-color: darken(@tooltipBorderColor, 20%);
|
||||
border-width: 9px;
|
||||
margin-top: -9px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scroll-region {
|
||||
position:relative;
|
||||
|
||||
.scroll-region-content {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.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;
|
||||
transition: opacity 0.3s;
|
||||
top: 50%;
|
||||
transform: translate(-100%, -50%);
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scroll-region.scrolling {
|
||||
.scrollbar-track {
|
||||
opacity: 1;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
}
|
||||
.scroll-region.dragging {
|
||||
.scrollbar-track {
|
||||
opacity: 1;
|
||||
.scrollbar-handle {
|
||||
cursor: default;
|
||||
background-color: lighten(@gray, 30%);
|
||||
border:1px solid lighten(@gray, 20%);
|
||||
.tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -18,6 +18,7 @@
|
|||
@import "components/tokenizing-text-field";
|
||||
@import "components/extra";
|
||||
@import "components/list-tabular";
|
||||
@import "components/scroll-region";
|
||||
@import "components/spinner";
|
||||
@import "components/generated-form";
|
||||
@import "components/empty-state";
|
||||
|
|
|
@ -198,7 +198,7 @@ body.is-blurred {
|
|||
}
|
||||
|
||||
&.flexbox-handle-right {
|
||||
right:-3px;
|
||||
right:-4px;
|
||||
padding-right:3px;
|
||||
}
|
||||
&.flexbox-handle-left {
|
||||
|
|
Loading…
Reference in a new issue