From b054ed431da17fb32e5a13244e0d8fb66589186b Mon Sep 17 00:00:00 2001 From: Ben Gotow Date: Thu, 30 Jul 2015 18:29:38 -0700 Subject: [PATCH] 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 --- .../composer/lib/composer-view.cjsx | 12 +- .../lib/contenteditable-component.cjsx | 47 +++--- .../message-list/lib/message-item.cjsx | 40 ++--- .../message-list/lib/message-list.cjsx | 146 ++++-------------- .../stylesheets/message-list.less | 33 ++-- src/components/scroll-region.cjsx | 124 ++++++++++++++- src/dom-utils.coffee | 7 +- src/flux/models/utils.coffee | 3 + 8 files changed, 222 insertions(+), 190 deletions(-) diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx index 9a315ba26..7a585c77f 100644 --- a/internal_packages/composer/lib/composer-view.cjsx +++ b/internal_packages/composer/lib/composer-view.cjsx @@ -157,7 +157,7 @@ class ComposerView extends React.Component _wrapClasses: => - "composer-outer-wrap #{@props.className ? ""}" + "message-item-white-wrap composer-outer-wrap #{@props.className ? ""}" _renderComposer: => _renderBodyContenteditable: => + onScrollToBottom = null + if @props.onRequestScrollTo + onScrollToBottom = => + @props.onRequestScrollTo({messageId: @_proxy.draft().id}) + _renderFooterRegions: => return
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 diff --git a/internal_packages/composer/lib/contenteditable-component.cjsx b/internal_packages/composer/lib/contenteditable-component.cjsx index 445b6e4a8..b4bc9068f 100644 --- a/internal_packages/composer/lib/contenteditable-component.cjsx +++ b/internal_packages/composer/lib/contenteditable-component.cjsx @@ -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 diff --git a/internal_packages/message-list/lib/message-item.cjsx b/internal_packages/message-list/lib/message-item.cjsx index c91e42796..72a97f6e3 100644 --- a/internal_packages/message-list/lib/message-item.cjsx +++ b/internal_packages/message-list/lib/message-item.cjsx @@ -58,30 +58,34 @@ class MessageItem extends React.Component attachmentIcon =
-
-
- {@props.message.from?[0]?.displayFirstName()} +
+
+
+ {@props.message.from?[0]?.displayFirstName()} +
+
+ {@props.message.snippet} +
+
+ +
+ {attachmentIcon}
-
- {@props.message.snippet} -
-
- -
- {attachmentIcon}
_renderFull: =>
-
- {@_renderHeader()} - - {@_formatBody()} - - - {@_renderEvents()} - {@_renderAttachments()} +
+
+ {@_renderHeader()} + + {@_formatBody()} + + + {@_renderEvents()} + {@_renderAttachments()} +
diff --git a/internal_packages/message-list/lib/message-list.cjsx b/internal_packages/message-list/lib/message-list.cjsx index a982bbfe3..41102fb69 100755 --- a/internal_packages/message-list/lib/message-list.cjsx +++ b/internal_packages/message-list/lib/message-list.cjsx @@ -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
{@_renderSubject()}
@@ -250,7 +229,7 @@ class MessageList extends React.Component
{@_messageComponents()}
- +
_renderSubject: -> @@ -273,7 +252,8 @@ class MessageList extends React.Component Write a reply…
- else return
+ else +
_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 else components.push - 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 diff --git a/internal_packages/message-list/stylesheets/message-list.less b/internal_packages/message-list/stylesheets/message-list.less index 795d2d38c..0ba9ef514 100644 --- a/internal_packages/message-list/stylesheets/message-list.less +++ b/internal_packages/message-list/stylesheets/message-list.less @@ -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 diff --git a/src/components/scroll-region.cjsx b/src/components/scroll-region.cjsx index ef3a04fca..c82504f32 100644 --- a/src/components/scroll-region.cjsx +++ b/src/components/scroll-region.cjsx @@ -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 = @@ -154,7 +184,7 @@ class ScrollRegion extends React.Component otherProps = _.omit(@props, _.keys(@constructor.propTypes))
- {scrollbar} + {@_scrollbarComponent}
{@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 diff --git a/src/dom-utils.coffee b/src/dom-utils.coffee index 157beb36b..a5fd9fdb7 100644 --- a/src/dom-utils.coffee +++ b/src/dom-utils.coffee @@ -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 diff --git a/src/flux/models/utils.coffee b/src/flux/models/utils.coffee index dcd3a6db8..fb173943b 100644 --- a/src/flux/models/utils.coffee +++ b/src/flux/models/utils.coffee @@ -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")