mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-21 07:46:06 +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
fb4e806afe
commit
592374c0dc
|
@ -15,7 +15,7 @@ class ThreadTrashButton extends React.Component
|
|||
|
||||
render: =>
|
||||
focusedMailboxPerspective = FocusedPerspectiveStore.current()
|
||||
return false unless focusedMailboxPerspective?.canTrashThreads()
|
||||
return false unless focusedMailboxPerspective.canTrashThreads()
|
||||
|
||||
<button className="btn btn-toolbar"
|
||||
style={order: -106}
|
||||
|
|
|
@ -18,7 +18,7 @@ class ThreadBulkArchiveButton extends React.Component
|
|||
|
||||
render: ->
|
||||
mailboxPerspective = FocusedPerspectiveStore.current()
|
||||
return false unless mailboxPerspective?.canArchiveThreads()
|
||||
return false unless mailboxPerspective.canArchiveThreads()
|
||||
|
||||
<button style={order:-107}
|
||||
className="btn btn-toolbar"
|
||||
|
@ -43,7 +43,7 @@ class ThreadBulkTrashButton extends React.Component
|
|||
|
||||
render: ->
|
||||
mailboxPerspective = FocusedPerspectiveStore.current()
|
||||
return false unless mailboxPerspective?.canTrashThreads()
|
||||
return false unless mailboxPerspective.canTrashThreads()
|
||||
|
||||
<button style={order:-106}
|
||||
className="btn btn-toolbar"
|
||||
|
|
|
@ -13,7 +13,7 @@ class ThreadArchiveQuickAction extends React.Component
|
|||
mailboxPerspective = FocusedPerspectiveStore.current()
|
||||
archive = null
|
||||
|
||||
if mailboxPerspective?.canArchiveThreads()
|
||||
if mailboxPerspective.canArchiveThreads()
|
||||
archive = <div key="archive"
|
||||
title="Archive"
|
||||
style={{ order: 100 }}
|
||||
|
@ -42,7 +42,7 @@ class ThreadTrashQuickAction extends React.Component
|
|||
mailboxPerspective = FocusedPerspectiveStore.current()
|
||||
trash = null
|
||||
|
||||
if mailboxPerspective?.canTrashThreads()
|
||||
if mailboxPerspective.canTrashThreads()
|
||||
trash = <div key="remove"
|
||||
title="Trash"
|
||||
style={{ order: 110 }}
|
||||
|
|
|
@ -98,8 +98,23 @@ class ThreadList extends React.Component
|
|||
</FluxContainer>
|
||||
|
||||
_threadPropsProvider: (item) ->
|
||||
className: classNames
|
||||
'unread': item.unread
|
||||
props =
|
||||
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) ->
|
||||
itemThreadId = @refs.list.itemIdAtPoint(event.clientX, event.clientY)
|
||||
|
|
|
@ -48,6 +48,80 @@
|
|||
-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 {
|
||||
background-color: darken(@background-primary, 2%);
|
||||
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', ->
|
||||
beforeEach ->
|
||||
spyOn(AccountStore, 'accountForId').andReturn {categoryIcon: -> 'icon'}
|
||||
spyOn(AccountStore, 'accountForId').andReturn(AccountStore.accounts()[0])
|
||||
@accountIds = ['a1', 'a2', 'a3']
|
||||
@perspective = new MailboxPerspective(@accountIds)
|
||||
|
||||
|
@ -51,40 +57,50 @@ describe 'MailboxPerspective', ->
|
|||
|
||||
describe 'removeThreads', ->
|
||||
beforeEach ->
|
||||
@threads = ['t1', 't2']
|
||||
@taskArgs = {threads: @threads, categories: @categories}
|
||||
@threads = [new Thread(id:'t1'), new Thread(id: 't2')]
|
||||
spyOn(Actions, 'queueTasks')
|
||||
spyOn(DatabaseStore, 'modelify').andReturn then: (cb) => cb(@threads)
|
||||
|
||||
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')
|
||||
@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)
|
||||
@taskArgs.moveToFinishedCategory = true
|
||||
expect(TaskFactory.tasksForRemovingCategories).toHaveBeenCalledWith(@taskArgs)
|
||||
expect(TaskFactory.tasksForRemovingCategories).toHaveBeenCalledWith({
|
||||
threads: @threads,
|
||||
moveToFinishedCategory: true,
|
||||
categories: @categories
|
||||
})
|
||||
|
||||
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')
|
||||
@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)
|
||||
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', ->
|
||||
spyOn(@perspective, 'isInbox').andReturn false
|
||||
spyOn(@perspective, 'canTrashThreads').andReturn true
|
||||
spyOn(@perspective, 'canArchiveThreads').andReturn true
|
||||
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)
|
||||
@taskArgs.moveToFinishedCategory = false
|
||||
expect(TaskFactory.tasksForRemovingCategories).toHaveBeenCalledWith(@taskArgs)
|
||||
|
||||
it 'does nothing otherwise', ->
|
||||
spyOn(@perspective, 'isInbox').andReturn false
|
||||
spyOn(@perspective, 'canTrashThreads').andReturn true
|
||||
spyOn(@perspective, 'canArchiveThreads').andReturn false
|
||||
@perspective.removeThreads(@threads)
|
||||
expect(Actions.queueTasks).not.toHaveBeenCalled()
|
||||
expect(TaskFactory.tasksForRemovingCategories).toHaveBeenCalledWith({
|
||||
threads: @threads,
|
||||
moveToFinishedCategory: false,
|
||||
categories: @categories
|
||||
})
|
||||
|
|
|
@ -162,6 +162,12 @@ class NylasWindow
|
|||
@browserWindow.on 'closed', =>
|
||||
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.webContents.send('browser-window-focus')
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
_ = require 'underscore'
|
||||
React = require 'react/addons'
|
||||
SwipeContainer = require './swipe-container'
|
||||
{Utils} = require 'nylas-exports'
|
||||
|
||||
class ListTabularItem extends React.Component
|
||||
|
@ -32,9 +33,11 @@ class ListTabularItem extends React.Component
|
|||
# We only do it if the item prop has changed.
|
||||
@_columnCache ?= @_columns()
|
||||
|
||||
<div {...props} className={className} onClick={@_onClick} style={position:'absolute', top: @props.metrics.top, width:'100%', height:@props.metrics.height}>
|
||||
{@_columnCache}
|
||||
</div>
|
||||
<SwipeContainer {...props} onClick={@_onClick} style={position:'absolute', top: @props.metrics.top, width:'100%', height:@props.metrics.height}>
|
||||
<div className={className} style={height:@props.metrics.height}>
|
||||
{@_columnCache}
|
||||
</div>
|
||||
</SwipeContainer>
|
||||
|
||||
_columns: =>
|
||||
names = {}
|
||||
|
|
|
@ -35,6 +35,7 @@ class ListTabular extends React.Component
|
|||
|
||||
componentWillUnmount: =>
|
||||
window.removeEventListener('resize', @onWindowResize, true)
|
||||
window.clearTimeout(@_cleanupAnimationTimeout) if @_cleanupAnimationTimeout
|
||||
@_unlisten?()
|
||||
|
||||
componentWillReceiveProps: (nextProps) =>
|
||||
|
@ -53,15 +54,39 @@ class ListTabular extends React.Component
|
|||
dataSource ?= @props.dataSource
|
||||
|
||||
items = {}
|
||||
animatingOut = {}
|
||||
|
||||
[start..end].forEach (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
|
||||
renderedRangeEnd: end
|
||||
count: dataSource.count()
|
||||
loaded: dataSource.loaded()
|
||||
empty: dataSource.empty()
|
||||
items: items
|
||||
animatingOut: animatingOut
|
||||
|
||||
componentDidUpdate: (prevProps, prevState) =>
|
||||
# If our view has been swapped out for an entirely different one,
|
||||
|
@ -73,6 +98,23 @@ class ListTabular extends React.Component
|
|||
@updateRangeState()
|
||||
@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 ?= _.debounce(@updateRangeState, 50)
|
||||
@_onWindowResize()
|
||||
|
@ -132,19 +174,29 @@ class ListTabular extends React.Component
|
|||
</div>
|
||||
|
||||
_rows: =>
|
||||
[@state.renderedRangeStart..@state.renderedRangeEnd-1].map (idx) =>
|
||||
item = @state.items[idx]
|
||||
return false unless item
|
||||
# The ordering of the results array is important. We want current rows to
|
||||
# slide over rows which are animating out, so we need to render them last.
|
||||
results = []
|
||||
for idx, record of @state.animatingOut
|
||||
results.push @_rowForItem(record.item, idx / 1)
|
||||
|
||||
<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} />
|
||||
[@state.renderedRangeStart..@state.renderedRangeEnd].forEach (idx) =>
|
||||
if @state.items[idx]
|
||||
results.push @_rowForItem(@state.items[idx], idx)
|
||||
|
||||
results
|
||||
|
||||
_rowForItem: (item, idx) =>
|
||||
return false unless item
|
||||
<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.
|
||||
#
|
||||
|
|
|
@ -209,7 +209,7 @@ class MultiselectList extends React.Component
|
|||
# Public Methods
|
||||
|
||||
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 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 "Flexbox", 'flexbox'
|
||||
@load "RetinaImg", 'retina-img'
|
||||
@load "SwipeContainer", 'swipe-container'
|
||||
@load "FluxContainer", 'flux-container'
|
||||
@load "ListTabular", 'list-tabular'
|
||||
@load "DraggableImg", 'draggable-img'
|
||||
|
|
|
@ -210,6 +210,8 @@ class StarredMailboxPerspective extends MailboxPerspective
|
|||
Actions.queueTask(task)
|
||||
|
||||
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)
|
||||
Actions.queueTask(task)
|
||||
|
||||
|
@ -292,7 +294,7 @@ class CategoryMailboxPerspective extends MailboxPerspective
|
|||
|
||||
canTrashThreads: =>
|
||||
for cat in @_categories
|
||||
return false if cat.name in ["trash"]
|
||||
return false if cat.name in ["trash", "sent"]
|
||||
super
|
||||
|
||||
receiveThreads: (threadsOrIds) =>
|
||||
|
@ -309,30 +311,23 @@ class CategoryMailboxPerspective extends MailboxPerspective
|
|||
Actions.queueTasks(tasks)
|
||||
|
||||
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) =>
|
||||
isTrash = not @canTrashThreads()
|
||||
isNotArchiveOrSent = @canArchiveThreads()
|
||||
isFinishedCategory = _.any @_categories, (cat) ->
|
||||
cat.name in ['trash', 'archive', 'all']
|
||||
|
||||
tasks = null
|
||||
categories = @categories()
|
||||
|
||||
if @isInbox()
|
||||
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
|
||||
})
|
||||
if isFinishedCategory
|
||||
Actions.queueTasks(TaskFactory.tasksForMovingToInbox({
|
||||
threads: threads,
|
||||
fromPerspective: @
|
||||
}))
|
||||
else
|
||||
return
|
||||
|
||||
Actions.queueTasks(tasks)
|
||||
Actions.queueTasks(TaskFactory.tasksForRemovingCategories({
|
||||
threads: threads,
|
||||
categories: @categories(),
|
||||
moveToFinishedCategory: @isInbox()
|
||||
}))
|
||||
|
||||
module.exports = MailboxPerspective
|
||||
|
|
|
@ -47,6 +47,12 @@ class WindowEventHandler
|
|||
activeElement = workspaceElement
|
||||
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', =>
|
||||
# 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
|
||||
|
|
|
@ -78,6 +78,10 @@
|
|||
|
||||
|
||||
.list-container {
|
||||
.list-rows > div {
|
||||
// Note: This allows rows to be animated in and out!
|
||||
transition: top ease-out 120ms;
|
||||
}
|
||||
.list-item {
|
||||
font-size: @font-size-base;
|
||||
color: @text-color;
|
||||
|
|
|
@ -51,6 +51,7 @@
|
|||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
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…
Reference in a new issue