mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-03-05 04:24:59 +08:00
feat(picker): new folder and label picker
Summary: The new, styled folder and label picker! Some highlights: - Folder picker has custom icons and a divider - Label picker has checkboxes with off, checked, and intermediate state - Extracted LabelColorizer out of mail-label - Search will bold the results it found - Fixes to Tooltip to prevent it displaying at invalid moments - Keyboard UX improvements to Menu Test Plan: coming soon Reviewers: bengotow Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D1790
This commit is contained in:
parent
0aea79f0ba
commit
f22d154486
29 changed files with 356 additions and 278 deletions
exports
internal_packages
category-picker
message-list/lib
tooltip/lib
keymaps
src
static
|
@ -4,6 +4,8 @@
|
|||
GeneratedForm,
|
||||
GeneratedFieldset} = require ('../src/components/generated-form')
|
||||
|
||||
{MailLabel, LabelColorizer} = require '../src/components/mail-label'
|
||||
|
||||
module.exports =
|
||||
# Models
|
||||
Menu: require '../src/components/menu'
|
||||
|
@ -23,7 +25,8 @@ module.exports =
|
|||
InjectedComponent: require '../src/components/injected-component'
|
||||
TokenizingTextField: require '../src/components/tokenizing-text-field'
|
||||
TimeoutTransitionGroup: require '../src/components/timeout-transition-group'
|
||||
MailLabel: require '../src/components/mail-label'
|
||||
MailLabel: MailLabel
|
||||
LabelColorizer: LabelColorizer
|
||||
FormItem: FormItem
|
||||
GeneratedForm: GeneratedForm
|
||||
GeneratedFieldset: GeneratedFieldset
|
||||
|
|
|
@ -1,43 +1,43 @@
|
|||
_ = require 'underscore'
|
||||
React = require 'react'
|
||||
|
||||
{Actions,
|
||||
{Utils,
|
||||
Thread,
|
||||
Actions,
|
||||
TaskQueue,
|
||||
CategoryStore,
|
||||
NamespaceStore,
|
||||
WorkspaceStore,
|
||||
ChangeLabelsTask,
|
||||
ChangeFolderTask,
|
||||
FocusedContentStore} = require 'nylas-exports'
|
||||
FocusedContentStore,
|
||||
FocusedCategoryStore} = require 'nylas-exports'
|
||||
|
||||
{Menu,
|
||||
Popover,
|
||||
RetinaImg} = require 'nylas-component-kit'
|
||||
RetinaImg,
|
||||
LabelColorizer} = require 'nylas-component-kit'
|
||||
|
||||
# This changes the category on one or more threads.
|
||||
#
|
||||
# See internal_packages/thread-list/lib/thread-buttons.cjsx
|
||||
# See internal_packages/message-list/lib/thread-tags-button.cjsx
|
||||
# See internal_packages/message-list/lib/thread-archive-button.cjsx
|
||||
# See internal_packages/message-list/lib/message-toolbar-items.cjsx
|
||||
|
||||
class CategoryPicker extends React.Component
|
||||
@displayName: "CategoryPicker"
|
||||
@containerRequired: false
|
||||
|
||||
@propTypes:
|
||||
selection: React.PropTypes.object.isRequired
|
||||
|
||||
constructor: (@props) ->
|
||||
@state = _.extend @_recalculateState(@props), searchValue: ""
|
||||
|
||||
@contextTypes:
|
||||
sheetDepth: React.PropTypes.number
|
||||
|
||||
componentDidMount: =>
|
||||
@unsubscribers = []
|
||||
@unsubscribers.push CategoryStore.listen @_onStoreChanged
|
||||
@unsubscribers.push NamespaceStore.listen @_onStoreChanged
|
||||
@unsubscribers.push FocusedContentStore.listen @_onStoreChanged
|
||||
@unsubscribers.push FocusedCategoryStore.listen @_onStoreChanged
|
||||
|
||||
@_commandUnsubscriber = atom.commands.add 'body',
|
||||
"application:change-category": @_onChangeCategory
|
||||
"application:change-category": @_onOpenCategoryPopover
|
||||
|
||||
# If the threads we're picking categories for change, (like when they
|
||||
# get their categories updated), we expect our parents to pass us new
|
||||
|
@ -52,9 +52,25 @@ class CategoryPicker extends React.Component
|
|||
@_commandUnsubscriber.dispose()
|
||||
|
||||
render: =>
|
||||
button = <button className="btn btn-toolbar" data-tooltip={@_tooltipLabel()}>
|
||||
<RetinaImg name="toolbar-tags.png" mode={RetinaImg.Mode.ContentIsMask}/>
|
||||
<RetinaImg name="toolbar-chevron.png" mode={RetinaImg.Mode.ContentIsMask}/>
|
||||
return <span></span> unless @_namespace
|
||||
|
||||
if @_namespace?.usesLabels()
|
||||
img = "ic-toolbar-tag.png"
|
||||
tooltip = "Apply Labels"
|
||||
placeholder = "Label as"
|
||||
else if @_namespace?.usesFolders()
|
||||
img = "ic-toolbar-movetofolder.png"
|
||||
tooltip = "Move to Folder"
|
||||
placeholder = "Move to folder"
|
||||
else
|
||||
img = ""
|
||||
tooltip = ""
|
||||
placeholder = ""
|
||||
|
||||
if @state.isPopoverOpen then tooltip = ""
|
||||
|
||||
button = <button className="btn btn-toolbar" data-tooltip={tooltip}>
|
||||
<RetinaImg name={img} mode={RetinaImg.Mode.ContentIsMask}/>
|
||||
</button>
|
||||
|
||||
headerComponents = [
|
||||
|
@ -62,39 +78,99 @@ class CategoryPicker extends React.Component
|
|||
tabIndex="1"
|
||||
key="textfield"
|
||||
className="search"
|
||||
placeholder={placeholder}
|
||||
value={@state.searchValue}
|
||||
onChange={@_onSearchValueChange}/>
|
||||
]
|
||||
|
||||
<Popover className="tag-picker"
|
||||
<Popover className="category-picker"
|
||||
ref="popover"
|
||||
onOpened={@_onPopoverOpened}
|
||||
direction="down"
|
||||
onClosed={@_onPopoverClosed}
|
||||
direction="down-align-left"
|
||||
buttonComponent={button}>
|
||||
<Menu ref="menu"
|
||||
headerComponents={headerComponents}
|
||||
footerComponents={[]}
|
||||
items={@state.categoryData}
|
||||
itemKey={ (categoryDatum) -> categoryDatum.id }
|
||||
itemContent={@_itemContent}
|
||||
itemChecked={ (categoryDatum) -> categoryDatum.usage > 0 }
|
||||
itemContent={@_renderItemContent}
|
||||
onSelect={@_onSelectCategory}
|
||||
defaultSelectedIndex={if @state.searchValue is "" then -1 else 0}
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
_tooltipLabel: ->
|
||||
return "" unless @_namespace
|
||||
if @_namespace.usesLabels()
|
||||
return "Apply Labels"
|
||||
else if @_namespace.usesFolders()
|
||||
return "Move to Folder"
|
||||
|
||||
_onChangeCategory: =>
|
||||
_onOpenCategoryPopover: =>
|
||||
return unless @_threads().length > 0
|
||||
return unless @context.sheetDepth is WorkspaceStore.sheetStack().length - 1
|
||||
@refs.popover.open()
|
||||
return
|
||||
|
||||
_itemContent: (categoryDatum) =>
|
||||
<span className="category-item">{categoryDatum.display_name}</span>
|
||||
_renderItemContent: (categoryDatum) =>
|
||||
if categoryDatum.divider
|
||||
return <Menu.Item divider={categoryDatum.divider} />
|
||||
if @_namespace?.usesLabels()
|
||||
icon = @_renderCheckbox(categoryDatum)
|
||||
else if @_namespace?.usesFolders()
|
||||
icon = @_renderFolderIcon(categoryDatum)
|
||||
else return <span></span>
|
||||
|
||||
<div className="category-item">
|
||||
{icon}
|
||||
<div className="category-display-name">
|
||||
{@_renderBoldedSearchResults(categoryDatum)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
_renderCheckbox: (categoryDatum) ->
|
||||
styles = {}
|
||||
styles.backgroundColor = categoryDatum.backgroundColor
|
||||
|
||||
if categoryDatum.usage is 0
|
||||
checkStatus = <span></span>
|
||||
else if categoryDatum.usage < categoryDatum.numThreads
|
||||
checkStatus = <RetinaImg
|
||||
className="check-img dash"
|
||||
name="tagging-conflicted.png"
|
||||
mode={RetinaImg.Mode.ContentPreserve}
|
||||
onClick={=> @_onSelectCategory(categoryDatum)}/>
|
||||
else
|
||||
checkStatus = <RetinaImg
|
||||
className="check-img check"
|
||||
name="tagging-checkmark.png"
|
||||
mode={RetinaImg.Mode.ContentPreserve}
|
||||
onClick={=> @_onSelectCategory(categoryDatum)}/>
|
||||
|
||||
<div className="check-wrap" style={styles}>
|
||||
<RetinaImg
|
||||
className="check-img check"
|
||||
name="tagging-checkbox.png"
|
||||
mode={RetinaImg.Mode.ContentPreserve}
|
||||
onClick={=> @_onSelectCategory(categoryDatum)}/>
|
||||
{checkStatus}
|
||||
</div>
|
||||
|
||||
_renderFolderIcon: (categoryDatum) ->
|
||||
<RetinaImg name={"#{categoryDatum.name}.png"} fallback={'folder.png'} mode={RetinaImg.Mode.ContentIsMask} />
|
||||
|
||||
_renderBoldedSearchResults: (categoryDatum) ->
|
||||
name = categoryDatum.display_name
|
||||
searchTerm = (@state.searchValue ? "").trim()
|
||||
|
||||
return name if searchTerm.length is 0
|
||||
|
||||
re = Utils.wordSearchRegExp(searchTerm)
|
||||
parts = name.split(re).map (part) ->
|
||||
# The wordSearchRegExp looks for a leading non-word character to
|
||||
# deterine if it's a valid place to search. As such, we need to not
|
||||
# include that leading character as part of our match.
|
||||
if re.test(part)
|
||||
if /\W/.test(part[0])
|
||||
return <span>{part[0]}<strong>{part[1..-1]}</strong></span>
|
||||
else
|
||||
return <strong>{part}</strong>
|
||||
else return part
|
||||
return <span>{parts}</span>
|
||||
|
||||
_onSelectCategory: (categoryDatum) =>
|
||||
return unless @_threads().length > 0
|
||||
|
@ -116,11 +192,12 @@ class CategoryPicker extends React.Component
|
|||
threadIds: @_threadIds()
|
||||
if @props.thread
|
||||
Actions.moveThread(@props.thread, task)
|
||||
else if @props.selection
|
||||
else if @props.items
|
||||
Actions.moveThreads(@_threads(), task)
|
||||
|
||||
else throw new Error("Invalid organizationUnit")
|
||||
|
||||
@refs.popover.close()
|
||||
TaskQueue.enqueue(task)
|
||||
|
||||
_onStoreChanged: =>
|
||||
|
@ -131,20 +208,33 @@ class CategoryPicker extends React.Component
|
|||
|
||||
_onPopoverOpened: =>
|
||||
@setState @_recalculateState(@props, searchValue: "")
|
||||
@setState isPopoverOpen: true
|
||||
|
||||
_onPopoverClosed: =>
|
||||
@setState isPopoverOpen: false
|
||||
|
||||
_recalculateState: (props=@props, {searchValue}={}) =>
|
||||
searchValue = searchValue ? @state?.searchValue ? ""
|
||||
if @_threads(props).length is 0
|
||||
numThreads = @_threads(props).length
|
||||
if numThreads is 0
|
||||
return {categoryData: [], searchValue}
|
||||
@_namespace = NamespaceStore.current()
|
||||
return unless @_namespace
|
||||
|
||||
categories = CategoryStore.getCategories()
|
||||
categories = [].concat(CategoryStore.getStandardCategories())
|
||||
.concat([{divider: true}])
|
||||
.concat(CategoryStore.getUserCategories())
|
||||
|
||||
usageCount = @_categoryUsageCount(props, categories)
|
||||
|
||||
allInInbox = @_allInInbox(usageCount, numThreads)
|
||||
|
||||
displayData = {usageCount, numThreads}
|
||||
|
||||
categoryData = _.chain(categories)
|
||||
.filter(@_isUserFacing)
|
||||
.filter(_.partial(@_isUserFacing, allInInbox))
|
||||
.filter(_.partial(@_isInSearch, searchValue))
|
||||
.map(_.partial(@_extendCategoryWithUsage, usageCount))
|
||||
.map(_.partial(@_extendCategoryWithDisplayData, displayData))
|
||||
.value()
|
||||
|
||||
return {categoryData, searchValue}
|
||||
|
@ -157,34 +247,32 @@ class CategoryPicker extends React.Component
|
|||
return categoryUsageCount
|
||||
|
||||
_isInSearch: (searchValue, category) ->
|
||||
searchTerm = searchValue.trim().toLowerCase()
|
||||
return true if searchTerm.length is 0
|
||||
return Utils.wordSearchRegExp(searchValue).test(category.displayName)
|
||||
|
||||
catName = category.displayName.trim().toLowerCase()
|
||||
_isUserFacing: (allInInbox, category) =>
|
||||
hiddenCategories = []
|
||||
currentCategoryId = FocusedCategoryStore.categoryId()
|
||||
if @_namespace?.usesLabels()
|
||||
hiddenCategories = ["all", "spam", "trash", "drafts", "sent"]
|
||||
if allInInbox
|
||||
hiddenCategories.push("inbox")
|
||||
return false if category.divider
|
||||
else if @_namespace?.usesFolders()
|
||||
hiddenCategories = ["drafts", "sent"]
|
||||
|
||||
wordIndices = []
|
||||
# Where a non-word character is followed by a word character
|
||||
# We don't use \b (word boundary) because we want to split on slashes
|
||||
# and dashes and other non word things
|
||||
re = /\W[\w\d]/g
|
||||
while match = re.exec(catName) then wordIndices.push(match.index)
|
||||
# To shift to the start of each word.
|
||||
wordIndices = wordIndices.map (i) -> i += 1
|
||||
return (category.name not in hiddenCategories) and (category.id isnt currentCategoryId)
|
||||
|
||||
# Always include the start
|
||||
wordIndices.push(0)
|
||||
_allInInbox: (usageCount, numThreads) ->
|
||||
inbox = CategoryStore.getStandardCategory("inbox")
|
||||
return usageCount[inbox.id] is numThreads
|
||||
|
||||
return catName.indexOf(searchTerm) in wordIndices
|
||||
|
||||
_isUserFacing: (category) =>
|
||||
hiddenCategories = ["inbox", "all", "archive", "drafts", "sent"]
|
||||
return category.name not in hiddenCategories
|
||||
|
||||
_extendCategoryWithUsage: (usageCount, category, i, categories) ->
|
||||
_extendCategoryWithDisplayData: ({usageCount, numThreads}, category) ->
|
||||
return category if category.divider
|
||||
cat = category.toJSON()
|
||||
usage = usageCount[cat.id] ? 0
|
||||
cat.backgroundColor = LabelColorizer.backgroundColorDark(category)
|
||||
cat.usage = usage
|
||||
cat.totalUsage = categories.length
|
||||
cat.numThreads = numThreads
|
||||
return cat
|
||||
|
||||
_threadCategories: (thread) =>
|
||||
|
@ -195,7 +283,7 @@ class CategoryPicker extends React.Component
|
|||
else throw new Error("Invalid organizationUnit")
|
||||
|
||||
_threads: (props=@props) =>
|
||||
if props.selection then return (props.selection.items() ? [])
|
||||
if props.items then return (props.items ? [])
|
||||
else if props.thread then return [props.thread]
|
||||
else return []
|
||||
|
||||
|
|
|
@ -1,4 +1,72 @@
|
|||
@import "ui-variables";
|
||||
|
||||
.category-picker {
|
||||
.popover {
|
||||
}
|
||||
|
||||
.menu {
|
||||
|
||||
background: @background-secondary;
|
||||
|
||||
.header-container {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.item.selected:hover {
|
||||
background-color: @accent-primary;
|
||||
color: @text-color-inverse;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
.primary, .secondary {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.item.divider {
|
||||
background-color: #e0e0e0;
|
||||
margin: 4px 0;
|
||||
height: 2px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.btn.btn-toolbar {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.check-wrap {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
img.content-mask {
|
||||
background-color: @text-color-subtle;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
.item.selected img.content-mask {
|
||||
background-color: @text-color-inverse;
|
||||
}
|
||||
|
||||
img.check-img {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.category-display-name {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ React = require 'react'
|
|||
|
||||
class ThreadArchiveButton extends React.Component
|
||||
@displayName: "ThreadArchiveButton"
|
||||
@containerRequired: false
|
||||
|
||||
render: =>
|
||||
<button className="btn btn-toolbar btn-archive"
|
||||
|
|
|
@ -5,6 +5,7 @@ React = require 'react'
|
|||
|
||||
class StarButton extends React.Component
|
||||
@displayName: "StarButton"
|
||||
@containerRequired: false
|
||||
@propTypes:
|
||||
thread: React.PropTypes.object
|
||||
|
||||
|
|
|
@ -1,150 +0,0 @@
|
|||
_ = require 'underscore'
|
||||
React = require 'react'
|
||||
Reflux = require 'reflux'
|
||||
classNames = require 'classnames'
|
||||
{Actions,
|
||||
Utils,
|
||||
FocusedContentStore,
|
||||
FocusedContentStore,
|
||||
DatabaseStore,
|
||||
Tag,
|
||||
Thread,
|
||||
TaskQueue} = require 'nylas-exports'
|
||||
{RetinaImg,
|
||||
Popover,
|
||||
Menu} = require 'nylas-component-kit'
|
||||
|
||||
TagsStore = Reflux.createStore
|
||||
init: ->
|
||||
@_setStoreDefaults()
|
||||
@_registerListeners()
|
||||
@_fetch()
|
||||
|
||||
items: ->
|
||||
@_items
|
||||
|
||||
_setStoreDefaults: ->
|
||||
@_items = []
|
||||
|
||||
_registerListeners: ->
|
||||
@listenTo DatabaseStore, @_onDataChanged
|
||||
|
||||
_onDataChanged: (change) ->
|
||||
if change and change.objectClass is Tag.name
|
||||
@_fetch()
|
||||
|
||||
_fetch: ->
|
||||
DatabaseStore.findAll(Tag).then (tags) =>
|
||||
@_items = tags
|
||||
@trigger()
|
||||
|
||||
|
||||
# Note
|
||||
class ThreadTagsButton extends React.Component
|
||||
@displayName: 'ThreadTagsButton'
|
||||
|
||||
constructor: (@props) ->
|
||||
@state = @_getStateForSearch('')
|
||||
@
|
||||
|
||||
componentDidMount: ->
|
||||
@unsubscribers = []
|
||||
@unsubscribers.push TagsStore.listen @_onStoreChange
|
||||
@unsubscribers.push FocusedContentStore.listen @_onFocusChange
|
||||
|
||||
componentWillUnmount: =>
|
||||
return unless @unsubscribers
|
||||
unsubscribe() for unsubscribe in @unsubscribers
|
||||
|
||||
render: =>
|
||||
button = <button className="btn btn-toolbar">
|
||||
<RetinaImg name="toolbar-tags.png" mode={RetinaImg.Mode.ContentIsMask}/>
|
||||
<RetinaImg name="toolbar-chevron.png" mode={RetinaImg.Mode.ContentIsMask}/>
|
||||
</button>
|
||||
|
||||
headerComponents = [
|
||||
<input type="text"
|
||||
tabIndex="1"
|
||||
key="textfield"
|
||||
className="search"
|
||||
value={@state.searchValue}
|
||||
onChange={@_onSearchValueChange}/>
|
||||
]
|
||||
|
||||
<Popover className="tag-picker"
|
||||
direction="down"
|
||||
onOpened={@_onShowTags}
|
||||
buttonComponent={button}>
|
||||
<Menu ref="menu"
|
||||
headerComponents={headerComponents}
|
||||
footerComponents={[]}
|
||||
items={@state.tags}
|
||||
itemKey={ (item) -> item.id }
|
||||
itemContent={@_itemContent}
|
||||
itemChecked={@_itemChecked}
|
||||
onSelect={@_onToggleTag}
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
_itemContent: (tag) =>
|
||||
if tag.id is 'divider'
|
||||
<Menu.Item divider={tag.name} />
|
||||
else
|
||||
tag.name.charAt(0).toUpperCase() + tag.name.slice(1)
|
||||
|
||||
_itemChecked: (tag) =>
|
||||
return false unless @state.thread
|
||||
@state.thread.hasCategoryId(tag.id)
|
||||
|
||||
_onShowTags: =>
|
||||
# Always reset search state when the popover is shown
|
||||
if @state.searchValue.length > 0
|
||||
@setState @_getStateForSearch('')
|
||||
|
||||
_onToggleTag: (tag) =>
|
||||
return unless @state.thread
|
||||
|
||||
if @state.thread.hasCategoryId(tag.id)
|
||||
task = new AddRemoveTagsTask(@state.thread, [], [tag.id])
|
||||
else
|
||||
task = new AddRemoveTagsTask(@state.thread, [tag.id], [])
|
||||
|
||||
@refs.menu.setSelectedItem(null)
|
||||
|
||||
TaskQueue.enqueue(task)
|
||||
|
||||
_onFocusChange: (change) =>
|
||||
if change.impactsCollection('thread')
|
||||
@_onStoreChange()
|
||||
|
||||
_onStoreChange: =>
|
||||
@setState @_getStateForSearch(@state.searchValue)
|
||||
|
||||
_onSearchValueChange: (event) =>
|
||||
@setState @_getStateForSearch(event.target.value)
|
||||
|
||||
_getStateForSearch: (searchValue = '') =>
|
||||
searchTerm = searchValue.toLowerCase()
|
||||
thread = FocusedContentStore.focused('thread')
|
||||
return [] unless thread
|
||||
|
||||
tags = _.filter TagsStore.items(), (tag) -> tag.name.toLowerCase().indexOf(searchTerm) is 0
|
||||
|
||||
# Some tags are "magic" state and can't be added/removed
|
||||
tags = _.filter tags, (tag) -> not (tag.id in ['unseen', 'attachment', 'sending', 'drafts', 'sent'])
|
||||
|
||||
# Some tags are readonly
|
||||
tags = _.filter tags, (tag) -> not tag.readonly
|
||||
|
||||
# Organize tags into currently applied / not applied
|
||||
active = []
|
||||
inactive = []
|
||||
for tag in tags
|
||||
if thread.hasCategoryId(tag.id)
|
||||
active.push(tag)
|
||||
else
|
||||
inactive.push(tag)
|
||||
|
||||
{searchValue, thread, tags: [].concat(active, inactive)}
|
||||
|
||||
module.exports = ThreadTagsButton
|
|
@ -35,6 +35,7 @@ class Tooltip extends React.Component
|
|||
componentWillUnmount: =>
|
||||
clearTimeout @_showTimeout
|
||||
clearTimeout @_showDelayTimeout
|
||||
@_mutationObserver?.disconnect()
|
||||
|
||||
render: =>
|
||||
<div className="tooltip-wrap #{@state.pos}" style={@_positionStyles()}>
|
||||
|
@ -72,6 +73,11 @@ class Tooltip extends React.Component
|
|||
return target
|
||||
|
||||
_onTooltipEnter: (target) =>
|
||||
# https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
|
||||
@_mutationObserver?.disconnect()
|
||||
@_mutationObserver = new MutationObserver @_onTooltipLeave
|
||||
@_mutationObserver.observe(target.parentNode, attributes: true, subtree: true, childList: true)
|
||||
|
||||
@_lastTarget = target
|
||||
@_enteredTooltip = true
|
||||
clearTimeout(@_showTimeout)
|
||||
|
@ -82,6 +88,7 @@ class Tooltip extends React.Component
|
|||
|
||||
_onTooltipLeave: =>
|
||||
return unless @_enteredTooltip
|
||||
@_mutationObserver?.disconnect()
|
||||
@_enteredTooltip = false
|
||||
clearTimeout(@_showTimeout)
|
||||
@_hideTooltip()
|
||||
|
|
|
@ -206,13 +206,12 @@
|
|||
'tab': 'native!'
|
||||
'shift-tab': 'native!'
|
||||
|
||||
|
||||
|
||||
# For menus
|
||||
'body .menu, body .menu, body .menu input':
|
||||
'up': 'menu:move-up'
|
||||
'down': 'menu:move-down'
|
||||
'enter': 'menu:enter'
|
||||
# and by "native!" I actually mean for it to just let React deal with
|
||||
# it.
|
||||
'tab': 'native!'
|
||||
'shift-tab': 'native!'
|
||||
|
||||
# For Popover Component
|
||||
'body .popover-container, body .popover-container input':
|
||||
|
|
|
@ -2,19 +2,25 @@ React = require 'react'
|
|||
RetinaImg = require './retina-img'
|
||||
CategoryStore = require '../flux/stores/category-store'
|
||||
|
||||
LabelColorizer =
|
||||
|
||||
color: (label) -> "hsl(#{label.hue()}, 50%, 34%)"
|
||||
|
||||
backgroundColor: (label) -> "hsl(#{label.hue()}, 62%, 87%)"
|
||||
backgroundColorDark: (label) -> "hsl(#{label.hue()}, 62%, 57%)"
|
||||
|
||||
styles: (label) ->
|
||||
color: LabelColorizer.color(label)
|
||||
backgroundColor: LabelColorizer.backgroundColor(label)
|
||||
boxShadow: "inset 0 0 1px hsl(#{label.hue()}, 62%, 47%), inset 0 1px 1px rgba(255,255,255,0.5), 0 0.5px 0 rgba(255,255,255,0.5)"
|
||||
backgroundImage: 'linear-gradient(rgba(255,255,255, 0.4), rgba(255,255,255,0))'
|
||||
|
||||
class MailLabel extends React.Component
|
||||
@propTypes:
|
||||
label: React.PropTypes.object.isRequired
|
||||
onRemove: React.PropTypes.function
|
||||
|
||||
render: ->
|
||||
hue = @props.label.hue()
|
||||
style =
|
||||
backgroundColor: "hsl(#{hue}, 62%, 87%)"
|
||||
color: "hsl(#{hue}, 50%, 34%)"
|
||||
boxShadow: "inset 0 0 1px hsl(#{hue}, 62%, 47%), inset 0 1px 1px rgba(255,255,255,0.5), 0 0.5px 0 rgba(255,255,255,0.5)"
|
||||
backgroundImage: 'linear-gradient(rgba(255,255,255, 0.4), rgba(255,255,255,0))'
|
||||
|
||||
classname = 'mail-label'
|
||||
content = @props.label.displayName
|
||||
|
||||
|
@ -25,14 +31,14 @@ class MailLabel extends React.Component
|
|||
x = <RetinaImg
|
||||
className="x"
|
||||
name="label-x.png"
|
||||
style={backgroundColor: "hsl(#{hue}, 50%, 34%)"}
|
||||
style={backgroundColor: LabelColorizer.color(@props.label)}
|
||||
mode={RetinaImg.Mode.ContentIsMask}
|
||||
onClick={@props.onRemove}/>
|
||||
|
||||
<div className={classname} style={style}>{content}{x}</div>
|
||||
<div className={classname} style={LabelColorizer.styles(@props.label)}>{content}{x}</div>
|
||||
|
||||
_removable: ->
|
||||
isLockedLabel = @props.label.name in CategoryStore.LockedCategoryNames
|
||||
return @props.onRemove and not isLockedLabel
|
||||
|
||||
module.exports = MailLabel
|
||||
module.exports = {MailLabel, LabelColorizer}
|
||||
|
|
|
@ -126,6 +126,10 @@ class Menu extends React.Component
|
|||
- `onSelect` A {Function} called with the selected item when the user clicks
|
||||
an item in the menu or confirms their selection with the Enter key.
|
||||
|
||||
- `defaultSelectedIndex` The index of the item first selected if there
|
||||
was no other previous index. Defaults to 0. Set to -1 if you want
|
||||
nothing selected.
|
||||
|
||||
###
|
||||
@propTypes:
|
||||
className: React.PropTypes.string,
|
||||
|
@ -139,9 +143,11 @@ class Menu extends React.Component
|
|||
|
||||
onSelect: React.PropTypes.func.isRequired,
|
||||
|
||||
defaultSelectedIndex: React.PropTypes.number
|
||||
|
||||
constructor: (@props) ->
|
||||
@state =
|
||||
selectedIndex: 0
|
||||
selectedIndex: @props.defaultSelectedIndex ? 0
|
||||
|
||||
# Public: Returns the currently selected item.
|
||||
#
|
||||
|
@ -152,16 +158,7 @@ class Menu extends React.Component
|
|||
# null to remove the selection
|
||||
#
|
||||
setSelectedItem: (item) =>
|
||||
@setState
|
||||
selectedIndex: @props.items.indexOf(item)
|
||||
|
||||
componentDidMount: =>
|
||||
@subscriptions = new CompositeDisposable()
|
||||
@subscriptions.add atom.commands.add '.menu', {
|
||||
'menu:move-up': => @_onShiftSelectedIndex(-1)
|
||||
'menu:move-down': => @_onShiftSelectedIndex(1)
|
||||
'menu:enter': => @_onEnter()
|
||||
}
|
||||
@setState selectedIndex: @props.items.indexOf(item)
|
||||
|
||||
componentWillReceiveProps: (newProps) =>
|
||||
# Attempt to preserve selection across props.items changes by
|
||||
|
@ -171,7 +168,7 @@ class Menu extends React.Component
|
|||
selection = @props.items[@state.selectedIndex]
|
||||
newSelectionIndex = 0
|
||||
else
|
||||
newSelectionIndex = -1
|
||||
newSelectionIndex = newProps.defaultSelectedIndex ? -1
|
||||
|
||||
if selection?
|
||||
selectionKey = @props.itemKey(selection)
|
||||
|
@ -188,15 +185,14 @@ class Menu extends React.Component
|
|||
if adjustment isnt 0
|
||||
container.scrollTop += adjustment
|
||||
|
||||
componentWillUnmount: =>
|
||||
@subscriptions?.dispose()
|
||||
|
||||
render: =>
|
||||
hc = @props.headerComponents ? []
|
||||
if hc.length is 0 then hc = <span></span>
|
||||
fc = @props.footerComponents ? []
|
||||
if fc.length is 0 then fc = <span></span>
|
||||
<div className={"menu " + @props.className} tabIndex="-1">
|
||||
<div onKeyDown={@_onKeyDown}
|
||||
className={"native-key-bindings menu " + @props.className}
|
||||
tabIndex="-1">
|
||||
<div className="header-container">
|
||||
{hc}
|
||||
</div>
|
||||
|
@ -206,6 +202,18 @@ class Menu extends React.Component
|
|||
</div>
|
||||
</div>
|
||||
|
||||
_onKeyDown: (event) =>
|
||||
if event.key is "Enter"
|
||||
@_onEnter()
|
||||
else if event.key is "ArrowUp" or (event.key is "Tab" and event.shiftKey)
|
||||
@_onShiftSelectedIndex(-1)
|
||||
event.preventDefault()
|
||||
else if event.key is "ArrowDown" or event.key is "Tab"
|
||||
@_onShiftSelectedIndex(1)
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
|
||||
_contentContainer: =>
|
||||
items = @props.items.map(@_itemComponentForItem) ? []
|
||||
contentClass = classNames
|
||||
|
@ -234,18 +242,22 @@ class Menu extends React.Component
|
|||
|
||||
_onShiftSelectedIndex: (delta) =>
|
||||
return if @props.items.length is 0
|
||||
|
||||
index = @state.selectedIndex + delta
|
||||
|
||||
isDivider = true
|
||||
while isDivider
|
||||
item = @props.items[index]
|
||||
break unless item
|
||||
if @props.itemContent(item).props?.divider
|
||||
if delta > 0 then index += 1
|
||||
else if delta < 0 then index -= 1
|
||||
else isDivider = false
|
||||
|
||||
index = Math.max(0, Math.min(@props.items.length-1, index))
|
||||
|
||||
# Update the selected index
|
||||
@setState
|
||||
selectedIndex: index
|
||||
|
||||
# Fire the shift method again to move selection past the divider
|
||||
# if the new selected item is a divider.
|
||||
itemContent = @props.itemContent(@props.items[index])
|
||||
isDivider = itemContent.props?.divider
|
||||
@_onShiftSelectedIndex(delta) if isDivider
|
||||
@setState selectedIndex: index
|
||||
|
||||
_onEnter: =>
|
||||
item = @props.items[@state.selectedIndex]
|
||||
|
|
|
@ -2,7 +2,8 @@ React = require "react/addons"
|
|||
classNames = require 'classnames'
|
||||
_ = require 'underscore'
|
||||
|
||||
{Actions,
|
||||
{Utils,
|
||||
Actions,
|
||||
WorkspaceStore} = require "nylas-exports"
|
||||
InjectedComponentSet = require './injected-component-set'
|
||||
RetinaImg = require './retina-img'
|
||||
|
@ -75,10 +76,8 @@ class MultiselectActionBar extends React.Component
|
|||
@unsubscribers.push WorkspaceStore.listen @_onChange
|
||||
|
||||
shouldComponentUpdate: (nextProps, nextState) =>
|
||||
@props.collection isnt nextProps.collection or
|
||||
@state.count isnt nextState.count or
|
||||
@state.view isnt nextState.view or
|
||||
@state.type isnt nextState.type
|
||||
not Utils.isEqualReact(nextProps, @props) or
|
||||
not Utils.isEqualReact(nextState, @state)
|
||||
|
||||
render: =>
|
||||
<div className={@_classSet()}><div className="absolute"><div className="inner">
|
||||
|
@ -98,27 +97,30 @@ class MultiselectActionBar extends React.Component
|
|||
_renderActions: =>
|
||||
return <div></div> unless @state.view
|
||||
<InjectedComponentSet matching={role:"#{@props.collection}:BulkAction"}
|
||||
exposedProps={selection: @state.view.selection} />
|
||||
exposedProps={selection: @state.view.selection, items: @state.items} />
|
||||
|
||||
_label: =>
|
||||
if @state.count > 1
|
||||
"#{@state.count} #{@props.collection}s selected"
|
||||
else if @state.count is 1
|
||||
"#{@state.count} #{@props.collection} selected"
|
||||
if @state.items.length > 1
|
||||
"#{@state.items.length} #{@props.collection}s selected"
|
||||
else if @state.items.length is 1
|
||||
"#{@state.items.length} #{@props.collection} selected"
|
||||
else
|
||||
""
|
||||
|
||||
_classSet: =>
|
||||
classNames
|
||||
"selection-bar": true
|
||||
"enabled": @state.count > 0
|
||||
"enabled": @state.items.length > 0
|
||||
|
||||
_getStateFromStores: (props) =>
|
||||
props ?= @props
|
||||
view = props.dataStore.view()
|
||||
items = view?.selection.items() ? []
|
||||
|
||||
view: view
|
||||
count: view?.selection.items().length
|
||||
return {
|
||||
view: view
|
||||
items: items
|
||||
}
|
||||
|
||||
_onChange: =>
|
||||
@setState(@_getStateFromStores())
|
||||
|
|
|
@ -87,12 +87,12 @@ class Popover extends React.Component
|
|||
@_focusOnOpen = true
|
||||
@setState
|
||||
showing: true
|
||||
if @props.onOpened?
|
||||
@props.onOpened()
|
||||
@props.onOpened?()
|
||||
|
||||
close: =>
|
||||
@setState
|
||||
showing: false
|
||||
@props.onClosed?()
|
||||
|
||||
_focusImportantElement: =>
|
||||
# Automatically focus the element inside us with the lowest tab index
|
||||
|
@ -143,6 +143,14 @@ class Popover extends React.Component
|
|||
'top': -10
|
||||
'left':-12
|
||||
|
||||
if @props.direction is "down-align-left"
|
||||
popoverStyle = _.extend popoverStyle,
|
||||
'transform': 'translate(0, 2px)'
|
||||
'top': '100%'
|
||||
'left': 0
|
||||
pointerStyle = _.extend pointerStyle,
|
||||
'display': 'none'
|
||||
|
||||
popoverComponent = <div ref="popover" className={"popover popover-"+@props.direction} style={popoverStyle}>
|
||||
{@props.children}
|
||||
<div className="popover-pointer" style={pointerStyle}></div>
|
||||
|
|
|
@ -11,6 +11,17 @@ Utils =
|
|||
isHash: (object) ->
|
||||
_.isObject(object) and not _.isFunction(object) and not _.isArray(object)
|
||||
|
||||
escapeRegExp: (str) ->
|
||||
str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&")
|
||||
|
||||
# Generates a new RegExp that is great for basic search fields. It
|
||||
# checks if the test string is at the start of words
|
||||
#
|
||||
# See regex explanation and test here:
|
||||
# https://regex101.com/r/zG7aW4/2
|
||||
wordSearchRegExp: (str="") ->
|
||||
new RegExp("((?:^|\\W|$)#{Utils.escapeRegExp(str.trim())})", "ig")
|
||||
|
||||
# Takes an optional customizer. The customizer is passed the key and the
|
||||
# new cloned value for that key. The customizer is expected to either
|
||||
# modify the value and return it or simply be the identity function.
|
||||
|
|
|
@ -14,8 +14,7 @@ class ModelViewSelection
|
|||
ids: ->
|
||||
_.pluck(@_items, 'id')
|
||||
|
||||
items: ->
|
||||
@_items
|
||||
items: -> _.clone(@_items)
|
||||
|
||||
top: ->
|
||||
@_items[@_items.length - 1]
|
||||
|
|
|
@ -18,7 +18,7 @@ class ChangeLabelsTask extends ChangeCategoryTask
|
|||
constructor: ({@labelsToAdd, @labelsToRemove, @threadIds, @messageIds}={}) ->
|
||||
@threadIds ?= []; @messageIds ?= []
|
||||
@objectIds = @threadIds.concat(@messageIds)
|
||||
@_newLabels = []
|
||||
@_newLabels = {}
|
||||
super
|
||||
|
||||
label: -> "Applying labels…"
|
||||
|
@ -46,7 +46,7 @@ class ChangeLabelsTask extends ChangeCategoryTask
|
|||
return Promise.resolve(Task.Status.Finished)
|
||||
|
||||
requestBody: (id) ->
|
||||
labels: @_newLabels.map (l) -> l.id
|
||||
labels: @_newLabels[id].map (l) -> l.id
|
||||
|
||||
createUndoTask: ->
|
||||
labelsToAdd = @labelsToRemove
|
||||
|
@ -59,7 +59,7 @@ class ChangeLabelsTask extends ChangeCategoryTask
|
|||
# Called from super-class's `performLocal`
|
||||
localUpdateThread: (thread, categories) ->
|
||||
newLabels = @_newLabelSet(thread, categories)
|
||||
@_newLabels = newLabels
|
||||
@_newLabels[thread.id] = newLabels
|
||||
|
||||
messageQuery = DatabaseStore.findAll(Message, threadId: thread.id)
|
||||
childSavePromise = messageQuery.then (messages) ->
|
||||
|
@ -79,7 +79,7 @@ class ChangeLabelsTask extends ChangeCategoryTask
|
|||
# Called from super-class's `performLocal`
|
||||
localUpdateMessage: (message, categories) ->
|
||||
message.labels = @_newLabelSet(message, categories)
|
||||
@_newLabels = message.labels
|
||||
@_newLabels[message.id] = message.labels
|
||||
return DatabaseStore.persistModel(message)
|
||||
|
||||
# Returns a new set of {Label} objects that incoprates the existing,
|
||||
|
|
|
@ -84,6 +84,11 @@ class Toolbar extends React.Component
|
|||
data: React.PropTypes.object
|
||||
depth: React.PropTypes.number
|
||||
|
||||
@childContextTypes:
|
||||
sheetDepth: React.PropTypes.number
|
||||
getChildContext: =>
|
||||
sheetDepth: @props.depth
|
||||
|
||||
constructor: (@props) ->
|
||||
@state = @_getStateFromStores()
|
||||
|
||||
|
|
|
@ -16,6 +16,11 @@ class Sheet extends React.Component
|
|||
depth: React.PropTypes.number.isRequired
|
||||
onColumnSizeChanged: React.PropTypes.func
|
||||
|
||||
@childContextTypes:
|
||||
sheetDepth: React.PropTypes.number
|
||||
getChildContext: =>
|
||||
sheetDepth: @props.depth
|
||||
|
||||
constructor: (@props) ->
|
||||
@state = @_getStateFromStores()
|
||||
|
||||
|
|
|
@ -121,7 +121,6 @@ class WindowEventHandler
|
|||
|
||||
onKeydown: (event) ->
|
||||
atom.keymaps.handleKeyboardEvent(event)
|
||||
event.stopImmediatePropagation()
|
||||
|
||||
# Important: even though we don't do anything here, we need to catch the
|
||||
# drop event to prevent the browser from navigating the to the "url" of the
|
||||
|
|
|
@ -1,28 +1,39 @@
|
|||
@import "ui-variables";
|
||||
|
||||
.menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.header-container {
|
||||
flex-shrink: 0; // Don't squish the header! There may be a search box here
|
||||
background-color: @background-color-secondary;
|
||||
border-bottom:1px solid @base-border-color;
|
||||
padding:8px;
|
||||
border-bottom: 1px solid @base-border-color;
|
||||
padding: 3px;
|
||||
position: relative;
|
||||
input.search {
|
||||
border:1px solid darken(@background-color-secondary, 10%);
|
||||
border: 1px solid darken(@background-color-secondary, 10%);
|
||||
border-radius: 3px;
|
||||
padding-left: 0;
|
||||
background-color: white;
|
||||
border-radius:12px;
|
||||
padding-left: 10px;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url("../static/images/search/searchloupe@2x.png");
|
||||
background-size: 15px 15px;
|
||||
background-position: 7px 4px;
|
||||
text-indent: 31px;
|
||||
box-shadow: inset 0 1px 0 rgba(0,0,0,0.05), 0 1px 0 rgba(0,0,0,0.05)
|
||||
}
|
||||
}
|
||||
|
||||
.footer-container {
|
||||
position: relative;
|
||||
border-top:1px solid @base-border-color;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
background: @background-color-secondary;
|
||||
width: 100%;
|
||||
margin-top: -1px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
|
@ -30,7 +41,7 @@
|
|||
padding-left: @padding-base-horizontal;
|
||||
padding-top: @padding-base-vertical;
|
||||
padding-bottom: @padding-base-vertical;
|
||||
cursor: pointer;
|
||||
cursor: default;
|
||||
color: @text-color;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
@ -74,7 +85,7 @@
|
|||
background-image:url(./images/menu/checked-selected@2x.png);
|
||||
}
|
||||
|
||||
.item.selected, .item:hover {
|
||||
.item.selected, .item:hover, .item:active {
|
||||
text-decoration: none;
|
||||
background-color: @accent-primary;
|
||||
color: @text-color-inverse;
|
||||
|
|
|
@ -6,11 +6,14 @@
|
|||
}
|
||||
|
||||
.popover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 250px;
|
||||
max-height:400px;
|
||||
background-color: @background-color;
|
||||
border-radius: @border-radius-base;
|
||||
box-shadow: 0 4px 30px rgba(0,0,0,0.19), inset 0 0 1px rgba(0,0,0,0.5);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0.5px 0 rgba(0, 0, 0, 0.15), 0 -0.5px 0 rgba(0, 0, 0, 0.15), 0.5px 0 0 rgba(0, 0, 0, 0.15), -0.5px 0 0 rgba(0, 0, 0, 0.15), 0 4px 7px rgba(0,0,0,0.15), inset 0 0 1px rgba(0,0,0,0.5);
|
||||
|
||||
.menu {
|
||||
z-index:1;
|
||||
|
|
BIN
static/images/labels/tagging-checkbox@2x.png
Normal file
BIN
static/images/labels/tagging-checkbox@2x.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 1.2 KiB |
BIN
static/images/labels/tagging-checkmark@2x.png
Normal file
BIN
static/images/labels/tagging-checkmark@2x.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 1.5 KiB |
BIN
static/images/labels/tagging-conflicted@2x.png
Normal file
BIN
static/images/labels/tagging-conflicted@2x.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 1.2 KiB |
BIN
static/images/search/searchloupe@2x.png
Normal file
BIN
static/images/search/searchloupe@2x.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 1 KiB |
BIN
static/images/toolbar/ic-toolbar-movetofolder@2x.png
Normal file
BIN
static/images/toolbar/ic-toolbar-movetofolder@2x.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 14 KiB |
BIN
static/images/toolbar/ic-toolbar-tag@2x.png
Normal file
BIN
static/images/toolbar/ic-toolbar-tag@2x.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 15 KiB |
Binary file not shown.
Before ![]() (image error) Size: 1.5 KiB After ![]() (image error) Size: 1.4 KiB ![]() ![]() |
Binary file not shown.
Before ![]() (image error) Size: 1.7 KiB After ![]() (image error) Size: 1.7 KiB ![]() ![]() |
Binary file not shown.
Before ![]() (image error) Size: 1.1 KiB |
Loading…
Reference in a new issue