diff --git a/internal_packages/settings/lib/apm-wrapper.coffee b/internal_packages/settings/lib/apm-wrapper.coffee index 99b63a092..44d86b4f6 100644 --- a/internal_packages/settings/lib/apm-wrapper.coffee +++ b/internal_packages/settings/lib/apm-wrapper.coffee @@ -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: -> diff --git a/internal_packages/thread-list/lib/draft-list.cjsx b/internal_packages/thread-list/lib/draft-list.cjsx index 15a14f8f6..af518da78 100644 --- a/internal_packages/thread-list/lib/draft-list.cjsx +++ b/internal_packages/thread-list/lib/draft-list.cjsx @@ -59,6 +59,7 @@ class DraftList extends React.Component commands={@commands} onDoubleClick={@_onDoubleClick} itemPropsProvider={ -> {} } + itemHeight={39} className="draft-list" collection="draft" /> diff --git a/internal_packages/thread-list/lib/thread-list-store.coffee b/internal_packages/thread-list/lib/thread-list-store.coffee index e0adce41d..1a2031ec1 100644 --- a/internal_packages/thread-list/lib/thread-list-store.coffee +++ b/internal_packages/thread-list/lib/thread-list-store.coffee @@ -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}) diff --git a/internal_packages/thread-list/lib/thread-list.cjsx b/internal_packages/thread-list/lib/thread-list.cjsx index 8c57fb640..b43250980 100644 --- a/internal_packages/thread-list/lib/thread-list.cjsx +++ b/internal_packages/thread-list/lib/thread-list.cjsx @@ -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) => {timestamp(thread.lastMessageTimestamp)} - @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 = + +
+
+ + + {timestamp(thread.lastMessageTimestamp)} + {pencil} +
+
{subject(thread.subject)}
+
{thread.snippet}
+
+ + @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: => - + if @state.style is 'wide' + + else if @state.style is 'narrow' + + else +
+ + _onResize: (event) => + current = @state.style + desired = if React.findDOMNode(@).offsetWidth < 540 then 'narrow' else 'wide' + if current isnt desired + @setState(style: desired) # Additional Commands diff --git a/internal_packages/thread-list/stylesheets/thread-list.less b/internal_packages/thread-list/stylesheets/thread-list.less index 383d7f4b3..dae3dcb7d 100644 --- a/internal_packages/thread-list/stylesheets/thread-list.less +++ b/internal_packages/thread-list/stylesheets/thread-list.less @@ -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; + } +} diff --git a/internal_packages/unread-notifications/lib/main.coffee b/internal_packages/unread-notifications/lib/main.coffee index 6eba230f6..377ff9827 100644 --- a/internal_packages/unread-notifications/lib/main.coffee +++ b/internal_packages/unread-notifications/lib/main.coffee @@ -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", { diff --git a/spec-nylas/components/multiselect-list-interaction-handler-spec.coffee b/spec-nylas/components/multiselect-list-interaction-handler-spec.coffee new file mode 100644 index 000000000..189f159f3 --- /dev/null +++ b/spec-nylas/components/multiselect-list-interaction-handler-spec.coffee @@ -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}) diff --git a/spec-nylas/components/multiselect-split-interaction-handler-spec.coffee b/spec-nylas/components/multiselect-split-interaction-handler-spec.coffee new file mode 100644 index 000000000..bae6e9b8f --- /dev/null +++ b/spec-nylas/components/multiselect-split-interaction-handler-spec.coffee @@ -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}) diff --git a/spec-nylas/stores/focused-content-store-spec.coffee b/spec-nylas/stores/focused-content-store-spec.coffee index eaa1d2f6c..1fedc74fa 100644 --- a/spec-nylas/stores/focused-content-store-spec.coffee +++ b/spec-nylas/stores/focused-content-store-spec.coffee @@ -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') diff --git a/src/components/list-narrow-item.cjsx b/src/components/list-narrow-item.cjsx deleted file mode 100644 index 3e01b92f7..000000000 --- a/src/components/list-narrow-item.cjsx +++ /dev/null @@ -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: -> -
- {_.pluck(@props.participants, "email").join ", "} -
- -module.exports = -ThreadListNarrowItem = React.createClass - mixins: [ComponentRegistry.Mixin, ThreadListItemMixin] - displayName: 'ThreadListNarrowItem' - components: ["Participants"] - - render: -> - Participants = @state.Participants ? DefaultParticipants -
-
- -
- {@threadTime()} -
- -
-
- {@_subject()} - {@_snippet()} -
-
- - _containerClasses: -> - classNames - 'unread': @props.unread - 'selected': @props.selected - 'thread-list-item': true - 'thread-list-narrow-item': true diff --git a/src/components/list-tabular.cjsx b/src/components/list-tabular.cjsx index 87311a6ad..6f0c5da32 100644 --- a/src/components/list-tabular.cjsx +++ b/src/components/list-tabular.cjsx @@ -46,7 +46,7 @@ class ListTabularItem extends React.Component
+ className="list-column list-column-#{column.name}"> {column.resolver(@props.item, @)}
@@ -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' @@ -164,9 +166,6 @@ class ListTabular extends React.Component - _rowHeight: => - 39 - _headers: => return [] unless @props.displayHeaders @@ -182,7 +181,6 @@ class ListTabular extends React.Component _rows: => - rowHeight = @_rowHeight() rows = [] for idx in [@state.renderedRangeStart..@state.renderedRangeEnd-1] @@ -196,7 +194,7 @@ class ListTabular extends React.Component rows.push + + 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} diff --git a/src/components/multiselect-list.cjsx b/src/components/multiselect-list.cjsx index e85634bf1..d0a2c07ed 100644 --- a/src/components/multiselect-list.cjsx +++ b/src/components/multiselect-list.cjsx @@ -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() -
- 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
@@ -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() +
+ _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 diff --git a/src/components/multiselect-split-interaction-handler.coffee b/src/components/multiselect-split-interaction-handler.coffee new file mode 100644 index 000000000..6f0218566 --- /dev/null +++ b/src/components/multiselect-split-interaction-handler.coffee @@ -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() diff --git a/src/flux/actions.coffee b/src/flux/actions.coffee index e8b7c66c8..d0c136008 100644 --- a/src/flux/actions.coffee +++ b/src/flux/actions.coffee @@ -185,7 +185,7 @@ class Actions Actions.selectLayoutMode(collection: 'thread', item: ) ``` ### - @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: ) + Actions.setFocus(collection: 'thread', item: ) ``` ### - @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* diff --git a/src/flux/stores/focused-content-store.coffee b/src/flux/stores/focused-content-store.coffee index 21e0b3ce7..f4f286faf 100644 --- a/src/flux/stores/focused-content-store.coffee +++ b/src/flux/stores/focused-content-store.coffee @@ -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 = {} diff --git a/src/flux/stores/model-view-selection.coffee b/src/flux/stores/model-view-selection.coffee index d848ae1f1..4635b997e 100644 --- a/src/flux/stores/model-view-selection.coffee +++ b/src/flux/stores/model-view-selection.coffee @@ -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 diff --git a/src/flux/stores/workspace-store.coffee b/src/flux/stores/workspace-store.coffee index 3097f0fe7..eac392e34 100644 --- a/src/flux/stores/workspace-store.coffee +++ b/src/flux/stores/workspace-store.coffee @@ -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. diff --git a/src/sheet-container.cjsx b/src/sheet-container.cjsx index 23924c049..1b200a25c 100644 --- a/src/sheet-container.cjsx +++ b/src/sheet-container.cjsx @@ -36,7 +36,7 @@ class SheetContainer extends React.Component {@_toolbarContainerElement()} - +
@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 \ No newline at end of file +module.exports = SheetContainer diff --git a/static/components/list-tabular.less b/static/components/list-tabular.less index 630527a30..45dd7f712 100644 --- a/static/components/list-tabular.less +++ b/static/components/list-tabular.less @@ -113,7 +113,7 @@ } &.keyboard-cursor { - .checkmark { + .list-column:first-child { border-left:4px solid @list-focused-bg; padding-left:8px; }