mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-10-06 19:26:55 +08:00
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:
parent
48f1cc80d6
commit
d5e1816d88
20 changed files with 590 additions and 154 deletions
|
@ -25,7 +25,7 @@ class APMWrapper
|
||||||
args.push('--no-color')
|
args.push('--no-color')
|
||||||
new BufferedProcess({command, args, stdout, stderr, exit, options})
|
new BufferedProcess({command, args, stdout, stderr, exit, options})
|
||||||
|
|
||||||
runCommandReturningPackages: (args, callback) ->
|
runCommandReturningPackages: (args, errorMessage, callback) ->
|
||||||
@runCommand args, (code, stdout, stderr) ->
|
@runCommand args, (code, stdout, stderr) ->
|
||||||
if code is 0
|
if code is 0
|
||||||
try
|
try
|
||||||
|
@ -43,7 +43,7 @@ class APMWrapper
|
||||||
loadInstalled: (callback) ->
|
loadInstalled: (callback) ->
|
||||||
args = ['ls', '--json']
|
args = ['ls', '--json']
|
||||||
errorMessage = 'Fetching local packages failed.'
|
errorMessage = 'Fetching local packages failed.'
|
||||||
apmProcess = @runCommandReturningPackages(args, callback)
|
apmProcess = @runCommandReturningPackages(args, errorMessage, callback)
|
||||||
|
|
||||||
handleProcessErrors(apmProcess, errorMessage, callback)
|
handleProcessErrors(apmProcess, errorMessage, callback)
|
||||||
|
|
||||||
|
@ -54,7 +54,7 @@ class APMWrapper
|
||||||
args.push('--compatible', version) if semver.valid(version)
|
args.push('--compatible', version) if semver.valid(version)
|
||||||
errorMessage = 'Fetching featured packages failed.'
|
errorMessage = 'Fetching featured packages failed.'
|
||||||
|
|
||||||
apmProcess = @runCommandReturningPackages(args, callback)
|
apmProcess = @runCommandReturningPackages(args, errorMessage, callback)
|
||||||
handleProcessErrors(apmProcess, errorMessage, callback)
|
handleProcessErrors(apmProcess, errorMessage, callback)
|
||||||
|
|
||||||
loadOutdated: (callback) ->
|
loadOutdated: (callback) ->
|
||||||
|
@ -63,21 +63,21 @@ class APMWrapper
|
||||||
args.push('--compatible', version) if semver.valid(version)
|
args.push('--compatible', version) if semver.valid(version)
|
||||||
errorMessage = 'Fetching outdated packages and themes failed.'
|
errorMessage = 'Fetching outdated packages and themes failed.'
|
||||||
|
|
||||||
apmProcess = @runCommandReturningPackages(args, callback)
|
apmProcess = @runCommandReturningPackages(args, errorMessage, callback)
|
||||||
handleProcessErrors(apmProcess, errorMessage, callback)
|
handleProcessErrors(apmProcess, errorMessage, callback)
|
||||||
|
|
||||||
loadPackage: (packageName, callback) ->
|
loadPackage: (packageName, callback) ->
|
||||||
args = ['view', packageName, '--json']
|
args = ['view', packageName, '--json']
|
||||||
errorMessage = "Fetching package '#{packageName}' failed."
|
errorMessage = "Fetching package '#{packageName}' failed."
|
||||||
|
|
||||||
apmProcess = @runCommandReturningPackages(args, callback)
|
apmProcess = @runCommandReturningPackages(args, errorMessage, callback)
|
||||||
handleProcessErrors(apmProcess, errorMessage, callback)
|
handleProcessErrors(apmProcess, errorMessage, callback)
|
||||||
|
|
||||||
loadCompatiblePackageVersion: (packageName, callback) ->
|
loadCompatiblePackageVersion: (packageName, callback) ->
|
||||||
args = ['view', packageName, '--json', '--compatible', @normalizeVersion(atom.getVersion())]
|
args = ['view', packageName, '--json', '--compatible', @normalizeVersion(atom.getVersion())]
|
||||||
errorMessage = "Fetching package '#{packageName}' failed."
|
errorMessage = "Fetching package '#{packageName}' failed."
|
||||||
|
|
||||||
apmProcess = @runCommandReturningPackages(args, callback)
|
apmProcess = @runCommandReturningPackages(args, errorMessage, callback)
|
||||||
handleProcessErrors(apmProcess, errorMessage, callback)
|
handleProcessErrors(apmProcess, errorMessage, callback)
|
||||||
|
|
||||||
getInstalled: ->
|
getInstalled: ->
|
||||||
|
|
|
@ -59,6 +59,7 @@ class DraftList extends React.Component
|
||||||
commands={@commands}
|
commands={@commands}
|
||||||
onDoubleClick={@_onDoubleClick}
|
onDoubleClick={@_onDoubleClick}
|
||||||
itemPropsProvider={ -> {} }
|
itemPropsProvider={ -> {} }
|
||||||
|
itemHeight={39}
|
||||||
className="draft-list"
|
className="draft-list"
|
||||||
collection="draft" />
|
collection="draft" />
|
||||||
|
|
||||||
|
|
|
@ -73,7 +73,7 @@ ThreadListStore = Reflux.createStore
|
||||||
@setView new DatabaseView Thread, {matchers}, (item) ->
|
@setView new DatabaseView Thread, {matchers}, (item) ->
|
||||||
DatabaseStore.findAll(Message, {threadId: item.id})
|
DatabaseStore.findAll(Message, {threadId: item.id})
|
||||||
|
|
||||||
Actions.focusInCollection(collection: 'thread', item: null)
|
Actions.setFocus(collection: 'thread', item: null)
|
||||||
|
|
||||||
# Inbound Events
|
# Inbound Events
|
||||||
|
|
||||||
|
@ -128,9 +128,9 @@ ThreadListStore = Reflux.createStore
|
||||||
task = new AddRemoveTagsTask(thread, ['archive'], ['inbox'])
|
task = new AddRemoveTagsTask(thread, ['archive'], ['inbox'])
|
||||||
Actions.queueTask(task)
|
Actions.queueTask(task)
|
||||||
if thread.id is focusedId
|
if thread.id is focusedId
|
||||||
Actions.focusInCollection(collection: 'thread', item: null)
|
Actions.setFocus(collection: 'thread', item: null)
|
||||||
if thread.id is keyboardId
|
if thread.id is keyboardId
|
||||||
Actions.focusKeyboardInCollection(collection: 'thread', item: null)
|
Actions.setCursorPosition(collection: 'thread', item: null)
|
||||||
|
|
||||||
@_view.selection.clear()
|
@_view.selection.clear()
|
||||||
|
|
||||||
|
@ -175,10 +175,13 @@ ThreadListStore = Reflux.createStore
|
||||||
nextFocus = null
|
nextFocus = null
|
||||||
|
|
||||||
@_afterViewUpdate.push ->
|
@_afterViewUpdate.push ->
|
||||||
Actions.focusInCollection(collection: 'thread', item: nextFocus)
|
Actions.setFocus(collection: 'thread', item: nextFocus)
|
||||||
Actions.focusKeyboardInCollection(collection: 'thread', item: nextKeyboard)
|
Actions.setCursorPosition(collection: 'thread', item: nextKeyboard)
|
||||||
|
|
||||||
_autofocusForLayoutMode: ->
|
_autofocusForLayoutMode: ->
|
||||||
focusedId = FocusedContentStore.focusedId('thread')
|
layoutMode = WorkspaceStore.layoutMode()
|
||||||
if WorkspaceStore.layoutMode() is "split" and not focusedId
|
focused = FocusedContentStore.focused('thread')
|
||||||
_.defer => Actions.focusInCollection(collection: 'thread', item: @_view.get(0))
|
if layoutMode is 'split' and not focused and @_view.selection.count() is 0
|
||||||
|
item = @_view.get(0)
|
||||||
|
_.defer =>
|
||||||
|
Actions.setFocus({collection: 'thread', item: item})
|
||||||
|
|
|
@ -44,6 +44,10 @@ class ThreadList extends React.Component
|
||||||
|
|
||||||
@containerRequired: false
|
@containerRequired: false
|
||||||
|
|
||||||
|
constructor: (@props) ->
|
||||||
|
@state =
|
||||||
|
style: 'unknown'
|
||||||
|
|
||||||
componentWillMount: =>
|
componentWillMount: =>
|
||||||
labelComponents = (thread) =>
|
labelComponents = (thread) =>
|
||||||
for label in @state.threadLabelComponents
|
for label in @state.threadLabelComponents
|
||||||
|
@ -88,7 +92,29 @@ class ThreadList extends React.Component
|
||||||
resolver: (thread) =>
|
resolver: (thread) =>
|
||||||
<span className="timestamp">{timestamp(thread.lastMessageTimestamp)}</span>
|
<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 =
|
@commands =
|
||||||
'core:remove-item': @_onArchive
|
'core:remove-item': @_onArchive
|
||||||
'core:remove-and-previous': -> Actions.archiveAndPrevious()
|
'core:remove-and-previous': -> Actions.archiveAndPrevious()
|
||||||
|
@ -100,15 +126,42 @@ class ThreadList extends React.Component
|
||||||
className: classNames
|
className: classNames
|
||||||
'unread': item.isUnread()
|
'unread': item.isUnread()
|
||||||
|
|
||||||
|
componentDidMount: =>
|
||||||
|
window.addEventListener('resize', @_onResize, true)
|
||||||
|
@_onResize()
|
||||||
|
|
||||||
|
componentWillUnmount: =>
|
||||||
|
window.removeEventListener('resize', @_onResize)
|
||||||
|
|
||||||
render: =>
|
render: =>
|
||||||
<MultiselectList
|
if @state.style is 'wide'
|
||||||
dataStore={ThreadListStore}
|
<MultiselectList
|
||||||
columns={@columns}
|
dataStore={ThreadListStore}
|
||||||
commands={@commands}
|
columns={@wideColumns}
|
||||||
itemPropsProvider={@itemPropsProvider}
|
commands={@commands}
|
||||||
className="thread-list"
|
itemPropsProvider={@itemPropsProvider}
|
||||||
scrollTooltipComponent={ThreadListScrollTooltip}
|
itemHeight={39}
|
||||||
collection="thread" />
|
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
|
# Additional Commands
|
||||||
|
|
||||||
|
|
|
@ -180,3 +180,33 @@
|
||||||
background-image:url(../static/images/thread-list/icon-star-hover-@2x.png);
|
background-image:url(../static/images/thread-list/icon-star-hover-@2x.png);
|
||||||
background-size: 16px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -58,7 +58,7 @@ module.exports =
|
||||||
notif.onclick = ->
|
notif.onclick = ->
|
||||||
atom.displayWindow()
|
atom.displayWindow()
|
||||||
Actions.focusTag(new Tag(name: "inbox", id: "inbox"))
|
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
|
if newUnreadInInbox.length > 1
|
||||||
new Notification("#{newUnreadInInbox.length} Unread Messages", {
|
new Notification("#{newUnreadInInbox.length} Unread Messages", {
|
||||||
|
|
|
@ -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})
|
|
@ -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})
|
|
@ -7,7 +7,7 @@ Actions = require '../../src/flux/actions'
|
||||||
testThread = new Thread(id: '123')
|
testThread = new Thread(id: '123')
|
||||||
|
|
||||||
describe "FocusedContentStore", ->
|
describe "FocusedContentStore", ->
|
||||||
describe "onFocusInCollection", ->
|
describe "onSetFocus", ->
|
||||||
it "should not trigger if the thread is already focused", ->
|
it "should not trigger if the thread is already focused", ->
|
||||||
FocusedContentStore._onFocus({collection: 'thread', item: testThread})
|
FocusedContentStore._onFocus({collection: 'thread', item: testThread})
|
||||||
spyOn(FocusedContentStore, 'trigger')
|
spyOn(FocusedContentStore, 'trigger')
|
||||||
|
|
|
@ -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
|
|
|
@ -46,7 +46,7 @@ class ListTabularItem extends React.Component
|
||||||
<div key={column.name}
|
<div key={column.name}
|
||||||
displayName={column.name}
|
displayName={column.name}
|
||||||
style={_.pick(column, ['flex', 'width'])}
|
style={_.pick(column, ['flex', 'width'])}
|
||||||
className="list-column">
|
className="list-column list-column-#{column.name}">
|
||||||
{column.resolver(@props.item, @)}
|
{column.resolver(@props.item, @)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -66,11 +66,15 @@ class ListTabular extends React.Component
|
||||||
columns: React.PropTypes.arrayOf(React.PropTypes.object).isRequired
|
columns: React.PropTypes.arrayOf(React.PropTypes.object).isRequired
|
||||||
dataView: React.PropTypes.object
|
dataView: React.PropTypes.object
|
||||||
itemPropsProvider: React.PropTypes.func
|
itemPropsProvider: React.PropTypes.func
|
||||||
|
itemHeight: React.PropTypes.number
|
||||||
onSelect: React.PropTypes.func
|
onSelect: React.PropTypes.func
|
||||||
onClick: React.PropTypes.func
|
onClick: React.PropTypes.func
|
||||||
onDoubleClick: React.PropTypes.func
|
onDoubleClick: React.PropTypes.func
|
||||||
|
|
||||||
constructor: (@props) ->
|
constructor: (@props) ->
|
||||||
|
if not @props.itemHeight
|
||||||
|
throw new Error("ListTabular: You must provide an itemHeight - raising to avoid divide by zero errors.")
|
||||||
|
|
||||||
@state =
|
@state =
|
||||||
renderedRangeStart: -1
|
renderedRangeStart: -1
|
||||||
renderedRangeEnd: -1
|
renderedRangeEnd: -1
|
||||||
|
@ -103,7 +107,7 @@ class ListTabular extends React.Component
|
||||||
|
|
||||||
# If we've shifted enough pixels from our previous scrollTop to require
|
# If we've shifted enough pixels from our previous scrollTop to require
|
||||||
# new rows to be rendered, update our state!
|
# 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()
|
@updateRangeState()
|
||||||
|
|
||||||
onDoneReceivingScrollEvents: =>
|
onDoneReceivingScrollEvents: =>
|
||||||
|
@ -114,11 +118,9 @@ class ListTabular extends React.Component
|
||||||
updateRangeState: =>
|
updateRangeState: =>
|
||||||
scrollTop = @refs.container.scrollTop
|
scrollTop = @refs.container.scrollTop
|
||||||
|
|
||||||
rowHeight = @_rowHeight()
|
|
||||||
|
|
||||||
# Determine the exact range of rows we want onscreen
|
# Determine the exact range of rows we want onscreen
|
||||||
rangeStart = Math.floor(scrollTop / rowHeight)
|
rangeStart = Math.floor(scrollTop / @props.itemHeight)
|
||||||
rangeEnd = rangeStart + window.innerHeight / rowHeight
|
rangeEnd = rangeStart + window.innerHeight / @props.itemHeight
|
||||||
|
|
||||||
# 1. Clip this range to the number of available items
|
# 1. Clip this range to the number of available items
|
||||||
#
|
#
|
||||||
|
@ -154,7 +156,7 @@ class ListTabular extends React.Component
|
||||||
|
|
||||||
render: =>
|
render: =>
|
||||||
innerStyles =
|
innerStyles =
|
||||||
height: @props.dataView.count() * @_rowHeight()
|
height: @props.dataView.count() * @props.itemHeight
|
||||||
pointerEvents: if @state.scrollInProgress then 'none' else 'auto'
|
pointerEvents: if @state.scrollInProgress then 'none' else 'auto'
|
||||||
|
|
||||||
<ScrollRegion ref="container" onScroll={@updateScrollState} tabIndex="-1" className="list-container list-tabular" scrollTooltipComponent={@props.scrollTooltipComponent} >
|
<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>
|
</div>
|
||||||
</ScrollRegion>
|
</ScrollRegion>
|
||||||
|
|
||||||
_rowHeight: =>
|
|
||||||
39
|
|
||||||
|
|
||||||
_headers: =>
|
_headers: =>
|
||||||
return [] unless @props.displayHeaders
|
return [] unless @props.displayHeaders
|
||||||
|
|
||||||
|
@ -182,7 +181,6 @@ class ListTabular extends React.Component
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
_rows: =>
|
_rows: =>
|
||||||
rowHeight = @_rowHeight()
|
|
||||||
rows = []
|
rows = []
|
||||||
|
|
||||||
for idx in [@state.renderedRangeStart..@state.renderedRangeEnd-1]
|
for idx in [@state.renderedRangeStart..@state.renderedRangeEnd-1]
|
||||||
|
@ -196,7 +194,7 @@ class ListTabular extends React.Component
|
||||||
rows.push <ListTabularItem key={item.id ? idx}
|
rows.push <ListTabularItem key={item.id ? idx}
|
||||||
item={item}
|
item={item}
|
||||||
itemProps={itemProps}
|
itemProps={itemProps}
|
||||||
metrics={top: idx * rowHeight, height: rowHeight}
|
metrics={top: idx * @props.itemHeight, height: @props.itemHeight}
|
||||||
columns={@props.columns}
|
columns={@props.columns}
|
||||||
onSelect={@props.onSelect}
|
onSelect={@props.onSelect}
|
||||||
onClick={@props.onClick}
|
onClick={@props.onClick}
|
||||||
|
|
54
src/components/multiselect-list-interaction-handler.coffee
Normal file
54
src/components/multiselect-list-interaction-handler.coffee
Normal 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}
|
|
@ -11,6 +11,9 @@ Spinner = require './spinner'
|
||||||
NamespaceStore} = require 'nylas-exports'
|
NamespaceStore} = require 'nylas-exports'
|
||||||
EventEmitter = require('events').EventEmitter
|
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
|
Public: MultiselectList wraps {ListTabular} and makes it easy to present a
|
||||||
{ModelView} with selection support. It adds a checkbox column to the columns
|
{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
|
columns: React.PropTypes.array.isRequired
|
||||||
dataStore: React.PropTypes.object.isRequired
|
dataStore: React.PropTypes.object.isRequired
|
||||||
itemPropsProvider: React.PropTypes.func.isRequired
|
itemPropsProvider: React.PropTypes.func.isRequired
|
||||||
|
itemHeight: React.PropTypes.number.isRequired
|
||||||
scrollTooltipComponent: React.PropTypes.func
|
scrollTooltipComponent: React.PropTypes.func
|
||||||
|
|
||||||
constructor: (@props) ->
|
constructor: (@props) ->
|
||||||
|
@ -77,21 +81,9 @@ class MultiselectList extends React.Component
|
||||||
context = {focusedId: @state.focusedId}
|
context = {focusedId: @state.focusedId}
|
||||||
props.commands[key](context)
|
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 = []
|
||||||
@unsubscribers.push props.dataStore.listen @_onChange
|
@unsubscribers.push props.dataStore.listen @_onChange
|
||||||
|
@unsubscribers.push WorkspaceStore.listen @_onChange
|
||||||
@unsubscribers.push FocusedContentStore.listen @_onChange
|
@unsubscribers.push FocusedContentStore.listen @_onChange
|
||||||
@command_unsubscriber = atom.commands.add('body', commands)
|
@command_unsubscriber = atom.commands.add('body', commands)
|
||||||
|
|
||||||
|
@ -111,18 +103,19 @@ class MultiselectList extends React.Component
|
||||||
props.className ?= ''
|
props.className ?= ''
|
||||||
props.className += " " + classNames
|
props.className += " " + classNames
|
||||||
'selected': item.id in @state.selectedIds
|
'selected': item.id in @state.selectedIds
|
||||||
'focused': @state.showFocus and item.id is @state.focusedId
|
'focused': @state.handler.shouldShowFocus() and item.id is @state.focusedId
|
||||||
'keyboard-cursor': @state.showKeyboardCursor and item.id is @state.keyboardCursorId
|
'keyboard-cursor': @state.handler.shouldShowKeyboardCursor() and item.id is @state.keyboardCursorId
|
||||||
props
|
props
|
||||||
|
|
||||||
if @state.dataView
|
if @state.dataView
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<ListTabular
|
<ListTabular
|
||||||
ref="list"
|
ref="list"
|
||||||
columns={@props.columns}
|
columns={@state.columns}
|
||||||
scrollTooltipComponent={@props.scrollTooltipComponent}
|
scrollTooltipComponent={@props.scrollTooltipComponent}
|
||||||
dataView={@state.dataView}
|
dataView={@state.dataView}
|
||||||
itemPropsProvider={@itemPropsProvider}
|
itemPropsProvider={@itemPropsProvider}
|
||||||
|
itemHeight={@props.itemHeight}
|
||||||
onSelect={@_onClickItem}
|
onSelect={@_onClickItem}
|
||||||
onDoubleClick={@props.onDoubleClick} />
|
onDoubleClick={@props.onDoubleClick} />
|
||||||
<Spinner visible={!@state.ready} />
|
<Spinner visible={!@state.ready} />
|
||||||
|
@ -135,58 +128,24 @@ class MultiselectList extends React.Component
|
||||||
|
|
||||||
_onClickItem: (item, event) =>
|
_onClickItem: (item, event) =>
|
||||||
if event.metaKey
|
if event.metaKey
|
||||||
@state.dataView.selection.toggle(item)
|
@state.handler.onMetaClick(item)
|
||||||
if @state.showKeyboardCursor
|
|
||||||
Actions.focusKeyboardInCollection({collection: @props.collection, item: item})
|
|
||||||
else if event.shiftKey
|
else if event.shiftKey
|
||||||
@state.dataView.selection.expandTo(item)
|
@state.handler.onShiftClick(item)
|
||||||
if @state.showKeyboardCursor
|
|
||||||
Actions.focusKeyboardInCollection({collection: @props.collection, item: item})
|
|
||||||
else
|
else
|
||||||
Actions.focusInCollection({collection: @props.collection, item: item})
|
@state.handler.onClick(item)
|
||||||
|
|
||||||
_onEnter: =>
|
_onEnter: =>
|
||||||
return unless @state.showKeyboardCursor
|
@state.handler.onEnter()
|
||||||
item = @state.dataView.getById(@state.keyboardCursorId)
|
|
||||||
if item
|
|
||||||
Actions.focusInCollection({collection: @props.collection, item: item})
|
|
||||||
|
|
||||||
_onSelect: =>
|
_onSelect: =>
|
||||||
if @state.showKeyboardCursor and @_visible()
|
@state.handler.onSelect()
|
||||||
id = @state.keyboardCursorId
|
|
||||||
else
|
|
||||||
id = @state.focusedId
|
|
||||||
|
|
||||||
return unless id
|
|
||||||
@state.dataView.selection.toggle(@state.dataView.getById(id))
|
|
||||||
|
|
||||||
_onDeselect: =>
|
_onDeselect: =>
|
||||||
return unless @_visible()
|
return unless @_visible()
|
||||||
@state.dataView.selection.clear()
|
@state.dataView.selection.clear()
|
||||||
|
|
||||||
_onShift: (delta, options = {}) =>
|
_onShift: (delta, options = {}) =>
|
||||||
if @state.showKeyboardCursor and @_visible()
|
@state.handler.onShift(delta, options)
|
||||||
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
|
|
||||||
|
|
||||||
# This onChange handler can be called many times back to back and setState
|
# 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,
|
# sometimes triggers an immediate render. Ensure that we never render back-to-back,
|
||||||
|
@ -198,19 +157,44 @@ class MultiselectList extends React.Component
|
||||||
, 1
|
, 1
|
||||||
@_onChangeDebounced()
|
@_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) =>
|
_getStateFromStores: (props) =>
|
||||||
props ?= @props
|
props ?= @props
|
||||||
|
|
||||||
view = props.dataStore?.view()
|
view = props.dataStore?.view()
|
||||||
return {} unless 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
|
dataView: view
|
||||||
|
columns: columns
|
||||||
|
handler: handler
|
||||||
ready: view.loaded()
|
ready: view.loaded()
|
||||||
selectedIds: view.selection.ids()
|
selectedIds: view.selection.ids()
|
||||||
focusedId: FocusedContentStore.focusedId(props.collection)
|
focusedId: FocusedContentStore.focusedId(props.collection)
|
||||||
keyboardCursorId: FocusedContentStore.keyboardCursorId(props.collection)
|
keyboardCursorId: FocusedContentStore.keyboardCursorId(props.collection)
|
||||||
showFocus: !FocusedContentStore.keyboardCursorEnabled()
|
|
||||||
showKeyboardCursor: FocusedContentStore.keyboardCursorEnabled()
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = MultiselectList
|
module.exports = MultiselectList
|
||||||
|
|
75
src/components/multiselect-split-interaction-handler.coffee
Normal file
75
src/components/multiselect-split-interaction-handler.coffee
Normal 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()
|
|
@ -185,7 +185,7 @@ class Actions
|
||||||
Actions.selectLayoutMode(collection: 'thread', item: <Thread>)
|
Actions.selectLayoutMode(collection: 'thread', item: <Thread>)
|
||||||
```
|
```
|
||||||
###
|
###
|
||||||
@focusKeyboardInCollection: ActionScopeWindow
|
@setCursorPosition: ActionScopeWindow
|
||||||
|
|
||||||
###
|
###
|
||||||
Public: Focus on an item in a collection. This action changes the selection
|
Public: Focus on an item in a collection. This action changes the selection
|
||||||
|
@ -194,10 +194,10 @@ class Actions
|
||||||
*Scope: Window*
|
*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}.
|
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
|
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
|
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*
|
*Scope: Window*
|
||||||
|
|
||||||
|
@ -435,7 +435,7 @@ class Actions
|
||||||
###
|
###
|
||||||
Public: Push a sheet of a specific type onto the Sheet stack maintained by the
|
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,
|
{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*
|
*Scope: Window*
|
||||||
|
|
||||||
|
|
|
@ -18,8 +18,8 @@ selection public so that you can observe focus changes and trigger your own chan
|
||||||
to focus.
|
to focus.
|
||||||
|
|
||||||
Since {FocusedContentStore} is a Flux-compatible Store, you do not call setters
|
Since {FocusedContentStore} is a Flux-compatible Store, you do not call setters
|
||||||
on it directly. Instead, use {Actions::focusInCollection} or
|
on it directly. Instead, use {Actions::setFocus} or
|
||||||
{Actions::focusKeyboardInCollection} to set focus. The FocusedContentStore observes
|
{Actions::setCursorPosition} to set focus. The FocusedContentStore observes
|
||||||
these models, changes it's state, and broadcasts to it's observers.
|
these models, changes it's state, and broadcasts to it's observers.
|
||||||
|
|
||||||
Note: The {FocusedContentStore} triggers when a focused model is changed, even if
|
Note: The {FocusedContentStore} triggers when a focused model is changed, even if
|
||||||
|
@ -62,8 +62,8 @@ class FocusedContentStore
|
||||||
@listenTo NamespaceStore, @_onClear
|
@listenTo NamespaceStore, @_onClear
|
||||||
@listenTo WorkspaceStore, @_onWorkspaceChange
|
@listenTo WorkspaceStore, @_onWorkspaceChange
|
||||||
@listenTo DatabaseStore, @_onDataChange
|
@listenTo DatabaseStore, @_onDataChange
|
||||||
@listenTo Actions.focusInCollection, @_onFocus
|
@listenTo Actions.setFocus, @_onFocus
|
||||||
@listenTo Actions.focusKeyboardInCollection, @_onFocusKeyboard
|
@listenTo Actions.setCursorPosition, @_onFocusKeyboard
|
||||||
|
|
||||||
_resetInstanceVars: =>
|
_resetInstanceVars: =>
|
||||||
@_focused = {}
|
@_focused = {}
|
||||||
|
|
|
@ -17,6 +17,9 @@ class ModelViewSelection
|
||||||
items: ->
|
items: ->
|
||||||
@_items
|
@_items
|
||||||
|
|
||||||
|
top: ->
|
||||||
|
@_items[@_items.length - 1]
|
||||||
|
|
||||||
clear: ->
|
clear: ->
|
||||||
@set([])
|
@set([])
|
||||||
|
|
||||||
|
@ -49,6 +52,16 @@ class ModelViewSelection
|
||||||
@_items.push(item)
|
@_items.push(item)
|
||||||
@trigger(@)
|
@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) ->
|
remove: (item) ->
|
||||||
return unless item
|
return unless item
|
||||||
throw new Error("remove must be called with a Model") unless item instanceof Model
|
throw new Error("remove must be called with a Model") unless item instanceof Model
|
||||||
|
|
|
@ -28,7 +28,7 @@ class WorkspaceStore
|
||||||
|
|
||||||
@listenTo Actions.selectRootSheet, @_onSelectRootSheet
|
@listenTo Actions.selectRootSheet, @_onSelectRootSheet
|
||||||
@listenTo Actions.selectLayoutMode, @_onSelectLayoutMode
|
@listenTo Actions.selectLayoutMode, @_onSelectLayoutMode
|
||||||
@listenTo Actions.focusInCollection, @_onFocusInCollection
|
@listenTo Actions.setFocus, @_onSetFocus
|
||||||
|
|
||||||
@listenTo Actions.popSheet, @popSheet
|
@listenTo Actions.popSheet, @popSheet
|
||||||
@listenTo Actions.searchQueryCommitted, @popToRootSheet
|
@listenTo Actions.searchQueryCommitted, @popToRootSheet
|
||||||
|
@ -74,7 +74,7 @@ class WorkspaceStore
|
||||||
@_preferredLayoutMode = mode
|
@_preferredLayoutMode = mode
|
||||||
@trigger(@)
|
@trigger(@)
|
||||||
|
|
||||||
_onFocusInCollection: ({collection, item}) =>
|
_onSetFocus: ({collection, item}) =>
|
||||||
if collection is 'thread'
|
if collection is 'thread'
|
||||||
if @layoutMode() is 'list'
|
if @layoutMode() is 'list'
|
||||||
if item and @topSheet() isnt Sheet.Thread
|
if item and @topSheet() isnt Sheet.Thread
|
||||||
|
@ -182,7 +182,7 @@ class WorkspaceStore
|
||||||
@trigger()
|
@trigger()
|
||||||
|
|
||||||
if Sheet.Thread and sheet is Sheet.Thread
|
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
|
# Return to the root sheet. This method triggers, allowing observers
|
||||||
# to update.
|
# to update.
|
||||||
|
|
|
@ -36,7 +36,7 @@ class SheetContainer extends React.Component
|
||||||
|
|
||||||
<Flexbox direction="column">
|
<Flexbox direction="column">
|
||||||
{@_toolbarContainerElement()}
|
{@_toolbarContainerElement()}
|
||||||
|
|
||||||
<div name="Header" style={order:1, zIndex: 2}>
|
<div name="Header" style={order:1, zIndex: 2}>
|
||||||
<InjectedComponentSet matching={locations: [topSheet.Header, WorkspaceStore.Sheet.Global.Header]}
|
<InjectedComponentSet matching={locations: [topSheet.Header, WorkspaceStore.Sheet.Global.Header]}
|
||||||
direction="column"
|
direction="column"
|
||||||
|
@ -89,6 +89,7 @@ class SheetContainer extends React.Component
|
||||||
|
|
||||||
_onColumnSizeChanged: (sheet) =>
|
_onColumnSizeChanged: (sheet) =>
|
||||||
@refs["toolbar-#{sheet.props.depth}"]?.recomputeLayout()
|
@refs["toolbar-#{sheet.props.depth}"]?.recomputeLayout()
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
|
||||||
_onStoreChange: =>
|
_onStoreChange: =>
|
||||||
_.defer => @setState(@_getStateFromStores())
|
_.defer => @setState(@_getStateFromStores())
|
||||||
|
@ -97,4 +98,4 @@ class SheetContainer extends React.Component
|
||||||
stack: WorkspaceStore.sheetStack()
|
stack: WorkspaceStore.sheetStack()
|
||||||
|
|
||||||
|
|
||||||
module.exports = SheetContainer
|
module.exports = SheetContainer
|
||||||
|
|
|
@ -113,7 +113,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&.keyboard-cursor {
|
&.keyboard-cursor {
|
||||||
.checkmark {
|
.list-column:first-child {
|
||||||
border-left:4px solid @list-focused-bg;
|
border-left:4px solid @list-focused-bg;
|
||||||
padding-left:8px;
|
padding-left:8px;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue