mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-22 00:06:06 +08:00
fix(message-list-scrolling): Move scroll logic to ScrollRegion
Summary: This is still a work in progress and needs more specs Test Plan: Run tests Reviewers: evan Reviewed By: evan Differential Revision: https://phab.nylas.com/D1808
This commit is contained in:
parent
cab654ec48
commit
43f9b0b8c3
|
@ -157,7 +157,7 @@ class ComposerView extends React.Component
|
|||
</div>
|
||||
|
||||
_wrapClasses: =>
|
||||
"composer-outer-wrap #{@props.className ? ""}"
|
||||
"message-item-white-wrap composer-outer-wrap #{@props.className ? ""}"
|
||||
|
||||
_renderComposer: =>
|
||||
<DropZone className="composer-inner-wrap"
|
||||
|
@ -287,6 +287,11 @@ class ComposerView extends React.Component
|
|||
</ScrollRegion>
|
||||
|
||||
_renderBodyContenteditable: =>
|
||||
onScrollToBottom = null
|
||||
if @props.onRequestScrollTo
|
||||
onScrollToBottom = =>
|
||||
@props.onRequestScrollTo({messageId: @_proxy.draft().id})
|
||||
|
||||
<ContenteditableComponent ref="contentBody"
|
||||
html={@state.body}
|
||||
onChange={@_onChangeBody}
|
||||
|
@ -295,7 +300,8 @@ class ComposerView extends React.Component
|
|||
initialSelectionSnapshot={@_recoveredSelection}
|
||||
mode={{showQuotedText: @state.showQuotedText}}
|
||||
onChangeMode={@_onChangeEditableMode}
|
||||
onRequestScrollTo={@props.onRequestScrollTo}
|
||||
onScrollTo={@props.onRequestScrollTo}
|
||||
onScrollToBottom={onScrollToBottom}
|
||||
tabIndex="109" />
|
||||
_renderFooterRegions: =>
|
||||
return <div></div> unless @props.localId
|
||||
|
@ -521,8 +527,6 @@ class ComposerView extends React.Component
|
|||
|
||||
_onChangeBody: (event) =>
|
||||
return unless @_proxy
|
||||
if @_getSelections().currentSelection?.atEndOfContent
|
||||
@props.onRequestScrollTo?(messageId: @_proxy.draft().id, location: "bottom")
|
||||
|
||||
# The body changes extremely frequently (on every key stroke). To keep
|
||||
# performance up, we don't want to trigger every single key stroke
|
||||
|
|
|
@ -21,7 +21,8 @@ class ContenteditableComponent extends React.Component
|
|||
initialSelectionSnapshot: React.PropTypes.object
|
||||
|
||||
# Passes an absolute top coordinate to scroll to.
|
||||
onRequestScrollTo: React.PropTypes.func
|
||||
onScrollTo: React.PropTypes.func
|
||||
onScrollToBottom: React.PropTypes.func
|
||||
|
||||
constructor: (@props) ->
|
||||
@state =
|
||||
|
@ -304,11 +305,10 @@ class ContenteditableComponent extends React.Component
|
|||
|
||||
# http://www.w3.org/TR/selection-api/#selectstart-event
|
||||
_setupSelectionListeners: =>
|
||||
@_onSelectionChange = => @_saveSelectionState()
|
||||
document.addEventListener "selectionchange", @_onSelectionChange
|
||||
document.addEventListener("selectionchange", @_saveSelectionState)
|
||||
|
||||
_teardownSelectionListeners: =>
|
||||
document.removeEventListener("selectionchange", @_onSelectionChange)
|
||||
document.removeEventListener("selectionchange", @_saveSelectionState)
|
||||
|
||||
getCurrentSelection: => _.clone(@_selection ? {})
|
||||
getPreviousSelection: => _.clone(@_previousSelection ? {})
|
||||
|
@ -378,8 +378,8 @@ class ContenteditableComponent extends React.Component
|
|||
endOffset: selection.focusOffset
|
||||
endNodeIndex: @_getNodeIndex(selection.focusNode)
|
||||
isCollapsed: selection.isCollapsed
|
||||
atEndOfContent: @_atEndOfContent(selection)
|
||||
|
||||
@_ensureSelectionVisible(selection)
|
||||
@_refreshToolbarState()
|
||||
return @_selection
|
||||
|
||||
|
@ -464,9 +464,6 @@ class ContenteditableComponent extends React.Component
|
|||
@_previousSelection = @_selection
|
||||
@_selection = selection
|
||||
|
||||
# We need to use a boolean flag here because this runs before anything
|
||||
# might be rendered yet.
|
||||
@_selectionManuallyChanged = true
|
||||
|
||||
# When the selectionState gets set by a parent (e.g. undo-ing and
|
||||
# redo-ing) we need to make sure it's visible to the user.
|
||||
|
@ -478,28 +475,34 @@ class ContenteditableComponent extends React.Component
|
|||
# container is a direct parent of the requested element. In this case
|
||||
# the scroll container may be many levels up.
|
||||
_ensureSelectionVisible: (selection) ->
|
||||
rect = @_getRangeInScope().getBoundingClientRect()
|
||||
return if @_isEmptyBoudingRect(rect)
|
||||
top = @_getSelectionTop(selection, rect)
|
||||
@props.onRequestScrollTo?(selectionTop: top)
|
||||
# If our parent supports scroll to bottom, check for that
|
||||
if @props.onScrollToBottom and @_atEndOfContent(selection)
|
||||
@props.onScrollToBottom()
|
||||
|
||||
# Don't bother computing client rects if no scroll method has been provided
|
||||
else if @props.onScrollTo
|
||||
rect = @_getRangeInScope().getBoundingClientRect()
|
||||
if @_isEmptyBoudingRect(rect)
|
||||
rect = @_getSelectionRectFromDOM(selection)
|
||||
|
||||
if rect
|
||||
@props.onScrollTo({rect})
|
||||
|
||||
@_refreshToolbarState()
|
||||
|
||||
_isEmptyBoudingRect: (rect) ->
|
||||
rect.top is 0 and rect.bottom is 0 and rect.left is 0 and rect.right is 0
|
||||
|
||||
_getSelectionTop: (selection, rect) ->
|
||||
if rect then return rect.top + (Math.abs(rect.top - rect.bottom) / 2)
|
||||
_getSelectionRectFromDOM: (selection) ->
|
||||
node = selection.anchorNode
|
||||
if node.nodeType is Node.TEXT_NODE
|
||||
r = document.createRange()
|
||||
r.selectNodeContents(node)
|
||||
rect = r.getBoundingClientRect()
|
||||
return r.getBoundingClientRect()
|
||||
else if node.nodeType is Node.ELEMENT_NODE
|
||||
rect = node.getBoundingClientRect()
|
||||
return node.getBoundingClientRect()
|
||||
else
|
||||
rect = {top: 0, bottom: 0}
|
||||
|
||||
return rect.top + Math.abs(rect.top - rect.bottom) / 2
|
||||
return null
|
||||
|
||||
# We use global listeners to determine whether or not dragging is
|
||||
# happening. This is because dragging may stop outside the scope of
|
||||
|
@ -666,9 +669,8 @@ class ContenteditableComponent extends React.Component
|
|||
@_selection.startOffset,
|
||||
newEndNode,
|
||||
@_selection.endOffset)
|
||||
if @_selectionManuallyChanged
|
||||
@_ensureSelectionVisible(selection)
|
||||
@_selectionManuallyChanged = false
|
||||
|
||||
@_ensureSelectionVisible(selection)
|
||||
@_setupSelectionListeners()
|
||||
|
||||
# We need to break each node apart and cache since the `selection`
|
||||
|
@ -949,7 +951,6 @@ class ContenteditableComponent extends React.Component
|
|||
if inputText.length > 0
|
||||
cleanHtml = @_sanitizeInput(inputText, type)
|
||||
document.execCommand("insertHTML", false, cleanHtml)
|
||||
@_selectionManuallyChanged = true
|
||||
|
||||
return
|
||||
|
||||
|
|
|
@ -58,30 +58,34 @@ class MessageItem extends React.Component
|
|||
attachmentIcon = <div className="collapsed-attachment"></div>
|
||||
|
||||
<div className={@props.className} onClick={@_toggleCollapsed}>
|
||||
<div className="message-item-area">
|
||||
<div className="collapsed-from">
|
||||
{@props.message.from?[0]?.displayFirstName()}
|
||||
<div className="message-item-white-wrap">
|
||||
<div className="message-item-area">
|
||||
<div className="collapsed-from">
|
||||
{@props.message.from?[0]?.displayFirstName()}
|
||||
</div>
|
||||
<div className="collapsed-snippet">
|
||||
{@props.message.snippet}
|
||||
</div>
|
||||
<div className="collapsed-timestamp">
|
||||
<MessageTimestamp date={@props.message.date} />
|
||||
</div>
|
||||
{attachmentIcon}
|
||||
</div>
|
||||
<div className="collapsed-snippet">
|
||||
{@props.message.snippet}
|
||||
</div>
|
||||
<div className="collapsed-timestamp">
|
||||
<MessageTimestamp date={@props.message.date} />
|
||||
</div>
|
||||
{attachmentIcon}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
_renderFull: =>
|
||||
<div className={@props.className}>
|
||||
<div className="message-item-area">
|
||||
{@_renderHeader()}
|
||||
<EmailFrame showQuotedText={@state.showQuotedText}>
|
||||
{@_formatBody()}
|
||||
</EmailFrame>
|
||||
<a className={@_quotedTextClasses()} onClick={@_toggleQuotedText}></a>
|
||||
{@_renderEvents()}
|
||||
{@_renderAttachments()}
|
||||
<div className="message-item-white-wrap">
|
||||
<div className="message-item-area">
|
||||
{@_renderHeader()}
|
||||
<EmailFrame showQuotedText={@state.showQuotedText}>
|
||||
{@_formatBody()}
|
||||
</EmailFrame>
|
||||
<a className={@_quotedTextClasses()} onClick={@_toggleQuotedText}></a>
|
||||
{@_renderEvents()}
|
||||
{@_renderAttachments()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -95,9 +95,6 @@ class MessageList extends React.Component
|
|||
@MINIFY_THRESHOLD = 3
|
||||
|
||||
componentDidMount: =>
|
||||
@_mounted = true
|
||||
|
||||
window.addEventListener("resize", @_onResize)
|
||||
@_unsubscribers = []
|
||||
@_unsubscribers.push MessageStore.listen @_onChange
|
||||
|
||||
|
@ -109,19 +106,10 @@ class MessageList extends React.Component
|
|||
|
||||
@command_unsubscriber = atom.commands.add('body', commands)
|
||||
|
||||
# We don't need to listen to ThreadStore bcause MessageStore already
|
||||
# listens to thead selection changes
|
||||
|
||||
if not @state.loading
|
||||
@_prepareContentForDisplay()
|
||||
|
||||
componentWillUnmount: =>
|
||||
@_mounted = false
|
||||
unsubscribe() for unsubscribe in @_unsubscribers
|
||||
@command_unsubscriber.dispose()
|
||||
|
||||
window.removeEventListener("resize", @_onResize)
|
||||
|
||||
shouldComponentUpdate: (nextProps, nextState) =>
|
||||
not Utils.isEqualReact(nextProps, @props) or
|
||||
not Utils.isEqualReact(nextState, @state)
|
||||
|
@ -129,32 +117,24 @@ class MessageList extends React.Component
|
|||
componentDidUpdate: (prevProps, prevState) =>
|
||||
return if @state.loading
|
||||
|
||||
if prevState.loading
|
||||
@_prepareContentForDisplay()
|
||||
else
|
||||
newDraftIds = @_newDraftIds(prevState)
|
||||
newMessageIds = @_newMessageIds(prevState)
|
||||
if newMessageIds.length > 0
|
||||
@_prepareContentForDisplay()
|
||||
else if newDraftIds.length > 0
|
||||
@_focusDraft(@_getDraftElement(newDraftIds[0]))
|
||||
@_prepareContentForDisplay()
|
||||
newDraftIds = @_newDraftIds(prevState)
|
||||
if newDraftIds.length > 0
|
||||
@_focusDraft(@_getMessageElement(newDraftIds[0]))
|
||||
|
||||
_newDraftIds: (prevState) =>
|
||||
oldDraftIds = _.map(_.filter((prevState.messages ? []), (m) -> m.draft), (m) -> m.id)
|
||||
newDraftIds = _.map(_.filter((@state.messages ? []), (m) -> m.draft), (m) -> m.id)
|
||||
return _.difference(newDraftIds, oldDraftIds) ? []
|
||||
|
||||
_newMessageIds: (prevState) =>
|
||||
oldMessageIds = _.map(_.reject((prevState.messages ? []), (m) -> m.draft), (m) -> m.id)
|
||||
newMessageIds = _.map(_.reject((@state.messages ? []), (m) -> m.draft), (m) -> m.id)
|
||||
return _.difference(newMessageIds, oldMessageIds) ? []
|
||||
|
||||
_getDraftElement: (draftId) =>
|
||||
@refs["composerItem-#{draftId}"]
|
||||
_getMessageElement: (id) =>
|
||||
@refs["message-#{id}"]
|
||||
|
||||
_focusDraft: (draftElement) =>
|
||||
draftElement.focus()
|
||||
@refs.messageWrap.scrollTo(draftElement, {
|
||||
position: ScrollRegion.ScrollPosition.Bottom,
|
||||
settle: true
|
||||
})
|
||||
|
||||
_createReplyOrUpdateExistingDraft: (type) =>
|
||||
unless type in ['reply', 'reply-all']
|
||||
|
@ -204,7 +184,7 @@ class MessageList extends React.Component
|
|||
updated[key].push(contact) unless _.findWhere(updated[key], {email: contact.email})
|
||||
|
||||
session.changes.add(updated)
|
||||
@_focusDraft(@_getDraftElement(last.id))
|
||||
@_focusDraft(@_getMessageElement(last.id))
|
||||
|
||||
else
|
||||
if type is 'reply'
|
||||
|
@ -229,13 +209,12 @@ class MessageList extends React.Component
|
|||
|
||||
wrapClass = classNames
|
||||
"messages-wrap": true
|
||||
"ready": @state.ready
|
||||
"ready": not @state.loading
|
||||
|
||||
<div className="message-list" id="message-list">
|
||||
<ScrollRegion tabIndex="-1"
|
||||
className={wrapClass}
|
||||
scrollTooltipComponent={MessageListScrollTooltip}
|
||||
onScroll={_.debounce(@_cacheScrollPos, 100)}
|
||||
ref="messageWrap">
|
||||
{@_renderSubject()}
|
||||
<div className="headers" style={position:'relative'}>
|
||||
|
@ -250,7 +229,7 @@ class MessageList extends React.Component
|
|||
</div>
|
||||
{@_messageComponents()}
|
||||
</ScrollRegion>
|
||||
<Spinner visible={!@state.ready} />
|
||||
<Spinner visible={@state.loading} />
|
||||
</div>
|
||||
|
||||
_renderSubject: ->
|
||||
|
@ -273,7 +252,8 @@ class MessageList extends React.Component
|
|||
<span className="reply-text">Write a reply…</span>
|
||||
</div>
|
||||
</div>
|
||||
else return <div key={Utils.generateTempId()}></div>
|
||||
else
|
||||
<div key={Utils.generateTempId()}></div>
|
||||
|
||||
_hasReplyArea: =>
|
||||
not _.last(@state.messages)?.draft
|
||||
|
@ -294,38 +274,7 @@ class MessageList extends React.Component
|
|||
return unless @state.currentThread
|
||||
@_createReplyOrUpdateExistingDraft(@_replyType())
|
||||
|
||||
# There may be a lot of iframes to load which may take an indeterminate
|
||||
# amount of time. As long as there is more content being painted onto
|
||||
# the page and our height is changing, keep waiting. Then scroll to message.
|
||||
scrollToMessage: (msgDOMNode, done, location="top", stability=5) =>
|
||||
return done() unless msgDOMNode?
|
||||
|
||||
messageWrap = React.findDOMNode(@refs.messageWrap)
|
||||
lastHeight = -1
|
||||
stableCount = 0
|
||||
scrollIfSettled = =>
|
||||
return unless @_mounted
|
||||
messageWrapHeight = messageWrap.getBoundingClientRect().height
|
||||
if messageWrapHeight isnt lastHeight
|
||||
lastHeight = messageWrapHeight
|
||||
stableCount = 0
|
||||
else
|
||||
stableCount += 1
|
||||
if stableCount is stability
|
||||
if location is "top"
|
||||
messageWrap.scrollTop = msgDOMNode.offsetTop
|
||||
else if location is "bottom"
|
||||
offsetTop = msgDOMNode.offsetTop
|
||||
messageHeight = msgDOMNode.getBoundingClientRect().height
|
||||
messageWrap.scrollTop = offsetTop - (messageWrapHeight - messageHeight)
|
||||
return done()
|
||||
|
||||
window.requestAnimationFrame -> scrollIfSettled(msgDOMNode, done)
|
||||
|
||||
scrollIfSettled()
|
||||
|
||||
_messageComponents: =>
|
||||
appliedInitialScroll = false
|
||||
components = []
|
||||
|
||||
messages = @_messagesWithMinification(@state.messages)
|
||||
|
@ -337,29 +286,23 @@ class MessageList extends React.Component
|
|||
|
||||
collapsed = !@state.messagesExpandedState[message.id]
|
||||
|
||||
initialScroll = not appliedInitialScroll and not collapsed and
|
||||
((message.draft) or
|
||||
(message.unread) or
|
||||
(idx is @state.messages.length - 1 and idx > 0))
|
||||
appliedInitialScroll ||= initialScroll
|
||||
|
||||
className = classNames
|
||||
"message-item-wrap": true
|
||||
"before-reply-area": (messages.length - 1 is idx) and @_hasReplyArea()
|
||||
"initial-scroll": initialScroll
|
||||
"unread": message.unread
|
||||
"draft": message.draft
|
||||
"collapsed": collapsed
|
||||
|
||||
if message.draft
|
||||
components.push <InjectedComponent matching={role:"Composer"}
|
||||
exposedProps={ mode:"inline", localId:@state.messageLocalIds[message.id], onRequestScrollTo:@_onRequestScrollToComposer, threadId:@state.currentThread.id }
|
||||
ref={"composerItem-#{message.id}"}
|
||||
exposedProps={ mode:"inline", localId:@state.messageLocalIds[message.id], onRequestScrollTo:@_onChildScrollRequest, threadId:@state.currentThread.id }
|
||||
ref={"message-#{message.id}"}
|
||||
key={@state.messageLocalIds[message.id]}
|
||||
className={className} />
|
||||
else
|
||||
components.push <MessageItem key={message.id}
|
||||
thread={@state.currentThread}
|
||||
ref={"message-#{message.id}"}
|
||||
message={message}
|
||||
className={className}
|
||||
collapsed={collapsed}
|
||||
|
@ -370,7 +313,6 @@ class MessageList extends React.Component
|
|||
return components
|
||||
|
||||
_renderMinifiedBundle: (bundle) ->
|
||||
|
||||
BUNDLE_HEIGHT = 36
|
||||
lines = bundle.messages[0...10]
|
||||
h = Math.round(BUNDLE_HEIGHT / lines.length)
|
||||
|
@ -433,22 +375,17 @@ class MessageList extends React.Component
|
|||
#
|
||||
# If messageId and location are defined, that means we want to scroll
|
||||
# smoothly to the top of a particular message.
|
||||
_onRequestScrollToComposer: ({messageId, location, selectionTop}={}) =>
|
||||
composer = React.findDOMNode(@_getDraftElement(messageId))
|
||||
if selectionTop
|
||||
messageWrap = React.findDOMNode(@refs.messageWrap)
|
||||
wrapRect = messageWrap.getBoundingClientRect()
|
||||
if selectionTop < wrapRect.top or selectionTop > wrapRect.bottom
|
||||
wrapMid = wrapRect.top + Math.abs(wrapRect.top - wrapRect.bottom) / 2
|
||||
diff = selectionTop - wrapMid
|
||||
messageWrap.scrollTop += diff
|
||||
_onChildScrollRequest: ({messageId, rect}={}) =>
|
||||
if messageId
|
||||
@refs.messageWrap.scrollTo(@_getMessageElement(messageId), {
|
||||
position: ScrollRegion.ScrollPosition.Visible
|
||||
})
|
||||
else if rect
|
||||
@refs.messageWrap.scrollToRect(rect, {
|
||||
position: ScrollRegion.ScrollPosition.CenterIfInvisible
|
||||
})
|
||||
else
|
||||
done = ->
|
||||
location ?= "bottom"
|
||||
@scrollToMessage(composer, done, location, 1)
|
||||
|
||||
_makeRectVisible: (rect) ->
|
||||
messageWrap = React.findDOMNode(@refs.messageWrap)
|
||||
throw new Error("onChildScrollRequest: expected messageId or rect")
|
||||
|
||||
_onChange: =>
|
||||
newState = @_getStateFromStores()
|
||||
|
@ -462,34 +399,5 @@ class MessageList extends React.Component
|
|||
messagesExpandedState: MessageStore.itemsExpandedState()
|
||||
currentThread: MessageStore.thread()
|
||||
loading: MessageStore.itemsLoading()
|
||||
ready: if MessageStore.itemsLoading() then false else @state?.ready ? false
|
||||
|
||||
_prepareContentForDisplay: =>
|
||||
node = React.findDOMNode(@)
|
||||
return unless node
|
||||
initialScrollNode = node.querySelector(".initial-scroll")
|
||||
@scrollToMessage initialScrollNode, =>
|
||||
@setState(ready: true)
|
||||
@_cacheScrollPos()
|
||||
|
||||
_onResize: (event) =>
|
||||
@_scrollToBottom() if @_wasAtBottom()
|
||||
@_cacheScrollPos()
|
||||
|
||||
_scrollToBottom: =>
|
||||
messageWrap = React.findDOMNode(@refs.messageWrap)
|
||||
return unless messageWrap
|
||||
messageWrap.scrollTop = messageWrap.scrollHeight
|
||||
|
||||
_cacheScrollPos: =>
|
||||
messageWrap = React.findDOMNode(@refs.messageWrap)
|
||||
return unless messageWrap
|
||||
@_lastScrollTop = messageWrap.scrollTop
|
||||
@_lastHeight = messageWrap.getBoundingClientRect().height
|
||||
@_lastScrollHeight = messageWrap.scrollHeight
|
||||
|
||||
_wasAtBottom: =>
|
||||
(@_lastScrollTop + @_lastHeight) >= @_lastScrollHeight
|
||||
|
||||
|
||||
module.exports = MessageList
|
||||
|
|
|
@ -120,30 +120,33 @@
|
|||
}
|
||||
|
||||
.message-item-wrap {
|
||||
|
||||
transition: height 0.1s;
|
||||
position: relative;
|
||||
|
||||
max-width: @message-max-width;
|
||||
width: calc(~"100% - 12px");
|
||||
|
||||
margin: @message-spacing auto;
|
||||
padding: 0;
|
||||
margin: 0 auto;
|
||||
padding: @message-spacing 0;
|
||||
&:last-child {
|
||||
padding-bottom: @message-spacing * 2;
|
||||
}
|
||||
.message-item-white-wrap {
|
||||
background: @background-primary;
|
||||
border: 0;
|
||||
box-shadow: 0 0 0.5px rgba(0, 0, 0, 0.28), 0 1px 1.5px rgba(0, 0, 0, 0.08);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
background: @background-primary;
|
||||
border: 0;
|
||||
box-shadow: 0 0 0.5px rgba(0, 0, 0, 0.28), 0 1px 1.5px rgba(0, 0, 0, 0.08);
|
||||
border-radius: 4px;
|
||||
|
||||
&:first-child { padding-top: 0; }
|
||||
|
||||
&.before-reply-area { margin-bottom: 0; }
|
||||
&.before-reply-area { padding-bottom: 0; }
|
||||
|
||||
&.collapsed {
|
||||
background-color: darken(@background-primary, 2%);
|
||||
padding-top: 19px;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 0;
|
||||
.message-item-white-wrap {
|
||||
background-color: darken(@background-primary, 2%);
|
||||
padding-top: 19px;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&+.minified-bundle {
|
||||
margin-top: -@message-spacing
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
_ = require 'underscore'
|
||||
React = require 'react/addons'
|
||||
{DOMUtils} = require 'nylas-exports'
|
||||
{Utils} = require 'nylas-exports'
|
||||
classNames = require 'classnames'
|
||||
|
||||
class Scrollbar extends React.Component
|
||||
|
@ -117,7 +117,25 @@ class ScrollRegion extends React.Component
|
|||
children: React.PropTypes.oneOfType([React.PropTypes.element, React.PropTypes.array])
|
||||
getScrollbar: React.PropTypes.func
|
||||
|
||||
# Concept from https://developer.apple.com/library/prerelease/ios/documentation/UIKit/Reference/UITableView_Class/#//apple_ref/c/tdef/UITableViewScrollPosition
|
||||
|
||||
@ScrollPosition:
|
||||
# Scroll so that the desired region is at the top of the viewport
|
||||
Top: 'Top'
|
||||
# Scroll so that the desired region is at the bottom of the viewport
|
||||
Bottom: 'Bottom'
|
||||
# Scroll so that the desired region is visible in the viewport, with the
|
||||
# least movement possible.
|
||||
Visible: 'Visible'
|
||||
# Scroll so that the desired region is centered in the viewport
|
||||
Center: 'Center'
|
||||
# Scroll so that the desired region is centered in the viewport, only if it
|
||||
# is currently not visible
|
||||
CenterIfInvisible: 'CenterIfInvisible'
|
||||
|
||||
constructor: (@props) ->
|
||||
@_scrollToTaskId = 0
|
||||
@_scrollbarComponent = null
|
||||
@state =
|
||||
totalHeight:0
|
||||
viewportHeight: 0
|
||||
|
@ -130,23 +148,35 @@ class ScrollRegion extends React.Component
|
|||
})
|
||||
|
||||
componentDidMount: =>
|
||||
@_mounted = true
|
||||
@recomputeDimensions()
|
||||
|
||||
componentWillReceiveProps: (props) =>
|
||||
if @shouldInvalidateScrollbarComponent(props)
|
||||
@_scrollbarComponent = null
|
||||
|
||||
componentWillUnmount: =>
|
||||
@_mounted = false
|
||||
|
||||
shouldComponentUpdate: (newProps, newState) =>
|
||||
# Because this component renders @props.children, it needs to update
|
||||
# on props.children changes. Unfortunately, computing isEqual on the
|
||||
# @props.children tree extremely expensive. Just let React's algorithm do it's work.
|
||||
true
|
||||
|
||||
shouldInvalidateScrollbarComponent: (newProps) =>
|
||||
return true if newProps.scrollTooltipComponent isnt @props.scrollTooltipComponent
|
||||
return true if newProps.getScrollbar isnt @props.getScrollbar
|
||||
return false
|
||||
|
||||
render: =>
|
||||
containerClasses = "#{@props.className ? ''} " + classNames
|
||||
'scroll-region': true
|
||||
'dragging': @state.dragging
|
||||
'scrolling': @state.scrolling
|
||||
|
||||
scrollbar = []
|
||||
if not @props.getScrollbar
|
||||
scrollbar = <Scrollbar
|
||||
@_scrollbarComponent ?= <Scrollbar
|
||||
ref="scrollbar"
|
||||
scrollTooltipComponent={@props.scrollTooltipComponent}
|
||||
getScrollRegion={@_getSelf} />
|
||||
|
@ -154,7 +184,7 @@ class ScrollRegion extends React.Component
|
|||
otherProps = _.omit(@props, _.keys(@constructor.propTypes))
|
||||
|
||||
<div className={containerClasses} {...otherProps}>
|
||||
{scrollbar}
|
||||
{@_scrollbarComponent}
|
||||
<div className="scroll-region-content" onScroll={@_onScroll} ref="content">
|
||||
<div className="scroll-region-content-inner">
|
||||
{@props.children}
|
||||
|
@ -164,10 +194,88 @@ class ScrollRegion extends React.Component
|
|||
|
||||
# Public: Scroll to the DOM Node provided.
|
||||
#
|
||||
scrollTo: (node) =>
|
||||
container = React.findDOMNode(@)
|
||||
adjustment = DOMUtils.scrollAdjustmentToMakeNodeVisibleInContainer(node, container)
|
||||
@scrollTop += adjustment if adjustment isnt 0
|
||||
scrollTo: (node, {position, settle} = {}) =>
|
||||
if node instanceof React.Component
|
||||
node = React.findDOMNode(node)
|
||||
unless node instanceof Node
|
||||
throw new Error("ScrollRegion.scrollTo: requires a DOM node or React element. Maybe you meant scrollToRect?")
|
||||
@_scroll {position, settle}, =>
|
||||
node.getBoundingClientRect()
|
||||
|
||||
# Public: Scroll to the client rectangle provided. Note: This method expects
|
||||
# a ClientRect or similar object with top, left, width, height relative to the
|
||||
# window, not the scroll region. This is designed to make it easy to use with
|
||||
# node.getBoundingClientRect()
|
||||
scrollToRect: (rect, {position, settle} = {}) ->
|
||||
if rect instanceof Node
|
||||
throw new Error("ScrollRegion.scrollToRect: requires a rect. Maybe you meant scrollTo?")
|
||||
if not rect.top or not rect.height
|
||||
throw new Error("ScrollRegion.scrollToRect: requires a rect with `top` and `height` attributes.")
|
||||
@_scroll {position, settle}, => rect
|
||||
|
||||
_scroll: ({position, settle}, clientRectProviderCallback) ->
|
||||
contentNode = React.findDOMNode(@refs.content)
|
||||
position ?= ScrollRegion.ScrollPosition.Visible
|
||||
|
||||
if settle is true
|
||||
settleFn = @_settleHeight
|
||||
else
|
||||
settleFn = (callback) -> callback()
|
||||
|
||||
@_scrollToTaskId += 1
|
||||
taskId = @_scrollToTaskId
|
||||
|
||||
settleFn =>
|
||||
# If another scroll call has been made since ours, don't do anything.
|
||||
return unless @_scrollToTaskId is taskId
|
||||
|
||||
contentClientRect = contentNode.getBoundingClientRect()
|
||||
rect = _.clone(clientRectProviderCallback())
|
||||
|
||||
# For sanity's sake, convert the client rectangle we get into a rect
|
||||
# relative to the contentRect of our scroll region.
|
||||
rect.top = rect.top - contentClientRect.top + contentNode.scrollTop
|
||||
rect.bottom = rect.bottom - contentClientRect.top + contentNode.scrollTop
|
||||
|
||||
# Also give ourselves a representation of the visible region, in the same
|
||||
# coordinate space as `rect`
|
||||
contentVisibleRect = _.clone(contentClientRect)
|
||||
contentVisibleRect.top += contentNode.scrollTop
|
||||
contentVisibleRect.bottom += contentNode.scrollTop
|
||||
|
||||
if position is ScrollRegion.ScrollPosition.Top
|
||||
@scrollTop = rect.top
|
||||
else if position is ScrollRegion.ScrollPosition.Bottom
|
||||
@scrollTop = (rect.top + rect.height) - contentClientRect.height
|
||||
else if position is ScrollRegion.ScrollPosition.Center
|
||||
@scrollTop = rect.top - (contentClientRect.height - rect.height) / 2
|
||||
else if position is ScrollRegion.ScrollPosition.CenterIfInvisible
|
||||
if not Utils.rectVisibleInRect(rect, contentVisibleRect)
|
||||
@scrollTop = rect.top - (contentClientRect.height - rect.height) / 2
|
||||
else if position is ScrollRegion.ScrollPosition.Visible
|
||||
distanceBelowBottom = (rect.top + rect.height) - (contentClientRect.height + contentNode.scrollTop)
|
||||
distanceAboveTop = @scrollTop - rect.top
|
||||
if distanceBelowBottom >= 0
|
||||
@scrollTop += distanceBelowBottom
|
||||
else if distanceAboveTop >= 0
|
||||
@scrollTop -= distanceAboveTop
|
||||
|
||||
_settleHeight: (callback) =>
|
||||
contentNode = React.findDOMNode(@refs.content)
|
||||
lastContentHeight = -1
|
||||
stableCount = 0
|
||||
scrollIfSettled = =>
|
||||
return unless @_mounted
|
||||
contentRect = contentNode.getBoundingClientRect()
|
||||
if contentRect.height isnt lastContentHeight
|
||||
lastContentHeight = contentRect.height
|
||||
stableCount = 0
|
||||
else
|
||||
stableCount += 1
|
||||
if stableCount is 5
|
||||
return callback()
|
||||
window.requestAnimationFrame(scrollIfSettled)
|
||||
scrollIfSettled()
|
||||
|
||||
recomputeDimensions: (options = {}) =>
|
||||
scrollbar = @props.getScrollbar?() ? @refs.scrollbar
|
||||
|
|
|
@ -100,16 +100,17 @@ DOMUtils =
|
|||
|
||||
scrollAdjustmentToMakeNodeVisibleInContainer: (node, container) ->
|
||||
return unless node
|
||||
|
||||
nodeRect = node.getBoundingClientRect()
|
||||
containerRect = container.getBoundingClientRect()
|
||||
return @scrollAdjustmentToMakeRectVisibleInRect(nodeRect, containerRect)
|
||||
|
||||
scrollAdjustmentToMakeRectVisibleInRect: (nodeRect, containerRect) ->
|
||||
distanceBelowBottom = (nodeRect.top + nodeRect.height) - (containerRect.top + containerRect.height)
|
||||
if distanceBelowBottom > 0
|
||||
if distanceBelowBottom >= 0
|
||||
return distanceBelowBottom
|
||||
|
||||
distanceAboveTop = containerRect.top - nodeRect.top
|
||||
if distanceAboveTop > 0
|
||||
if distanceAboveTop >= 0
|
||||
return -distanceAboveTop
|
||||
|
||||
return 0
|
||||
|
|
|
@ -249,6 +249,9 @@ Utils =
|
|||
domain = _.last(email.toLowerCase().trim().split("@"))
|
||||
return (Utils.commonDomains[domain] ? false)
|
||||
|
||||
rectVisibleInRect: (r1, r2) ->
|
||||
return !(r2.left > r1.right || r2.right < r1.left || r2.top > r1.bottom || r2.bottom < r1.top)
|
||||
|
||||
isEqualReact: (a, b, options={}) ->
|
||||
options.functionsAreEqual = true
|
||||
options.ignoreKeys = (options.ignoreKeys ? []).push("localId")
|
||||
|
|
Loading…
Reference in a new issue