mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-09-11 15:14:31 +08:00
feat(swipe-to-*): Gesture support and animation in thread list
Summary: This diff does two things: - It adds a new SwipeContainer that makes it easy to implement swipe gestures. This is built into listTabular, so you can create a list and define onSwipeLeft/Right to enable gestures. - It adds support for basic add/remove animations to the thread list. This works by adding a CSS transition to `top` and also leaving removed rows around for a specified time. (these times need to match.) Pretty much just cloned the core idea from TimeoutTransitionGroup. Test Plan: No tests yet Reviewers: evan, juan Reviewed By: evan Differential Revision: https://phab.nylas.com/D2581
This commit is contained in:
parent
3b8cc984d0
commit
f63b7e66e4
19 changed files with 453 additions and 71 deletions
|
@ -15,7 +15,7 @@ class ThreadTrashButton extends React.Component
|
||||||
|
|
||||||
render: =>
|
render: =>
|
||||||
focusedMailboxPerspective = FocusedPerspectiveStore.current()
|
focusedMailboxPerspective = FocusedPerspectiveStore.current()
|
||||||
return false unless focusedMailboxPerspective?.canTrashThreads()
|
return false unless focusedMailboxPerspective.canTrashThreads()
|
||||||
|
|
||||||
<button className="btn btn-toolbar"
|
<button className="btn btn-toolbar"
|
||||||
style={order: -106}
|
style={order: -106}
|
||||||
|
|
|
@ -18,7 +18,7 @@ class ThreadBulkArchiveButton extends React.Component
|
||||||
|
|
||||||
render: ->
|
render: ->
|
||||||
mailboxPerspective = FocusedPerspectiveStore.current()
|
mailboxPerspective = FocusedPerspectiveStore.current()
|
||||||
return false unless mailboxPerspective?.canArchiveThreads()
|
return false unless mailboxPerspective.canArchiveThreads()
|
||||||
|
|
||||||
<button style={order:-107}
|
<button style={order:-107}
|
||||||
className="btn btn-toolbar"
|
className="btn btn-toolbar"
|
||||||
|
@ -43,7 +43,7 @@ class ThreadBulkTrashButton extends React.Component
|
||||||
|
|
||||||
render: ->
|
render: ->
|
||||||
mailboxPerspective = FocusedPerspectiveStore.current()
|
mailboxPerspective = FocusedPerspectiveStore.current()
|
||||||
return false unless mailboxPerspective?.canTrashThreads()
|
return false unless mailboxPerspective.canTrashThreads()
|
||||||
|
|
||||||
<button style={order:-106}
|
<button style={order:-106}
|
||||||
className="btn btn-toolbar"
|
className="btn btn-toolbar"
|
||||||
|
|
|
@ -13,7 +13,7 @@ class ThreadArchiveQuickAction extends React.Component
|
||||||
mailboxPerspective = FocusedPerspectiveStore.current()
|
mailboxPerspective = FocusedPerspectiveStore.current()
|
||||||
archive = null
|
archive = null
|
||||||
|
|
||||||
if mailboxPerspective?.canArchiveThreads()
|
if mailboxPerspective.canArchiveThreads()
|
||||||
archive = <div key="archive"
|
archive = <div key="archive"
|
||||||
title="Archive"
|
title="Archive"
|
||||||
style={{ order: 100 }}
|
style={{ order: 100 }}
|
||||||
|
@ -42,7 +42,7 @@ class ThreadTrashQuickAction extends React.Component
|
||||||
mailboxPerspective = FocusedPerspectiveStore.current()
|
mailboxPerspective = FocusedPerspectiveStore.current()
|
||||||
trash = null
|
trash = null
|
||||||
|
|
||||||
if mailboxPerspective?.canTrashThreads()
|
if mailboxPerspective.canTrashThreads()
|
||||||
trash = <div key="remove"
|
trash = <div key="remove"
|
||||||
title="Trash"
|
title="Trash"
|
||||||
style={{ order: 110 }}
|
style={{ order: 110 }}
|
||||||
|
|
|
@ -98,8 +98,23 @@ class ThreadList extends React.Component
|
||||||
</FluxContainer>
|
</FluxContainer>
|
||||||
|
|
||||||
_threadPropsProvider: (item) ->
|
_threadPropsProvider: (item) ->
|
||||||
className: classNames
|
props =
|
||||||
'unread': item.unread
|
className: classNames
|
||||||
|
'unread': item.unread
|
||||||
|
|
||||||
|
perspective = FocusedPerspectiveStore.current()
|
||||||
|
account = AccountStore.accountForId(item.accountId)
|
||||||
|
finishedName = account.defaultFinishedCategory()?.name
|
||||||
|
|
||||||
|
if finishedName is 'trash' and perspective.canTrashThreads()
|
||||||
|
props.onSwipeRightClass = 'swipe-trash'
|
||||||
|
props.onSwipeRight = -> perspective.removeThreads([item])
|
||||||
|
|
||||||
|
else if finishedName in ['archive', 'all'] and perspective.canArchiveThreads()
|
||||||
|
props.onSwipeRightClass = 'swipe-archive'
|
||||||
|
props.onSwipeRight = -> perspective.removeThreads([item])
|
||||||
|
|
||||||
|
props
|
||||||
|
|
||||||
_threadMouseManipulateData: (event) ->
|
_threadMouseManipulateData: (event) ->
|
||||||
itemThreadId = @refs.list.itemIdAtPoint(event.clientX, event.clientY)
|
itemThreadId = @refs.list.itemIdAtPoint(event.clientX, event.clientY)
|
||||||
|
|
|
@ -48,6 +48,80 @@
|
||||||
-webkit-font-smoothing: subpixel-antialiased;
|
-webkit-font-smoothing: subpixel-antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.swipe-backing {
|
||||||
|
background-color: darken(@background-primary, 10%);
|
||||||
|
&::after {
|
||||||
|
color: fade(white, 90%);
|
||||||
|
padding-top: 45px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: @font-size-small;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
transform: translateX(0%);
|
||||||
|
width: 80px;
|
||||||
|
bottom: 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity linear 150ms;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: 50% 35%;
|
||||||
|
background-size: 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.swipe-trash {
|
||||||
|
transition: background-color linear 150ms;
|
||||||
|
background-color: mix(#ed304b, @background-primary, 75%);
|
||||||
|
&::after {
|
||||||
|
transition: left linear 150ms, transform linear 150ms;
|
||||||
|
content: "Trash";
|
||||||
|
left: 0;
|
||||||
|
background-image: url(../static/images/swipe/icon-swipe-trash@2x.png);
|
||||||
|
}
|
||||||
|
&.confirmed {
|
||||||
|
background-color: #ed304b;
|
||||||
|
&::after {
|
||||||
|
left: 100%;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.swipe-archive {
|
||||||
|
transition: background-color linear 150ms;
|
||||||
|
background-color: mix(#6cd420, @background-primary, 75%);
|
||||||
|
&::after {
|
||||||
|
transition: left linear 150ms, transform linear 150ms;
|
||||||
|
content: "Archive";
|
||||||
|
left: 0;
|
||||||
|
background-image: url(../static/images/swipe/icon-swipe-archive@2x.png);
|
||||||
|
}
|
||||||
|
&.confirmed {
|
||||||
|
background-color: #6cd420;
|
||||||
|
&::after {
|
||||||
|
left: 100%;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.swipe-snooze {
|
||||||
|
background-color: mix(#8d6be3, @background-primary, 75%);
|
||||||
|
&::after {
|
||||||
|
content: "Snooze";
|
||||||
|
left: 0;
|
||||||
|
background-image: url(../static/images/swipe/icon-swipe-snooze@2x.png);
|
||||||
|
}
|
||||||
|
&.confirmed {
|
||||||
|
background-color: #8d6be3;
|
||||||
|
&::after {
|
||||||
|
left: 100%;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.list-item {
|
.list-item {
|
||||||
background-color: darken(@background-primary, 2%);
|
background-color: darken(@background-primary, 2%);
|
||||||
border-bottom: 1px solid fade(@list-border, 60%);
|
border-bottom: 1px solid fade(@list-border, 60%);
|
||||||
|
|
|
@ -1,9 +1,15 @@
|
||||||
{AccountStore, MailboxPerspective, TaskFactory, Category, Actions, DatabaseStore} = require 'nylas-exports'
|
{AccountStore,
|
||||||
|
MailboxPerspective,
|
||||||
|
TaskFactory,
|
||||||
|
Category,
|
||||||
|
Thread,
|
||||||
|
Actions,
|
||||||
|
DatabaseStore} = require 'nylas-exports'
|
||||||
|
|
||||||
|
|
||||||
describe 'MailboxPerspective', ->
|
describe 'MailboxPerspective', ->
|
||||||
beforeEach ->
|
beforeEach ->
|
||||||
spyOn(AccountStore, 'accountForId').andReturn {categoryIcon: -> 'icon'}
|
spyOn(AccountStore, 'accountForId').andReturn(AccountStore.accounts()[0])
|
||||||
@accountIds = ['a1', 'a2', 'a3']
|
@accountIds = ['a1', 'a2', 'a3']
|
||||||
@perspective = new MailboxPerspective(@accountIds)
|
@perspective = new MailboxPerspective(@accountIds)
|
||||||
|
|
||||||
|
@ -51,40 +57,50 @@ describe 'MailboxPerspective', ->
|
||||||
|
|
||||||
describe 'removeThreads', ->
|
describe 'removeThreads', ->
|
||||||
beforeEach ->
|
beforeEach ->
|
||||||
@threads = ['t1', 't2']
|
@threads = [new Thread(id:'t1'), new Thread(id: 't2')]
|
||||||
@taskArgs = {threads: @threads, categories: @categories}
|
|
||||||
spyOn(Actions, 'queueTasks')
|
spyOn(Actions, 'queueTasks')
|
||||||
spyOn(DatabaseStore, 'modelify').andReturn then: (cb) => cb(@threads)
|
spyOn(DatabaseStore, 'modelify').andReturn then: (cb) => cb(@threads)
|
||||||
|
|
||||||
it 'moves the threads to finished category if in inbox', ->
|
it 'moves the threads to finished category if in inbox', ->
|
||||||
spyOn(@perspective, 'isInbox').andReturn true
|
|
||||||
spyOn(@perspective, 'canTrashThreads').andReturn true
|
|
||||||
spyOn(@perspective, 'canArchiveThreads').andReturn true
|
|
||||||
spyOn(TaskFactory, 'tasksForRemovingCategories')
|
spyOn(TaskFactory, 'tasksForRemovingCategories')
|
||||||
|
@categories = [
|
||||||
|
new Category(name: 'inbox', accountId: 'a1')
|
||||||
|
new Category(name: 'inbox', accountId: 'a2')
|
||||||
|
new Category(name: 'inbox', accountId: 'a2')
|
||||||
|
]
|
||||||
|
@perspective = MailboxPerspective.forCategories(@categories)
|
||||||
@perspective.removeThreads(@threads)
|
@perspective.removeThreads(@threads)
|
||||||
@taskArgs.moveToFinishedCategory = true
|
expect(TaskFactory.tasksForRemovingCategories).toHaveBeenCalledWith({
|
||||||
expect(TaskFactory.tasksForRemovingCategories).toHaveBeenCalledWith(@taskArgs)
|
threads: @threads,
|
||||||
|
moveToFinishedCategory: true,
|
||||||
|
categories: @categories
|
||||||
|
})
|
||||||
|
|
||||||
it 'moves threads to inbox if in trash', ->
|
it 'moves threads to inbox if in trash', ->
|
||||||
spyOn(@perspective, 'isInbox').andReturn false
|
|
||||||
spyOn(@perspective, 'canTrashThreads').andReturn false
|
|
||||||
spyOn(@perspective, 'canArchiveThreads').andReturn true
|
|
||||||
spyOn(TaskFactory, 'tasksForMovingToInbox')
|
spyOn(TaskFactory, 'tasksForMovingToInbox')
|
||||||
|
@categories = [
|
||||||
|
new Category(name: 'trash', accountId: 'a1')
|
||||||
|
new Category(name: 'trash', accountId: 'a2')
|
||||||
|
new Category(name: 'trash', accountId: 'a2')
|
||||||
|
]
|
||||||
|
@perspective = MailboxPerspective.forCategories(@categories)
|
||||||
@perspective.removeThreads(@threads)
|
@perspective.removeThreads(@threads)
|
||||||
expect(TaskFactory.tasksForMovingToInbox).toHaveBeenCalledWith({threads: @threads, fromPerspective: @perspective})
|
expect(TaskFactory.tasksForMovingToInbox).toHaveBeenCalledWith({
|
||||||
|
threads: @threads,
|
||||||
|
fromPerspective: @perspective
|
||||||
|
})
|
||||||
|
|
||||||
it 'removes categories if the current perspective does not correspond to archive or sent', ->
|
it 'removes categories if the current perspective does not correspond to archive or sent', ->
|
||||||
spyOn(@perspective, 'isInbox').andReturn false
|
|
||||||
spyOn(@perspective, 'canTrashThreads').andReturn true
|
|
||||||
spyOn(@perspective, 'canArchiveThreads').andReturn true
|
|
||||||
spyOn(TaskFactory, 'tasksForRemovingCategories')
|
spyOn(TaskFactory, 'tasksForRemovingCategories')
|
||||||
|
@categories = [
|
||||||
|
new Category(displayName: 'c1', accountId: 'a1')
|
||||||
|
new Category(displayName: 'c2', accountId: 'a2')
|
||||||
|
new Category(displayName: 'c3', accountId: 'a2')
|
||||||
|
]
|
||||||
|
@perspective = MailboxPerspective.forCategories(@categories)
|
||||||
@perspective.removeThreads(@threads)
|
@perspective.removeThreads(@threads)
|
||||||
@taskArgs.moveToFinishedCategory = false
|
expect(TaskFactory.tasksForRemovingCategories).toHaveBeenCalledWith({
|
||||||
expect(TaskFactory.tasksForRemovingCategories).toHaveBeenCalledWith(@taskArgs)
|
threads: @threads,
|
||||||
|
moveToFinishedCategory: false,
|
||||||
it 'does nothing otherwise', ->
|
categories: @categories
|
||||||
spyOn(@perspective, 'isInbox').andReturn false
|
})
|
||||||
spyOn(@perspective, 'canTrashThreads').andReturn true
|
|
||||||
spyOn(@perspective, 'canArchiveThreads').andReturn false
|
|
||||||
@perspective.removeThreads(@threads)
|
|
||||||
expect(Actions.queueTasks).not.toHaveBeenCalled()
|
|
||||||
|
|
|
@ -162,6 +162,12 @@ class NylasWindow
|
||||||
@browserWindow.on 'closed', =>
|
@browserWindow.on 'closed', =>
|
||||||
global.application.windowManager.removeWindow(this)
|
global.application.windowManager.removeWindow(this)
|
||||||
|
|
||||||
|
@browserWindow.on 'scroll-touch-begin', =>
|
||||||
|
@browserWindow.webContents.send('scroll-touch-begin')
|
||||||
|
|
||||||
|
@browserWindow.on 'scroll-touch-end', =>
|
||||||
|
@browserWindow.webContents.send('scroll-touch-end')
|
||||||
|
|
||||||
@browserWindow.on 'focus', =>
|
@browserWindow.on 'focus', =>
|
||||||
@browserWindow.webContents.send('browser-window-focus')
|
@browserWindow.webContents.send('browser-window-focus')
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
_ = require 'underscore'
|
_ = require 'underscore'
|
||||||
React = require 'react/addons'
|
React = require 'react/addons'
|
||||||
|
SwipeContainer = require './swipe-container'
|
||||||
{Utils} = require 'nylas-exports'
|
{Utils} = require 'nylas-exports'
|
||||||
|
|
||||||
class ListTabularItem extends React.Component
|
class ListTabularItem extends React.Component
|
||||||
|
@ -32,9 +33,11 @@ class ListTabularItem extends React.Component
|
||||||
# We only do it if the item prop has changed.
|
# We only do it if the item prop has changed.
|
||||||
@_columnCache ?= @_columns()
|
@_columnCache ?= @_columns()
|
||||||
|
|
||||||
<div {...props} className={className} onClick={@_onClick} style={position:'absolute', top: @props.metrics.top, width:'100%', height:@props.metrics.height}>
|
<SwipeContainer {...props} onClick={@_onClick} style={position:'absolute', top: @props.metrics.top, width:'100%', height:@props.metrics.height}>
|
||||||
{@_columnCache}
|
<div className={className} style={height:@props.metrics.height}>
|
||||||
</div>
|
{@_columnCache}
|
||||||
|
</div>
|
||||||
|
</SwipeContainer>
|
||||||
|
|
||||||
_columns: =>
|
_columns: =>
|
||||||
names = {}
|
names = {}
|
||||||
|
|
|
@ -35,6 +35,7 @@ class ListTabular extends React.Component
|
||||||
|
|
||||||
componentWillUnmount: =>
|
componentWillUnmount: =>
|
||||||
window.removeEventListener('resize', @onWindowResize, true)
|
window.removeEventListener('resize', @onWindowResize, true)
|
||||||
|
window.clearTimeout(@_cleanupAnimationTimeout) if @_cleanupAnimationTimeout
|
||||||
@_unlisten?()
|
@_unlisten?()
|
||||||
|
|
||||||
componentWillReceiveProps: (nextProps) =>
|
componentWillReceiveProps: (nextProps) =>
|
||||||
|
@ -53,15 +54,39 @@ class ListTabular extends React.Component
|
||||||
dataSource ?= @props.dataSource
|
dataSource ?= @props.dataSource
|
||||||
|
|
||||||
items = {}
|
items = {}
|
||||||
|
animatingOut = {}
|
||||||
|
|
||||||
[start..end].forEach (idx) =>
|
[start..end].forEach (idx) =>
|
||||||
items[idx] = dataSource.get(idx)
|
items[idx] = dataSource.get(idx)
|
||||||
|
|
||||||
|
# If we have a previous state, and the previous range matches the new range,
|
||||||
|
# (eg: we're not scrolling), identify removed items. We'll render them in one
|
||||||
|
# last time but not allocate height to them. This allows us to animate them
|
||||||
|
# being covered by other items, not just disappearing when others start to slide up.
|
||||||
|
if @state and start is @state.renderedRangeStart
|
||||||
|
nextIds = _.pluck(_.values(items), 'id')
|
||||||
|
animatingOut = {}
|
||||||
|
|
||||||
|
# Keep items which are still animating out and are still not in the set
|
||||||
|
for recordIdx, record of @state.animatingOut
|
||||||
|
if Date.now() < record.end and not (record.item.id in nextIds)
|
||||||
|
animatingOut[recordIdx] = record
|
||||||
|
|
||||||
|
# Add items which are no longer found in the set
|
||||||
|
for previousIdx, previousItem of @state.items
|
||||||
|
continue if !previousItem or previousItem.id in nextIds
|
||||||
|
animatingOut[previousIdx] =
|
||||||
|
item: previousItem
|
||||||
|
idx: previousIdx
|
||||||
|
end: Date.now() + 125
|
||||||
|
|
||||||
renderedRangeStart: start
|
renderedRangeStart: start
|
||||||
renderedRangeEnd: end
|
renderedRangeEnd: end
|
||||||
count: dataSource.count()
|
count: dataSource.count()
|
||||||
loaded: dataSource.loaded()
|
loaded: dataSource.loaded()
|
||||||
empty: dataSource.empty()
|
empty: dataSource.empty()
|
||||||
items: items
|
items: items
|
||||||
|
animatingOut: animatingOut
|
||||||
|
|
||||||
componentDidUpdate: (prevProps, prevState) =>
|
componentDidUpdate: (prevProps, prevState) =>
|
||||||
# If our view has been swapped out for an entirely different one,
|
# If our view has been swapped out for an entirely different one,
|
||||||
|
@ -73,6 +98,23 @@ class ListTabular extends React.Component
|
||||||
@updateRangeState()
|
@updateRangeState()
|
||||||
@updateRangeStateFiring = false
|
@updateRangeStateFiring = false
|
||||||
|
|
||||||
|
unless @_cleanupAnimationTimeout
|
||||||
|
@_cleanupAnimationTimeout = window.setTimeout(@onCleanupAnimatingItems, 50)
|
||||||
|
|
||||||
|
onCleanupAnimatingItems: =>
|
||||||
|
@_cleanupAnimationTimeout = null
|
||||||
|
|
||||||
|
nextAnimatingOut = {}
|
||||||
|
for idx, record of @state.animatingOut
|
||||||
|
if Date.now() < record.end
|
||||||
|
nextAnimatingOut[idx] = record
|
||||||
|
|
||||||
|
if Object.keys(nextAnimatingOut).length < Object.keys(@state.animatingOut).length
|
||||||
|
@setState(animatingOut: nextAnimatingOut)
|
||||||
|
|
||||||
|
if Object.keys(nextAnimatingOut).length > 0
|
||||||
|
@_cleanupAnimationTimeout = window.setTimeout(@onCleanupAnimatingItems, 50)
|
||||||
|
|
||||||
onWindowResize: =>
|
onWindowResize: =>
|
||||||
@_onWindowResize ?= _.debounce(@updateRangeState, 50)
|
@_onWindowResize ?= _.debounce(@updateRangeState, 50)
|
||||||
@_onWindowResize()
|
@_onWindowResize()
|
||||||
|
@ -132,19 +174,29 @@ class ListTabular extends React.Component
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
_rows: =>
|
_rows: =>
|
||||||
[@state.renderedRangeStart..@state.renderedRangeEnd-1].map (idx) =>
|
# The ordering of the results array is important. We want current rows to
|
||||||
item = @state.items[idx]
|
# slide over rows which are animating out, so we need to render them last.
|
||||||
return false unless item
|
results = []
|
||||||
|
for idx, record of @state.animatingOut
|
||||||
|
results.push @_rowForItem(record.item, idx / 1)
|
||||||
|
|
||||||
<ListTabularItem key={item.id ? idx}
|
[@state.renderedRangeStart..@state.renderedRangeEnd].forEach (idx) =>
|
||||||
item={item}
|
if @state.items[idx]
|
||||||
itemProps={@props.itemPropsProvider?(item, idx) ? {}}
|
results.push @_rowForItem(@state.items[idx], idx)
|
||||||
metrics={top: idx * @props.itemHeight, height: @props.itemHeight}
|
|
||||||
columns={@props.columns}
|
results
|
||||||
onSelect={@props.onSelect}
|
|
||||||
onClick={@props.onClick}
|
_rowForItem: (item, idx) =>
|
||||||
onReorder={@props.onReorder}
|
return false unless item
|
||||||
onDoubleClick={@props.onDoubleClick} />
|
<ListTabularItem key={item.id ? idx}
|
||||||
|
item={item}
|
||||||
|
itemProps={@props.itemPropsProvider?(item, idx) ? {}}
|
||||||
|
metrics={top: idx * @props.itemHeight, height: @props.itemHeight}
|
||||||
|
columns={@props.columns}
|
||||||
|
onSelect={@props.onSelect}
|
||||||
|
onClick={@props.onClick}
|
||||||
|
onReorder={@props.onReorder}
|
||||||
|
onDoubleClick={@props.onDoubleClick} />
|
||||||
|
|
||||||
# Public: Scroll to the DOM node provided.
|
# Public: Scroll to the DOM node provided.
|
||||||
#
|
#
|
||||||
|
|
|
@ -209,7 +209,7 @@ class MultiselectList extends React.Component
|
||||||
# Public Methods
|
# Public Methods
|
||||||
|
|
||||||
itemIdAtPoint: (x, y) ->
|
itemIdAtPoint: (x, y) ->
|
||||||
item = document.elementFromPoint(event.clientX, event.clientY).closest('.list-item')
|
item = document.elementFromPoint(event.clientX, event.clientY).closest('[data-item-id]')
|
||||||
return null unless item
|
return null unless item
|
||||||
return item.dataset.itemId
|
return item.dataset.itemId
|
||||||
|
|
||||||
|
|
209
src/components/swipe-container.jsx
Normal file
209
src/components/swipe-container.jsx
Normal file
|
@ -0,0 +1,209 @@
|
||||||
|
import React, {Component, PropTypes} from 'react';
|
||||||
|
import _ from 'underscore';
|
||||||
|
|
||||||
|
const Phase = {
|
||||||
|
// No wheel events received yet, container is inactive.
|
||||||
|
None: 'none',
|
||||||
|
|
||||||
|
// Wheel events received
|
||||||
|
GestureStarting: 'gesture-starting',
|
||||||
|
|
||||||
|
// Wheel events received and we are stopping event propagation.
|
||||||
|
GestureConfirmed: 'gesture-confirmed',
|
||||||
|
|
||||||
|
// Fingers lifted, we are animating to a final state.
|
||||||
|
Settling: 'settling',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class SwipeContainer extends Component {
|
||||||
|
static displayName = 'SwipeContainer';
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
children: PropTypes.object.isRequired,
|
||||||
|
onSwipeLeft: React.PropTypes.func,
|
||||||
|
onSwipeLeftClass: React.PropTypes.string,
|
||||||
|
onSwipeRight: React.PropTypes.func,
|
||||||
|
onSwipeRightClass: React.PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.tracking = false;
|
||||||
|
this.phase = Phase.None;
|
||||||
|
this.fired = false;
|
||||||
|
this.state = {
|
||||||
|
fullDistance: 'unknown',
|
||||||
|
velocity: 0,
|
||||||
|
currentX: 0,
|
||||||
|
targetX: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
window.addEventListener('scroll-touch-begin', this._onScrollTouchBegin);
|
||||||
|
window.addEventListener('scroll-touch-end', this._onScrollTouchEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// componentWillReceiveProps(nextProps) {
|
||||||
|
// if (nextProps.children !== this.props.children) {
|
||||||
|
// this.setState({currentX: 0, velocity: 0, targetX: 0, settling: false});
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
componentDidUpdate() {
|
||||||
|
if (this.phase === Phase.Settling) {
|
||||||
|
window.requestAnimationFrame(()=> {
|
||||||
|
if (this.phase === Phase.Settling) {
|
||||||
|
this._settle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.phase = Phase.None;
|
||||||
|
window.removeEventListener('scroll-touch-begin', this._onScrollTouchBegin);
|
||||||
|
window.removeEventListener('scroll-touch-end', this._onScrollTouchEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
_isEnabled = ()=> {
|
||||||
|
return (this.props.onSwipeLeft || this.props.onSwipeRight);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onWheel = (e)=> {
|
||||||
|
if ((this.tracking === false) || !this._isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const velocity = e.deltaX / 3;
|
||||||
|
const velocityConfirmsGesture = Math.abs(velocity) > 3;
|
||||||
|
|
||||||
|
if (this.phase === Phase.None) {
|
||||||
|
this.phase = Phase.GestureStarting;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (velocityConfirmsGesture || (this.phase === Phase.Settling)) {
|
||||||
|
this.phase = Phase.GestureConfirmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.phase === Phase.GestureConfirmed) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
let {fullDistance, thresholdDistance} = this.state;
|
||||||
|
|
||||||
|
if (fullDistance === 'unknown') {
|
||||||
|
fullDistance = React.findDOMNode(this).clientWidth;
|
||||||
|
thresholdDistance = 110;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentX = Math.max(-fullDistance, Math.min(fullDistance, this.state.currentX + velocity));
|
||||||
|
const estimatedSettleX = currentX + velocity * 8;
|
||||||
|
let targetX = 0;
|
||||||
|
|
||||||
|
if (this.props.onSwipeLeft && (estimatedSettleX > thresholdDistance)) {
|
||||||
|
targetX = fullDistance;
|
||||||
|
}
|
||||||
|
if (this.props.onSwipeRight && (estimatedSettleX < -thresholdDistance)) {
|
||||||
|
targetX = -fullDistance;
|
||||||
|
}
|
||||||
|
this.setState({thresholdDistance, fullDistance, velocity, currentX, targetX})
|
||||||
|
}
|
||||||
|
|
||||||
|
_onScrollTouchBegin = ()=> {
|
||||||
|
this.tracking = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_onScrollTouchEnd = ()=> {
|
||||||
|
this.tracking = false;
|
||||||
|
if (this.phase !== Phase.None) {
|
||||||
|
this.phase = Phase.Settling;
|
||||||
|
this.fired = false;
|
||||||
|
this._settle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_settle() {
|
||||||
|
const {currentX, targetX} = this.state;
|
||||||
|
let {velocity} = this.state;
|
||||||
|
let step = 0;
|
||||||
|
|
||||||
|
let shouldFire = false;
|
||||||
|
let shouldFinish = false;
|
||||||
|
|
||||||
|
if (targetX === 0) {
|
||||||
|
// settle
|
||||||
|
step = (targetX - currentX) / 6.0;
|
||||||
|
shouldFinish = (Math.abs(step) < 0.05);
|
||||||
|
} else {
|
||||||
|
// accelerate offscreen
|
||||||
|
if (Math.abs(velocity) < Math.abs((targetX - currentX) / 48.0)) {
|
||||||
|
velocity = (targetX - currentX) / 48.0;
|
||||||
|
} else {
|
||||||
|
velocity = velocity * 1.08;
|
||||||
|
}
|
||||||
|
step = velocity;
|
||||||
|
|
||||||
|
const fraction = Math.abs(currentX) / Math.abs(targetX);
|
||||||
|
shouldFire = ((fraction >= 0.8) && (!this.fired));
|
||||||
|
shouldFinish = (fraction >= 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldFire) {
|
||||||
|
this.fired = true;
|
||||||
|
if (targetX < 0) {
|
||||||
|
this.props.onSwipeRight();
|
||||||
|
}
|
||||||
|
if (targetX > 0) {
|
||||||
|
this.props.onSwipeLeft();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldFinish) {
|
||||||
|
this.phase = Phase.None;
|
||||||
|
this.setState({
|
||||||
|
velocity: 0,
|
||||||
|
currentX: targetX,
|
||||||
|
targetX: targetX,
|
||||||
|
thresholdDistance: 'unknown',
|
||||||
|
fullDistance: 'unknown',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.phase = Phase.Settling;
|
||||||
|
this.setState({
|
||||||
|
velocity: velocity,
|
||||||
|
currentX: currentX + step,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {currentX, targetX} = this.state;
|
||||||
|
const otherProps = _.omit(this.props, _.keys(this.constructor.propTypes));
|
||||||
|
|
||||||
|
const backingStyles = {top: 0, bottom: 0, position: 'absolute'};
|
||||||
|
let backingClass = 'swipe-backing';
|
||||||
|
|
||||||
|
if (currentX > 0) {
|
||||||
|
backingClass += ' ' + this.props.onSwipeLeftClass;
|
||||||
|
backingStyles.right = 0;
|
||||||
|
backingStyles.width = currentX + 1;
|
||||||
|
if (targetX > 0) {
|
||||||
|
backingClass += ' confirmed';
|
||||||
|
}
|
||||||
|
} else if (currentX < 0) {
|
||||||
|
backingClass += ' ' + this.props.onSwipeRightClass;
|
||||||
|
backingStyles.left = 0;
|
||||||
|
backingStyles.width = -currentX + 1;
|
||||||
|
if (targetX < 0) {
|
||||||
|
backingClass += ' confirmed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div onWheel={this._onWheel} {...otherProps}>
|
||||||
|
<div style={backingStyles} className={backingClass}></div>
|
||||||
|
<div style={{transform: 'translate3d(' + -currentX + 'px, 0, 0)'}}>
|
||||||
|
{this.props.children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,6 +17,7 @@ class NylasComponentKit
|
||||||
@load "Popover", 'popover'
|
@load "Popover", 'popover'
|
||||||
@load "Flexbox", 'flexbox'
|
@load "Flexbox", 'flexbox'
|
||||||
@load "RetinaImg", 'retina-img'
|
@load "RetinaImg", 'retina-img'
|
||||||
|
@load "SwipeContainer", 'swipe-container'
|
||||||
@load "FluxContainer", 'flux-container'
|
@load "FluxContainer", 'flux-container'
|
||||||
@load "ListTabular", 'list-tabular'
|
@load "ListTabular", 'list-tabular'
|
||||||
@load "DraggableImg", 'draggable-img'
|
@load "DraggableImg", 'draggable-img'
|
||||||
|
|
|
@ -210,6 +210,8 @@ class StarredMailboxPerspective extends MailboxPerspective
|
||||||
Actions.queueTask(task)
|
Actions.queueTask(task)
|
||||||
|
|
||||||
removeThreads: (threadsOrIds) =>
|
removeThreads: (threadsOrIds) =>
|
||||||
|
unless threadsOrIds instanceof Array
|
||||||
|
throw new Error("removeThreads: you must pass an array of threads or thread ids")
|
||||||
task = TaskFactory.taskForInvertingStarred(threads: threadsOrIds)
|
task = TaskFactory.taskForInvertingStarred(threads: threadsOrIds)
|
||||||
Actions.queueTask(task)
|
Actions.queueTask(task)
|
||||||
|
|
||||||
|
@ -292,7 +294,7 @@ class CategoryMailboxPerspective extends MailboxPerspective
|
||||||
|
|
||||||
canTrashThreads: =>
|
canTrashThreads: =>
|
||||||
for cat in @_categories
|
for cat in @_categories
|
||||||
return false if cat.name in ["trash"]
|
return false if cat.name in ["trash", "sent"]
|
||||||
super
|
super
|
||||||
|
|
||||||
receiveThreads: (threadsOrIds) =>
|
receiveThreads: (threadsOrIds) =>
|
||||||
|
@ -309,30 +311,23 @@ class CategoryMailboxPerspective extends MailboxPerspective
|
||||||
Actions.queueTasks(tasks)
|
Actions.queueTasks(tasks)
|
||||||
|
|
||||||
removeThreads: (threadsOrIds) =>
|
removeThreads: (threadsOrIds) =>
|
||||||
|
unless threadsOrIds instanceof Array
|
||||||
|
throw new Error("removeThreads: you must pass an array of threads or thread ids")
|
||||||
|
|
||||||
DatabaseStore.modelify(Thread, threadsOrIds).then (threads) =>
|
DatabaseStore.modelify(Thread, threadsOrIds).then (threads) =>
|
||||||
isTrash = not @canTrashThreads()
|
isFinishedCategory = _.any @_categories, (cat) ->
|
||||||
isNotArchiveOrSent = @canArchiveThreads()
|
cat.name in ['trash', 'archive', 'all']
|
||||||
|
|
||||||
tasks = null
|
if isFinishedCategory
|
||||||
categories = @categories()
|
Actions.queueTasks(TaskFactory.tasksForMovingToInbox({
|
||||||
|
threads: threads,
|
||||||
if @isInbox()
|
fromPerspective: @
|
||||||
tasks = TaskFactory.tasksForRemovingCategories({
|
}))
|
||||||
threads,
|
|
||||||
categories,
|
|
||||||
moveToFinishedCategory: true
|
|
||||||
})
|
|
||||||
else if isTrash
|
|
||||||
tasks = TaskFactory.tasksForMovingToInbox({threads, fromPerspective: @})
|
|
||||||
else if isNotArchiveOrSent
|
|
||||||
tasks = TaskFactory.tasksForRemovingCategories({
|
|
||||||
threads,
|
|
||||||
categories,
|
|
||||||
moveToFinishedCategory: false
|
|
||||||
})
|
|
||||||
else
|
else
|
||||||
return
|
Actions.queueTasks(TaskFactory.tasksForRemovingCategories({
|
||||||
|
threads: threads,
|
||||||
Actions.queueTasks(tasks)
|
categories: @categories(),
|
||||||
|
moveToFinishedCategory: @isInbox()
|
||||||
|
}))
|
||||||
|
|
||||||
module.exports = MailboxPerspective
|
module.exports = MailboxPerspective
|
||||||
|
|
|
@ -47,6 +47,12 @@ class WindowEventHandler
|
||||||
activeElement = workspaceElement
|
activeElement = workspaceElement
|
||||||
NylasEnv.commands.dispatch(activeElement, command, args[0])
|
NylasEnv.commands.dispatch(activeElement, command, args[0])
|
||||||
|
|
||||||
|
@subscribe ipcRenderer, 'scroll-touch-begin', ->
|
||||||
|
window.dispatchEvent(new Event('scroll-touch-begin'))
|
||||||
|
|
||||||
|
@subscribe ipcRenderer, 'scroll-touch-end', ->
|
||||||
|
window.dispatchEvent(new Event('scroll-touch-end'))
|
||||||
|
|
||||||
@subscribe $(window), 'beforeunload', =>
|
@subscribe $(window), 'beforeunload', =>
|
||||||
# Don't hide the window here if we don't want the renderer process to be
|
# Don't hide the window here if we don't want the renderer process to be
|
||||||
# throttled in case more work needs to be done before closing
|
# throttled in case more work needs to be done before closing
|
||||||
|
|
|
@ -78,6 +78,10 @@
|
||||||
|
|
||||||
|
|
||||||
.list-container {
|
.list-container {
|
||||||
|
.list-rows > div {
|
||||||
|
// Note: This allows rows to be animated in and out!
|
||||||
|
transition: top ease-out 120ms;
|
||||||
|
}
|
||||||
.list-item {
|
.list-item {
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
color: @text-color;
|
color: @text-color;
|
||||||
|
|
|
@ -51,6 +51,7 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
z-index: 1; // Important so that content does not repaint with container
|
z-index: 1; // Important so that content does not repaint with container
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
BIN
static/images/swipe/icon-swipe-archive@2x.png
Normal file
BIN
static/images/swipe/icon-swipe-archive@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
static/images/swipe/icon-swipe-snooze@2x.png
Normal file
BIN
static/images/swipe/icon-swipe-snooze@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
BIN
static/images/swipe/icon-swipe-trash@2x.png
Normal file
BIN
static/images/swipe/icon-swipe-trash@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
Loading…
Add table
Reference in a new issue