fix(thread-list): Narrow mode, and new selection rules for three-pane

Summary:
Fix bug in apm_wrapper

Refactor model selection so that it's easier to understand the logic for split vs list mode

Test Plan: Run new specs (WIP)

Reviewers: evan

Reviewed By: evan

Differential Revision: https://phab.nylas.com/D1619
This commit is contained in:
Ben Gotow 2015-06-11 18:00:40 -07:00
parent d7f12873b3
commit aa10ddfd1c
20 changed files with 590 additions and 154 deletions

View file

@ -25,7 +25,7 @@ class APMWrapper
args.push('--no-color')
new BufferedProcess({command, args, stdout, stderr, exit, options})
runCommandReturningPackages: (args, callback) ->
runCommandReturningPackages: (args, errorMessage, callback) ->
@runCommand args, (code, stdout, stderr) ->
if code is 0
try
@ -43,7 +43,7 @@ class APMWrapper
loadInstalled: (callback) ->
args = ['ls', '--json']
errorMessage = 'Fetching local packages failed.'
apmProcess = @runCommandReturningPackages(args, callback)
apmProcess = @runCommandReturningPackages(args, errorMessage, callback)
handleProcessErrors(apmProcess, errorMessage, callback)
@ -54,7 +54,7 @@ class APMWrapper
args.push('--compatible', version) if semver.valid(version)
errorMessage = 'Fetching featured packages failed.'
apmProcess = @runCommandReturningPackages(args, callback)
apmProcess = @runCommandReturningPackages(args, errorMessage, callback)
handleProcessErrors(apmProcess, errorMessage, callback)
loadOutdated: (callback) ->
@ -63,21 +63,21 @@ class APMWrapper
args.push('--compatible', version) if semver.valid(version)
errorMessage = 'Fetching outdated packages and themes failed.'
apmProcess = @runCommandReturningPackages(args, callback)
apmProcess = @runCommandReturningPackages(args, errorMessage, callback)
handleProcessErrors(apmProcess, errorMessage, callback)
loadPackage: (packageName, callback) ->
args = ['view', packageName, '--json']
errorMessage = "Fetching package '#{packageName}' failed."
apmProcess = @runCommandReturningPackages(args, callback)
apmProcess = @runCommandReturningPackages(args, errorMessage, callback)
handleProcessErrors(apmProcess, errorMessage, callback)
loadCompatiblePackageVersion: (packageName, callback) ->
args = ['view', packageName, '--json', '--compatible', @normalizeVersion(atom.getVersion())]
errorMessage = "Fetching package '#{packageName}' failed."
apmProcess = @runCommandReturningPackages(args, callback)
apmProcess = @runCommandReturningPackages(args, errorMessage, callback)
handleProcessErrors(apmProcess, errorMessage, callback)
getInstalled: ->

View file

@ -59,6 +59,7 @@ class DraftList extends React.Component
commands={@commands}
onDoubleClick={@_onDoubleClick}
itemPropsProvider={ -> {} }
itemHeight={39}
className="draft-list"
collection="draft" />

View file

@ -73,7 +73,7 @@ ThreadListStore = Reflux.createStore
@setView new DatabaseView Thread, {matchers}, (item) ->
DatabaseStore.findAll(Message, {threadId: item.id})
Actions.focusInCollection(collection: 'thread', item: null)
Actions.setFocus(collection: 'thread', item: null)
# Inbound Events
@ -128,9 +128,9 @@ ThreadListStore = Reflux.createStore
task = new AddRemoveTagsTask(thread, ['archive'], ['inbox'])
Actions.queueTask(task)
if thread.id is focusedId
Actions.focusInCollection(collection: 'thread', item: null)
Actions.setFocus(collection: 'thread', item: null)
if thread.id is keyboardId
Actions.focusKeyboardInCollection(collection: 'thread', item: null)
Actions.setCursorPosition(collection: 'thread', item: null)
@_view.selection.clear()
@ -175,10 +175,13 @@ ThreadListStore = Reflux.createStore
nextFocus = null
@_afterViewUpdate.push ->
Actions.focusInCollection(collection: 'thread', item: nextFocus)
Actions.focusKeyboardInCollection(collection: 'thread', item: nextKeyboard)
Actions.setFocus(collection: 'thread', item: nextFocus)
Actions.setCursorPosition(collection: 'thread', item: nextKeyboard)
_autofocusForLayoutMode: ->
focusedId = FocusedContentStore.focusedId('thread')
if WorkspaceStore.layoutMode() is "split" and not focusedId
_.defer => Actions.focusInCollection(collection: 'thread', item: @_view.get(0))
layoutMode = WorkspaceStore.layoutMode()
focused = FocusedContentStore.focused('thread')
if layoutMode is 'split' and not focused and @_view.selection.count() is 0
item = @_view.get(0)
_.defer =>
Actions.setFocus({collection: 'thread', item: item})

View file

@ -44,6 +44,10 @@ class ThreadList extends React.Component
@containerRequired: false
constructor: (@props) ->
@state =
style: 'unknown'
componentWillMount: =>
labelComponents = (thread) =>
for label in @state.threadLabelComponents
@ -88,7 +92,29 @@ class ThreadList extends React.Component
resolver: (thread) =>
<span className="timestamp">{timestamp(thread.lastMessageTimestamp)}</span>
@columns = [c1, c2, c3, c4]
@wideColumns = [c1, c2, c3, c4]
cNarrow = new ListTabular.Column
name: "Item"
resolver: (thread) =>
pencil = []
hasDraft = _.find (thread.metadata ? []), (m) -> m.draft
if hasDraft
pencil = <RetinaImg name="icon-draft-pencil.png" className="draft-icon" mode={RetinaImg.Mode.ContentPreserve} />
<div>
<div style={display: 'flex'}>
<ThreadListIcon thread={thread} />
<ThreadListParticipants thread={thread} />
<span className="timestamp">{timestamp(thread.lastMessageTimestamp)}</span>
{pencil}
</div>
<div className="subject">{subject(thread.subject)}</div>
<div className="snippet">{thread.snippet}</div>
</div>
@narrowColumns = [cNarrow]
@commands =
'core:remove-item': @_onArchive
'core:remove-and-previous': -> Actions.archiveAndPrevious()
@ -100,15 +126,42 @@ class ThreadList extends React.Component
className: classNames
'unread': item.isUnread()
componentDidMount: =>
window.addEventListener('resize', @_onResize, true)
@_onResize()
componentWillUnmount: =>
window.removeEventListener('resize', @_onResize)
render: =>
<MultiselectList
dataStore={ThreadListStore}
columns={@columns}
commands={@commands}
itemPropsProvider={@itemPropsProvider}
className="thread-list"
scrollTooltipComponent={ThreadListScrollTooltip}
collection="thread" />
if @state.style is 'wide'
<MultiselectList
dataStore={ThreadListStore}
columns={@wideColumns}
commands={@commands}
itemPropsProvider={@itemPropsProvider}
itemHeight={39}
className="thread-list"
scrollTooltipComponent={ThreadListScrollTooltip}
collection="thread" />
else if @state.style is 'narrow'
<MultiselectList
dataStore={ThreadListStore}
columns={@narrowColumns}
commands={@commands}
itemPropsProvider={@itemPropsProvider}
itemHeight={90}
className="thread-list thread-list-narrow"
scrollTooltipComponent={ThreadListScrollTooltip}
collection="thread" />
else
<div></div>
_onResize: (event) =>
current = @state.style
desired = if React.findDOMNode(@).offsetWidth < 540 then 'narrow' else 'wide'
if current isnt desired
@setState(style: desired)
# Additional Commands

View file

@ -180,3 +180,33 @@
background-image:url(../static/images/thread-list/icon-star-hover-@2x.png);
background-size: 16px;
}
.thread-list-narrow {
.timestamp {
flex: 1;
order: 100;
}
.participants {
font-size: @font-size-base;
}
.thread-icon {
margin-right:6px;
}
.subject {
font-size: @font-size-base;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
padding-top:2px;
padding-bottom:2px;
margin-left:30px;
}
.snippet {
font-size: @font-size-small;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.7;
text-align: left;
margin-left:30px;
}
}

View file

@ -58,7 +58,7 @@ module.exports =
notif.onclick = ->
atom.displayWindow()
Actions.focusTag(new Tag(name: "inbox", id: "inbox"))
Actions.focusInCollection(collection: 'thread', item: threads[msg.threadId])
Actions.setFocus(collection: 'thread', item: threads[msg.threadId])
if newUnreadInInbox.length > 1
new Notification("#{newUnreadInInbox.length} Unread Messages", {

View file

@ -0,0 +1,107 @@
MultiselectListInteractionHandler = require '../../src/components/multiselect-list-interaction-handler'
WorkspaceStore = require '../../src/flux/stores/workspace-store'
FocusedContentStore = require '../../src/flux/stores/focused-content-store'
Thread = require '../../src/flux/models/thread'
Actions = require '../../src/flux/actions'
_ = require 'underscore'
describe "MultiselectListInteractionHandler", ->
beforeEach ->
@item = new Thread(id:'123')
@itemFocus = new Thread({id: 'focus'})
@itemKeyboardFocus = new Thread({id: 'keyboard-focus'})
@itemAfterFocus = new Thread(id:'after-focus')
@itemAfterKeyboardFocus = new Thread(id:'after-keyboard-focus')
data = [@item, @itemFocus, @itemAfterFocus, @itemKeyboardFocus, @itemAfterKeyboardFocus]
@dataView =
selection:
toggle: jasmine.createSpy('toggle')
expandTo: jasmine.createSpy('expandTo')
walk: jasmine.createSpy('walk')
get: (idx) ->
data[idx]
getById: (id) ->
_.find data, (item) -> item.id is id
indexOfId: (id) ->
_.findIndex data, (item) -> item.id is id
count: -> data.length
@collection = 'threads'
@handler = new MultiselectListInteractionHandler(@dataView, @collection)
@isRootSheet = true
spyOn(WorkspaceStore, 'topSheet').andCallFake => {root: @isRootSheet}
spyOn(FocusedContentStore, 'keyboardCursorId').andCallFake -> 'keyboard-focus'
spyOn(FocusedContentStore, 'focusedId').andCallFake -> 'focus'
spyOn(Actions, 'setFocus')
spyOn(Actions, 'setCursorPosition')
it "should never show focus", ->
expect(@handler.shouldShowFocus()).toEqual(false)
it "should always show the keyboard cursor", ->
expect(@handler.shouldShowKeyboardCursor()).toEqual(true)
describe "onClick", ->
it "should focus list items", ->
@handler.onClick(@item)
expect(Actions.setFocus).toHaveBeenCalledWith({collection: @collection, item: @item})
describe "onMetaClick", ->
it "shoud toggle selection", ->
@handler.onMetaClick(@item)
expect(@dataView.selection.toggle).toHaveBeenCalledWith(@item)
it "should focus the keyboard on the clicked item", ->
@handler.onMetaClick(@item)
expect(Actions.setCursorPosition).toHaveBeenCalledWith({collection: @collection, item: @item})
describe "onShiftClick", ->
it "should expand selection", ->
@handler.onShiftClick(@item)
expect(@dataView.selection.expandTo).toHaveBeenCalledWith(@item)
it "should focus the keyboard on the clicked item", ->
@handler.onShiftClick(@item)
expect(Actions.setCursorPosition).toHaveBeenCalledWith({collection: @collection, item: @item})
describe "onEnter", ->
it "should focus the item with the current keyboard selection", ->
@handler.onEnter()
expect(Actions.setFocus).toHaveBeenCalledWith({collection: @collection, item: @itemKeyboardFocus})
describe "onSelect (x key on keyboard)", ->
describe "on the root view", ->
it "should toggle the selection of the keyboard item", ->
@isRootSheet = true
@handler.onSelect()
expect(@dataView.selection.toggle).toHaveBeenCalledWith(@itemKeyboardFocus)
describe "on the thread view", ->
it "should toggle the selection of the focused item", ->
@isRootSheet = false
@handler.onSelect()
expect(@dataView.selection.toggle).toHaveBeenCalledWith(@itemFocus)
describe "onShift", ->
describe "on the root view", ->
beforeEach ->
@isRootSheet = true
it "should shift the keyboard item", ->
@handler.onShift(1, {})
expect(Actions.setCursorPosition).toHaveBeenCalledWith({collection: @collection, item: @itemAfterKeyboardFocus})
it "should walk selection if the select option is passed", ->
@handler.onShift(1, select: true)
expect(@dataView.selection.walk).toHaveBeenCalledWith({current: @itemKeyboardFocus, next: @itemAfterKeyboardFocus})
describe "on the thread view", ->
beforeEach ->
@isRootSheet = false
it "should shift the focused item", ->
@handler.onShift(1, {})
expect(Actions.setFocus).toHaveBeenCalledWith({collection: @collection, item: @itemAfterFocus})

View file

@ -0,0 +1,160 @@
MultiselectSplitInteractionHandler = require '../../src/components/multiselect-split-interaction-handler'
WorkspaceStore = require '../../src/flux/stores/workspace-store'
FocusedContentStore = require '../../src/flux/stores/focused-content-store'
Thread = require '../../src/flux/models/thread'
Actions = require '../../src/flux/actions'
_ = require 'underscore'
describe "MultiselectSplitInteractionHandler", ->
beforeEach ->
@item = new Thread(id:'123')
@itemFocus = new Thread({id: 'focus'})
@itemKeyboardFocus = new Thread({id: 'keyboard-focus'})
@itemAfterFocus = new Thread(id:'after-focus')
@itemAfterKeyboardFocus = new Thread(id:'after-keyboard-focus')
data = [@item, @itemFocus, @itemAfterFocus, @itemKeyboardFocus, @itemAfterKeyboardFocus]
@selection = []
@dataView =
selection:
toggle: jasmine.createSpy('toggle')
expandTo: jasmine.createSpy('expandTo')
add: jasmine.createSpy('add')
walk: jasmine.createSpy('walk')
clear: jasmine.createSpy('clear')
count: => @selection.length
items: => @selection
top: => @selection[-1]
get: (idx) ->
data[idx]
getById: (id) ->
_.find data, (item) -> item.id is id
indexOfId: (id) ->
_.findIndex data, (item) -> item.id is id
count: -> data.length
@collection = 'threads'
@handler = new MultiselectSplitInteractionHandler(@dataView, @collection)
@isRootSheet = true
spyOn(WorkspaceStore, 'topSheet').andCallFake => {root: @isRootSheet}
spyOn(Actions, 'setFocus')
spyOn(Actions, 'setCursorPosition')
it "should always show focus", ->
expect(@handler.shouldShowFocus()).toEqual(true)
it "should show the keyboard cursor when multiple items are selected", ->
@selection = []
expect(@handler.shouldShowKeyboardCursor()).toEqual(false)
@selection = [@item]
expect(@handler.shouldShowKeyboardCursor()).toEqual(false)
@selection = [@item, @itemFocus]
expect(@handler.shouldShowKeyboardCursor()).toEqual(true)
describe "onClick", ->
it "should focus list items", ->
@handler.onClick(@item)
expect(Actions.setFocus).toHaveBeenCalledWith({collection: @collection, item: @item})
describe "onMetaClick", ->
describe "when there is currently a focused item", ->
beforeEach ->
spyOn(FocusedContentStore, 'focused').andCallFake => @itemFocus
spyOn(FocusedContentStore, 'focusedId').andCallFake -> 'focus'
it "should turn the focused item into the first selected item", ->
@handler.onMetaClick(@item)
expect(@dataView.selection.add).toHaveBeenCalledWith(@itemFocus)
it "should clear the focus", ->
@handler.onMetaClick(@item)
expect(Actions.setFocus).toHaveBeenCalledWith({collection: @collection, item: null})
it "should toggle selection", ->
@handler.onMetaClick(@item)
expect(@dataView.selection.toggle).toHaveBeenCalledWith(@item)
it "should call _checkSelectionAndFocusConsistency", ->
spyOn(@handler, '_checkSelectionAndFocusConsistency')
@handler.onMetaClick(@item)
expect(@handler._checkSelectionAndFocusConsistency).toHaveBeenCalled()
describe "onShiftClick", ->
describe "when there is currently a focused item", ->
beforeEach ->
spyOn(FocusedContentStore, 'focused').andCallFake => @itemFocus
spyOn(FocusedContentStore, 'focusedId').andCallFake -> 'focus'
it "should turn the focused item into the first selected item", ->
@handler.onMetaClick(@item)
expect(@dataView.selection.add).toHaveBeenCalledWith(@itemFocus)
it "should clear the focus", ->
@handler.onMetaClick(@item)
expect(Actions.setFocus).toHaveBeenCalledWith({collection: @collection, item: null})
it "should expand selection", ->
@handler.onShiftClick(@item)
expect(@dataView.selection.expandTo).toHaveBeenCalledWith(@item)
it "should call _checkSelectionAndFocusConsistency", ->
spyOn(@handler, '_checkSelectionAndFocusConsistency')
@handler.onMetaClick(@item)
expect(@handler._checkSelectionAndFocusConsistency).toHaveBeenCalled()
describe "onEnter", ->
describe "onSelect (x key on keyboard)", ->
it "should call _checkSelectionAndFocusConsistency", ->
spyOn(@handler, '_checkSelectionAndFocusConsistency')
@handler.onMetaClick(@item)
expect(@handler._checkSelectionAndFocusConsistency).toHaveBeenCalled()
describe "onShift", ->
it "should call _checkSelectionAndFocusConsistency", ->
spyOn(@handler, '_checkSelectionAndFocusConsistency')
@handler.onMetaClick(@item)
expect(@handler._checkSelectionAndFocusConsistency).toHaveBeenCalled()
describe "when the select option is passed", ->
it "should turn the existing focused item into a selected item", ->
spyOn(FocusedContentStore, 'focused').andCallFake => @itemFocus
spyOn(FocusedContentStore, 'focusedId').andCallFake -> 'focus'
@handler.onShift(1, {select: true})
expect(@dataView.selection.add).toHaveBeenCalledWith(@itemFocus)
it "should walk the selection to the shift target", ->
spyOn(FocusedContentStore, 'focused').andCallFake => @itemFocus
spyOn(FocusedContentStore, 'focusedId').andCallFake -> 'focus'
@handler.onShift(1, {select: true})
expect(@dataView.selection.walk).toHaveBeenCalledWith({current: @itemFocus, next: @itemAfterFocus})
describe "when one or more items is selected", ->
it "should move the keyboard cursor", ->
@selection = [@itemFocus, @itemAfterFocus, @itemKeyboardFocus]
spyOn(FocusedContentStore, 'keyboardCursor').andCallFake => @itemKeyboardFocus
spyOn(FocusedContentStore, 'keyboardCursorId').andCallFake -> 'keyboard-focus'
@handler.onShift(1, {})
expect(Actions.setCursorPosition).toHaveBeenCalledWith({collection: @collection, item: @itemAfterKeyboardFocus})
describe "when no items are selected", ->
it "should move the focus", ->
spyOn(FocusedContentStore, 'focused').andCallFake => @itemFocus
spyOn(FocusedContentStore, 'focusedId').andCallFake -> 'focus'
@handler.onShift(1, {})
expect(Actions.setFocus).toHaveBeenCalledWith({collection: @collection, item: @itemAfterFocus})
describe "_checkSelectionAndFocusConsistency", ->
describe "when only one item is selected", ->
beforeEach ->
spyOn(FocusedContentStore, 'focused').andCallFake -> null
spyOn(FocusedContentStore, 'focusedId').andCallFake -> null
@selection = [@item]
it "should clear the selection and make the item focused", ->
@handler._checkSelectionAndFocusConsistency()
expect(@dataView.selection.clear).toHaveBeenCalled()
expect(Actions.setFocus).toHaveBeenCalledWith({collection: @collection, item: @item})

View file

@ -7,7 +7,7 @@ Actions = require '../../src/flux/actions'
testThread = new Thread(id: '123')
describe "FocusedContentStore", ->
describe "onFocusInCollection", ->
describe "onSetFocus", ->
it "should not trigger if the thread is already focused", ->
FocusedContentStore._onFocus({collection: 'thread', item: testThread})
spyOn(FocusedContentStore, 'trigger')

View file

@ -1,43 +0,0 @@
_ = require 'underscore'
React = require 'react/addons'
classNames = require 'classnames'
{ComponentRegistry} = require 'nylas-exports'
ThreadListItemMixin = require './thread-list-item-mixin'
DefaultParticipants = React.createClass
render: ->
<div className="participants">
{_.pluck(@props.participants, "email").join ", "}
</div>
module.exports =
ThreadListNarrowItem = React.createClass
mixins: [ComponentRegistry.Mixin, ThreadListItemMixin]
displayName: 'ThreadListNarrowItem'
components: ["Participants"]
render: ->
Participants = @state.Participants ? DefaultParticipants
<div className={@_containerClasses()} onClick={@_onClick} id={@props.thread.id}>
<div className="thread-title">
<span className="btn-icon star-button pull-right"
onClick={@_toggleStar}
><i className={"fa " + (@_isStarred() and 'fa-star' or 'fa-star-o')}/></span>
<div className="message-time">
{@threadTime()}
</div>
<Participants participants={@props.thread.participants} clickable={false}/>
</div>
<div className="preview-body">
<span className="subject">{@_subject()}</span>
<span className="snippet">{@_snippet()}</span>
</div>
</div>
_containerClasses: ->
classNames
'unread': @props.unread
'selected': @props.selected
'thread-list-item': true
'thread-list-narrow-item': true

View file

@ -46,7 +46,7 @@ class ListTabularItem extends React.Component
<div key={column.name}
displayName={column.name}
style={_.pick(column, ['flex', 'width'])}
className="list-column">
className="list-column list-column-#{column.name}">
{column.resolver(@props.item, @)}
</div>
@ -66,11 +66,15 @@ class ListTabular extends React.Component
columns: React.PropTypes.arrayOf(React.PropTypes.object).isRequired
dataView: React.PropTypes.object
itemPropsProvider: React.PropTypes.func
itemHeight: React.PropTypes.number
onSelect: React.PropTypes.func
onClick: React.PropTypes.func
onDoubleClick: React.PropTypes.func
constructor: (@props) ->
if not @props.itemHeight
throw new Error("ListTabular: You must provide an itemHeight - raising to avoid divide by zero errors.")
@state =
renderedRangeStart: -1
renderedRangeEnd: -1
@ -103,7 +107,7 @@ class ListTabular extends React.Component
# If we've shifted enough pixels from our previous scrollTop to require
# new rows to be rendered, update our state!
if Math.abs(@state.scrollTop - @refs.container.scrollTop) >= @_rowHeight() * RangeChunkSize
if Math.abs(@state.scrollTop - @refs.container.scrollTop) >= @props.itemHeight * RangeChunkSize
@updateRangeState()
onDoneReceivingScrollEvents: =>
@ -114,11 +118,9 @@ class ListTabular extends React.Component
updateRangeState: =>
scrollTop = @refs.container.scrollTop
rowHeight = @_rowHeight()
# Determine the exact range of rows we want onscreen
rangeStart = Math.floor(scrollTop / rowHeight)
rangeEnd = rangeStart + window.innerHeight / rowHeight
rangeStart = Math.floor(scrollTop / @props.itemHeight)
rangeEnd = rangeStart + window.innerHeight / @props.itemHeight
# 1. Clip this range to the number of available items
#
@ -154,7 +156,7 @@ class ListTabular extends React.Component
render: =>
innerStyles =
height: @props.dataView.count() * @_rowHeight()
height: @props.dataView.count() * @props.itemHeight
pointerEvents: if @state.scrollInProgress then 'none' else 'auto'
<ScrollRegion ref="container" onScroll={@updateScrollState} tabIndex="-1" className="list-container list-tabular" scrollTooltipComponent={@props.scrollTooltipComponent} >
@ -164,9 +166,6 @@ class ListTabular extends React.Component
</div>
</ScrollRegion>
_rowHeight: =>
39
_headers: =>
return [] unless @props.displayHeaders
@ -182,7 +181,6 @@ class ListTabular extends React.Component
</div>
_rows: =>
rowHeight = @_rowHeight()
rows = []
for idx in [@state.renderedRangeStart..@state.renderedRangeEnd-1]
@ -196,7 +194,7 @@ class ListTabular extends React.Component
rows.push <ListTabularItem key={item.id ? idx}
item={item}
itemProps={itemProps}
metrics={top: idx * rowHeight, height: rowHeight}
metrics={top: idx * @props.itemHeight, height: @props.itemHeight}
columns={@props.columns}
onSelect={@props.onSelect}
onClick={@props.onClick}

View file

@ -0,0 +1,54 @@
_ = require 'underscore'
{Actions,
WorkspaceStore,
FocusedContentStore} = require 'nylas-exports'
module.exports =
class MultiselectListInteractionHandler
constructor: (@dataView, @collection) ->
shouldShowFocus: ->
false
shouldShowKeyboardCursor: ->
true
onClick: (item) ->
Actions.setFocus({collection: @collection, item: item})
onMetaClick: (item) ->
@dataView.selection.toggle(item)
Actions.setCursorPosition({collection: @collection, item: item})
onShiftClick: (item) ->
@dataView.selection.expandTo(item)
Actions.setCursorPosition({collection: @collection, item: item})
onEnter: ->
keyboardCursorId = FocusedContentStore.keyboardCursorId(@collection)
if keyboardCursorId
item = @dataView.getById(keyboardCursorId)
Actions.setFocus({collection: @collection, item: item})
onSelect: ->
{id} = @_keyboardContext()
return unless id
@dataView.selection.toggle(@dataView.getById(id))
onShift: (delta, options = {}) ->
{id, action} = @_keyboardContext()
current = @dataView.getById(id)
index = @dataView.indexOfId(id)
index = Math.max(0, Math.min(index + delta, @dataView.count() - 1))
next = @dataView.get(index)
action({collection: @collection, item: next})
if options.select
@dataView.selection.walk({current, next})
_keyboardContext: ->
if WorkspaceStore.topSheet().root
{id: FocusedContentStore.keyboardCursorId(@collection), action: Actions.setCursorPosition}
else
{id: FocusedContentStore.focusedId(@collection), action: Actions.setFocus}

View file

@ -11,6 +11,9 @@ Spinner = require './spinner'
NamespaceStore} = require 'nylas-exports'
EventEmitter = require('events').EventEmitter
MultiselectListInteractionHandler = require './multiselect-list-interaction-handler'
MultiselectSplitInteractionHandler = require './multiselect-split-interaction-handler'
###
Public: MultiselectList wraps {ListTabular} and makes it easy to present a
{ModelView} with selection support. It adds a checkbox column to the columns
@ -32,6 +35,7 @@ class MultiselectList extends React.Component
columns: React.PropTypes.array.isRequired
dataStore: React.PropTypes.object.isRequired
itemPropsProvider: React.PropTypes.func.isRequired
itemHeight: React.PropTypes.number.isRequired
scrollTooltipComponent: React.PropTypes.func
constructor: (@props) ->
@ -77,21 +81,9 @@ class MultiselectList extends React.Component
context = {focusedId: @state.focusedId}
props.commands[key](context)
unless props.columns[0].name is 'Check'
checkmarkColumn = new ListTabular.Column
name: "Check"
resolver: (thread) =>
toggle = (event) =>
if event.shiftKey
props.dataStore.view().selection.expandTo(thread)
else
props.dataStore.view().selection.toggle(thread)
event.stopPropagation()
<div className="checkmark" onClick={toggle}><div className="inner"></div></div>
props.columns.splice(0, 0, checkmarkColumn)
@unsubscribers = []
@unsubscribers.push props.dataStore.listen @_onChange
@unsubscribers.push WorkspaceStore.listen @_onChange
@unsubscribers.push FocusedContentStore.listen @_onChange
@command_unsubscriber = atom.commands.add('body', commands)
@ -111,18 +103,19 @@ class MultiselectList extends React.Component
props.className ?= ''
props.className += " " + classNames
'selected': item.id in @state.selectedIds
'focused': @state.showFocus and item.id is @state.focusedId
'keyboard-cursor': @state.showKeyboardCursor and item.id is @state.keyboardCursorId
'focused': @state.handler.shouldShowFocus() and item.id is @state.focusedId
'keyboard-cursor': @state.handler.shouldShowKeyboardCursor() and item.id is @state.keyboardCursorId
props
if @state.dataView
<div className={className}>
<ListTabular
ref="list"
columns={@props.columns}
columns={@state.columns}
scrollTooltipComponent={@props.scrollTooltipComponent}
dataView={@state.dataView}
itemPropsProvider={@itemPropsProvider}
itemHeight={@props.itemHeight}
onSelect={@_onClickItem}
onDoubleClick={@props.onDoubleClick} />
<Spinner visible={!@state.ready} />
@ -135,58 +128,24 @@ class MultiselectList extends React.Component
_onClickItem: (item, event) =>
if event.metaKey
@state.dataView.selection.toggle(item)
if @state.showKeyboardCursor
Actions.focusKeyboardInCollection({collection: @props.collection, item: item})
@state.handler.onMetaClick(item)
else if event.shiftKey
@state.dataView.selection.expandTo(item)
if @state.showKeyboardCursor
Actions.focusKeyboardInCollection({collection: @props.collection, item: item})
@state.handler.onShiftClick(item)
else
Actions.focusInCollection({collection: @props.collection, item: item})
@state.handler.onClick(item)
_onEnter: =>
return unless @state.showKeyboardCursor
item = @state.dataView.getById(@state.keyboardCursorId)
if item
Actions.focusInCollection({collection: @props.collection, item: item})
@state.handler.onEnter()
_onSelect: =>
if @state.showKeyboardCursor and @_visible()
id = @state.keyboardCursorId
else
id = @state.focusedId
return unless id
@state.dataView.selection.toggle(@state.dataView.getById(id))
@state.handler.onSelect()
_onDeselect: =>
return unless @_visible()
@state.dataView.selection.clear()
_onShift: (delta, options = {}) =>
if @state.showKeyboardCursor and @_visible()
id = @state.keyboardCursorId
action = Actions.focusKeyboardInCollection
else
id = @state.focusedId
action = Actions.focusInCollection
current = @state.dataView.getById(id)
index = @state.dataView.indexOfId(id)
index = Math.max(0, Math.min(index + delta, @state.dataView.count() - 1))
next = @state.dataView.get(index)
action({collection: @props.collection, item: next})
if options.select
@state.dataView.selection.walk({current, next})
_visible: =>
if WorkspaceStore.layoutMode() is "list"
WorkspaceStore.topSheet().root
else
true
@state.handler.onShift(delta, options)
# This onChange handler can be called many times back to back and setState
# sometimes triggers an immediate render. Ensure that we never render back-to-back,
@ -198,19 +157,44 @@ class MultiselectList extends React.Component
, 1
@_onChangeDebounced()
_visible: =>
if WorkspaceStore.layoutMode() is "list"
WorkspaceStore.topSheet().root
else
true
_getCheckmarkColumn: =>
new ListTabular.Column
name: 'Check'
resolver: (item) =>
toggle = (event) =>
if event.shiftKey
@state.handler.onShiftClick(item)
else
@state.handler.onMetaClick(item)
event.stopPropagation()
<div className="checkmark" onClick={toggle}><div className="inner"></div></div>
_getStateFromStores: (props) =>
props ?= @props
view = props.dataStore?.view()
return {} unless view
columns = [].concat(props.columns)
if WorkspaceStore.layoutMode() is 'list'
handler = new MultiselectListInteractionHandler(view, props.collection)
columns.splice(0, 0, @_getCheckmarkColumn())
else
handler = new MultiselectSplitInteractionHandler(view, props.collection)
dataView: view
columns: columns
handler: handler
ready: view.loaded()
selectedIds: view.selection.ids()
focusedId: FocusedContentStore.focusedId(props.collection)
keyboardCursorId: FocusedContentStore.keyboardCursorId(props.collection)
showFocus: !FocusedContentStore.keyboardCursorEnabled()
showKeyboardCursor: FocusedContentStore.keyboardCursorEnabled()
module.exports = MultiselectList

View file

@ -0,0 +1,75 @@
_ = require 'underscore'
{Actions,
WorkspaceStore,
FocusedContentStore} = require 'nylas-exports'
module.exports =
class MultiselectSplitInteractionHandler
constructor: (@dataView, @collection) ->
shouldShowFocus: ->
true
shouldShowKeyboardCursor: ->
@dataView.selection.count() > 1
onClick: (item) ->
Actions.setFocus({collection: @collection, item: item})
@dataView.selection.clear()
@_checkSelectionAndFocusConsistency()
onMetaClick: (item) ->
@_turnFocusIntoSelection()
@dataView.selection.toggle(item)
@_checkSelectionAndFocusConsistency()
onShiftClick: (item) ->
@_turnFocusIntoSelection()
@dataView.selection.expandTo(item)
@_checkSelectionAndFocusConsistency()
onEnter: ->
onSelect: ->
@_checkSelectionAndFocusConsistency()
onShift: (delta, options) ->
if options.select
@_turnFocusIntoSelection()
if @dataView.selection.count() > 0
selection = @dataView.selection
keyboardId = FocusedContentStore.keyboardCursorId(@collection)
id = keyboardId ? @dataView.selection.top().id
action = Actions.setCursorPosition
else
id = FocusedContentStore.focusedId(@collection)
action = Actions.setFocus
current = @dataView.getById(id)
index = @dataView.indexOfId(id)
index = Math.max(0, Math.min(index + delta, @dataView.count() - 1))
next = @dataView.get(index)
action({collection: @collection, item: next})
if options.select
@dataView.selection.walk({current, next})
@_checkSelectionAndFocusConsistency()
_turnFocusIntoSelection: ->
focused = FocusedContentStore.focused(@collection)
Actions.setFocus({collection: @collection, item: null})
@dataView.selection.add(focused)
_checkSelectionAndFocusConsistency: ->
focused = FocusedContentStore.focused(@collection)
selection = @dataView.selection
if focused and selection.count() > 0
@dataView.selection.add(focused)
Actions.setFocus({collection: @collection, item: null})
if selection.count() is 1 and !focused
Actions.setFocus({collection: @collection, item: selection.items()[0]})
@dataView.selection.clear()

View file

@ -185,7 +185,7 @@ class Actions
Actions.selectLayoutMode(collection: 'thread', item: <Thread>)
```
###
@focusKeyboardInCollection: ActionScopeWindow
@setCursorPosition: ActionScopeWindow
###
Public: Focus on an item in a collection. This action changes the selection
@ -194,10 +194,10 @@ class Actions
*Scope: Window*
```
Actions.focusInCollection(collection: 'thread', item: <Thread>)
Actions.setFocus(collection: 'thread', item: <Thread>)
```
###
@focusInCollection: ActionScopeWindow
@setFocus: ActionScopeWindow
###
Public: Focus the interface on a specific {Tag}.
@ -226,7 +226,7 @@ class Actions
###
Public: Create a new reply to the provided threadId and messageId. Note that
this action does not focus on the thread, so you may not be able to see the new draft
unless you also call {::focusInCollection}.
unless you also call {::setFocus}.
*Scope: Window*
@ -435,7 +435,7 @@ class Actions
###
Public: Push a sheet of a specific type onto the Sheet stack maintained by the
{WorkspaceStore}. Note that sheets have no state. To show a *specific* thread,
you should push a Thread sheet and call `focusInCollection` to select the thread.
you should push a Thread sheet and call `setFocus` to select the thread.
*Scope: Window*

View file

@ -18,8 +18,8 @@ selection public so that you can observe focus changes and trigger your own chan
to focus.
Since {FocusedContentStore} is a Flux-compatible Store, you do not call setters
on it directly. Instead, use {Actions::focusInCollection} or
{Actions::focusKeyboardInCollection} to set focus. The FocusedContentStore observes
on it directly. Instead, use {Actions::setFocus} or
{Actions::setCursorPosition} to set focus. The FocusedContentStore observes
these models, changes it's state, and broadcasts to it's observers.
Note: The {FocusedContentStore} triggers when a focused model is changed, even if
@ -62,8 +62,8 @@ class FocusedContentStore
@listenTo NamespaceStore, @_onClear
@listenTo WorkspaceStore, @_onWorkspaceChange
@listenTo DatabaseStore, @_onDataChange
@listenTo Actions.focusInCollection, @_onFocus
@listenTo Actions.focusKeyboardInCollection, @_onFocusKeyboard
@listenTo Actions.setFocus, @_onFocus
@listenTo Actions.setCursorPosition, @_onFocusKeyboard
_resetInstanceVars: =>
@_focused = {}

View file

@ -17,6 +17,9 @@ class ModelViewSelection
items: ->
@_items
top: ->
@_items[@_items.length - 1]
clear: ->
@set([])
@ -49,6 +52,16 @@ class ModelViewSelection
@_items.push(item)
@trigger(@)
add: (item) ->
return unless item
throw new Error("add must be called with a Model") unless item instanceof Model
updated = _.reject @_items, (t) -> t.id is item.id
updated.push(item)
if updated.length isnt @_items.length
@_items = updated
@trigger(@)
remove: (item) ->
return unless item
throw new Error("remove must be called with a Model") unless item instanceof Model

View file

@ -28,7 +28,7 @@ class WorkspaceStore
@listenTo Actions.selectRootSheet, @_onSelectRootSheet
@listenTo Actions.selectLayoutMode, @_onSelectLayoutMode
@listenTo Actions.focusInCollection, @_onFocusInCollection
@listenTo Actions.setFocus, @_onSetFocus
@listenTo Actions.popSheet, @popSheet
@listenTo Actions.searchQueryCommitted, @popToRootSheet
@ -74,7 +74,7 @@ class WorkspaceStore
@_preferredLayoutMode = mode
@trigger(@)
_onFocusInCollection: ({collection, item}) =>
_onSetFocus: ({collection, item}) =>
if collection is 'thread'
if @layoutMode() is 'list'
if item and @topSheet() isnt Sheet.Thread
@ -182,7 +182,7 @@ class WorkspaceStore
@trigger()
if Sheet.Thread and sheet is Sheet.Thread
Actions.focusInCollection(collection: 'thread', item: null)
Actions.setFocus(collection: 'thread', item: null)
# Return to the root sheet. This method triggers, allowing observers
# to update.

View file

@ -36,7 +36,7 @@ class SheetContainer extends React.Component
<Flexbox direction="column">
{@_toolbarContainerElement()}
<div name="Header" style={order:1, zIndex: 2}>
<InjectedComponentSet matching={locations: [topSheet.Header, WorkspaceStore.Sheet.Global.Header]}
direction="column"
@ -89,6 +89,7 @@ class SheetContainer extends React.Component
_onColumnSizeChanged: (sheet) =>
@refs["toolbar-#{sheet.props.depth}"]?.recomputeLayout()
window.dispatchEvent(new Event('resize'))
_onStoreChange: =>
_.defer => @setState(@_getStateFromStores())
@ -97,4 +98,4 @@ class SheetContainer extends React.Component
stack: WorkspaceStore.sheetStack()
module.exports = SheetContainer
module.exports = SheetContainer

View file

@ -113,7 +113,7 @@
}
&.keyboard-cursor {
.checkmark {
.list-column:first-child {
border-left:4px solid @list-focused-bg;
padding-left:8px;
}