mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-20 07:16:08 +08:00
WIP
Remove controlled focus in the composer, refactor ComposerView apart—ComposerHeader handles all top fields, DraftSessionContainer handles draft crap. Assert that somehow (we'll figure it out) the composerView will /always/ get a draft and session, to fix zillions of awful "return unless" statements.
This commit is contained in:
parent
0f8725a747
commit
ce7dcaa337
|
@ -62,6 +62,7 @@ class CategoryPicker extends React.Component
|
|||
return (
|
||||
<KeyCommandsRegion style={order: -103} globalHandlers={@_keymapHandlers()}>
|
||||
<button
|
||||
tabIndex={-1}
|
||||
ref="button"
|
||||
title={tooltip}
|
||||
onClick={@_onOpenCategoryPopover}
|
||||
|
|
|
@ -22,7 +22,7 @@ class EmojiButton extends React.Component {
|
|||
|
||||
render() {
|
||||
return (
|
||||
<button className="btn btn-toolbar" title="Insert emoji…" onClick={this.onClick}>
|
||||
<button tabIndex={-1} className="btn btn-toolbar" title="Insert emoji…" onClick={this.onClick}>
|
||||
<RetinaImg name="icon-composer-emoji.png" mode={RetinaImg.Mode.ContentIsMask}/>
|
||||
</button>
|
||||
);
|
||||
|
|
|
@ -102,6 +102,7 @@ class TemplatePicker extends React.Component {
|
|||
render() {
|
||||
return (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className="btn btn-toolbar narrow pull-right"
|
||||
onClick={this._onClickButton}
|
||||
title="Insert email template…">
|
||||
|
|
|
@ -134,6 +134,7 @@ class TranslateButton extends React.Component {
|
|||
render() {
|
||||
return (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className="btn btn-toolbar pull-right"
|
||||
onClick={this._onClickTranslateButton}
|
||||
title="Translate email body…">
|
||||
|
|
|
@ -11,10 +11,6 @@ class CollapsedParticipants extends React.Component
|
|||
cc: React.PropTypes.array
|
||||
bcc: React.PropTypes.array
|
||||
|
||||
# Notifies parent when the component has been clicked. This is usually
|
||||
# used to expand the participants field
|
||||
onClick: React.PropTypes.func
|
||||
|
||||
@defaultProps:
|
||||
to: []
|
||||
cc: []
|
||||
|
@ -51,9 +47,10 @@ class CollapsedParticipants extends React.Component
|
|||
toDisplay = contacts.concat(bcc)
|
||||
toDisplay = toDisplay[0...@state.numToDisplay]
|
||||
if toDisplay.length is 0 then toDisplay = "Recipients"
|
||||
<div onClick={ => @props.onClick?()}
|
||||
ref="participantsWrap"
|
||||
className="collapsed-composer-participants">
|
||||
<div
|
||||
tabIndex={0}
|
||||
ref="participantsWrap"
|
||||
className="collapsed-composer-participants">
|
||||
{@_renderNumRemaining()}
|
||||
{toDisplay}
|
||||
</div>
|
||||
|
|
|
@ -88,10 +88,14 @@ class ComposerEditor extends Component {
|
|||
|
||||
class ComposerFocusManager extends ContenteditableExtension {
|
||||
static onFocus() {
|
||||
return props.onFocus();
|
||||
if (props.onFocus) {
|
||||
return props.onFocus();
|
||||
}
|
||||
}
|
||||
static onBlur() {
|
||||
return props.onBlur();
|
||||
if (props.onBlur) {
|
||||
return props.onBlur();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,30 +9,30 @@ class ComposerHeaderActions extends React.Component
|
|||
|
||||
@propTypes:
|
||||
draftClientId: React.PropTypes.string.isRequired
|
||||
focusedField: React.PropTypes.string
|
||||
enabledFields: React.PropTypes.array.isRequired
|
||||
onAdjustEnabledFields: React.PropTypes.func.isRequired
|
||||
participantsFocused: React.PropTypes.bool
|
||||
onShowField: React.PropTypes.func.isRequired
|
||||
|
||||
render: =>
|
||||
items = []
|
||||
|
||||
if @props.focusedField in Fields.ParticipantFields
|
||||
if @props.participantsFocused
|
||||
if Fields.Cc not in @props.enabledFields
|
||||
items.push(
|
||||
<span className="action show-cc" key="cc"
|
||||
onClick={ => @props.onAdjustEnabledFields(show: [Fields.Cc]) }>Cc</span>
|
||||
onClick={ => @props.onShowField(Fields.Cc) }>Cc</span>
|
||||
)
|
||||
|
||||
if Fields.Bcc not in @props.enabledFields
|
||||
items.push(
|
||||
<span className="action show-bcc" key="bcc"
|
||||
onClick={ => @props.onAdjustEnabledFields(show: [Fields.Bcc]) }>Bcc</span>
|
||||
onClick={ => @props.onShowField(Fields.Bcc) }>Bcc</span>
|
||||
)
|
||||
|
||||
if Fields.Subject not in @props.enabledFields
|
||||
items.push(
|
||||
<span className="action show-subject" key="subject"
|
||||
onClick={ => @props.onAdjustEnabledFields(show: [Fields.Subject]) }>Subject</span>
|
||||
onClick={ => @props.onShowField(Fields.Subject) }>Subject</span>
|
||||
)
|
||||
|
||||
unless NylasEnv.isComposerWindow()
|
||||
|
|
219
internal_packages/composer/lib/composer-header.cjsx
Normal file
219
internal_packages/composer/lib/composer-header.cjsx
Normal file
|
@ -0,0 +1,219 @@
|
|||
_ = require 'underscore'
|
||||
React = require 'react'
|
||||
AccountContactField = require './account-contact-field'
|
||||
ParticipantsTextField = require './participants-text-field'
|
||||
{Actions, AccountStore} = require 'nylas-exports'
|
||||
{RetinaImg, FluxContainer, KeyCommandsRegion} = require 'nylas-component-kit'
|
||||
|
||||
CollapsedParticipants = require './collapsed-participants'
|
||||
ComposerHeaderActions = require './composer-header-actions'
|
||||
|
||||
Fields = require './fields'
|
||||
|
||||
class ComposerHeader extends React.Component
|
||||
@displayName: "ComposerHeader"
|
||||
|
||||
@propTypes:
|
||||
draft: React.PropTypes.object
|
||||
|
||||
# Callback for the participants change
|
||||
onChangeParticipants: React.PropTypes.func
|
||||
|
||||
constructor: (@props={}) ->
|
||||
@_renderCallCount = 0
|
||||
@state = {
|
||||
enabledFields: @_initiallyEnabledFields(@props.draft)
|
||||
}
|
||||
|
||||
componentWillReceiveProps: (nextProps) =>
|
||||
if @props.session isnt nextProps.session
|
||||
@setState(enabledFields: @_initiallyEnabledFields(nextProps.draft))
|
||||
else
|
||||
@_ensureFilledFieldsEnabled(nextProps.draft)
|
||||
|
||||
componentDidUpdate: =>
|
||||
@_renderCallCount += 1
|
||||
|
||||
afterRendering: (cb) =>
|
||||
desired = @_renderCallCount + 1
|
||||
attempt = =>
|
||||
return cb() if @_renderCallCount is desired
|
||||
window.requestAnimationFrame(attempt)
|
||||
attempt()
|
||||
|
||||
showField: (fieldName) =>
|
||||
enabledFields = _.uniq([].concat(@state.enabledFields, [fieldName]))
|
||||
@afterRendering =>
|
||||
@refs[fieldName].focus()
|
||||
@setState({enabledFields})
|
||||
|
||||
shiftFieldFocus: (fieldName, dir) =>
|
||||
sortedEnabledFields = @state.enabledFields.sort (a, b) =>
|
||||
Fields.Order[a] - Fields.Order[b]
|
||||
|
||||
i = sortedEnabledFields.indexOf(fieldName)
|
||||
next = null
|
||||
while (i > 0 && i < sortedEnabledFields.length - 1)
|
||||
i += dir
|
||||
next = @refs[sortedEnabledFields[i]]
|
||||
break if next
|
||||
|
||||
next.focus() if next
|
||||
|
||||
hideField: (fieldName) =>
|
||||
if React.findDOMNode(@refs[fieldName]).contains(document.activeElement)
|
||||
@shiftFieldFocus(fieldName, -1)
|
||||
enabledFields = _.without(@state.enabledFields, fieldName)
|
||||
@setState({enabledFields})
|
||||
|
||||
render: ->
|
||||
<div className="composer-header">
|
||||
<ComposerHeaderActions
|
||||
draftClientId={@props.draft.clientId}
|
||||
enabledFields={@state.enabledFields}
|
||||
participantsFocused={@state.participantsFocused}
|
||||
onShowField={@showField}
|
||||
/>
|
||||
{@_renderParticipants()}
|
||||
{@_renderSubject()}
|
||||
</div>
|
||||
|
||||
_renderParticipants: =>
|
||||
if @state.participantsFocused
|
||||
content = @_renderFields()
|
||||
else
|
||||
content = (
|
||||
<CollapsedParticipants
|
||||
to={@props.draft.to}
|
||||
cc={@props.draft.cc}
|
||||
bcc={@props.draft.bcc}
|
||||
/>
|
||||
)
|
||||
|
||||
<KeyCommandsRegion
|
||||
ref="participantsContainer"
|
||||
className="expanded-participants"
|
||||
onFocusIn={=>
|
||||
@afterRendering =>
|
||||
fieldName = @state.participantsLastActiveField || Fields.To
|
||||
@refs[fieldName].focus()
|
||||
@setState(participantsFocused: true, participantsLastActiveField: null)
|
||||
}
|
||||
onFocusOut={ (lastFocusedEl) =>
|
||||
active = Fields.ParticipantFields.find (fieldName) =>
|
||||
return false if not @refs[fieldName]
|
||||
return React.findDOMNode(@refs[fieldName]).contains(lastFocusedEl)
|
||||
@setState(participantsFocused: false, participantsLastActiveField: active)
|
||||
}>
|
||||
{content}
|
||||
</KeyCommandsRegion>
|
||||
|
||||
_renderSubject: =>
|
||||
return false unless Fields.Subject in @state.enabledFields
|
||||
|
||||
<div key="subject-wrap" className="compose-subject-wrap">
|
||||
<input type="text"
|
||||
name="subject"
|
||||
ref={Fields.Subject}
|
||||
placeholder="Subject"
|
||||
value={@state.subject}
|
||||
onChange={@_onChangeSubject}/>
|
||||
</div>
|
||||
|
||||
_renderFields: =>
|
||||
{to, cc, bcc, from} = @props.draft
|
||||
|
||||
# Note: We need to physically add and remove these elements, not just hide them.
|
||||
# If they're hidden, shift-tab between fields breaks.
|
||||
fields = []
|
||||
fields.push(
|
||||
<ParticipantsTextField
|
||||
ref={Fields.To}
|
||||
key="to"
|
||||
field='to'
|
||||
change={@_onChangeParticipants}
|
||||
className="composer-participant-field to-field"
|
||||
participants={{to, cc, bcc}} />
|
||||
)
|
||||
|
||||
if Fields.Cc in @state.enabledFields
|
||||
fields.push(
|
||||
<ParticipantsTextField
|
||||
ref={Fields.Cc}
|
||||
key="cc"
|
||||
field='cc'
|
||||
change={@_onChangeParticipants}
|
||||
onEmptied={ => @hideField(Fields.Cc) }
|
||||
className="composer-participant-field cc-field"
|
||||
participants={{to, cc, bcc}} />
|
||||
)
|
||||
|
||||
if Fields.Bcc in @state.enabledFields
|
||||
fields.push(
|
||||
<ParticipantsTextField
|
||||
ref={Fields.Bcc}
|
||||
key="bcc"
|
||||
field='bcc'
|
||||
change={@_onChangeParticipants}
|
||||
onEmptied={ => @hideField(Fields.Bcc) }
|
||||
className="composer-participant-field bcc-field"
|
||||
participants={{to, cc, bcc}} />
|
||||
)
|
||||
|
||||
if Fields.From in @state.enabledFields
|
||||
fields.push(
|
||||
<FluxContainer
|
||||
stores={[AccountStore]}
|
||||
getStateFromStores={ =>
|
||||
if @props.draft.threadId
|
||||
{accounts: [AccountStore.accountForId(@props.draft.accountId)]}
|
||||
else
|
||||
{accounts: AccountStore.accounts()}
|
||||
}>
|
||||
<AccountContactField
|
||||
key="from"
|
||||
ref={Fields.From}
|
||||
onChange={@_onChangeParticipants}
|
||||
accounts={@props.accounts}
|
||||
value={from[0]}
|
||||
/>
|
||||
</FluxContainer>
|
||||
)
|
||||
|
||||
fields
|
||||
|
||||
_ensureFilledFieldsEnabled: (draft) ->
|
||||
enabledFields = @state.enabledFields
|
||||
enabledFields = enabledFields.concat([Fields.Cc]) if not _.isEmpty(draft.cc)
|
||||
enabledFields = enabledFields.concat([Fields.Bcc]) if not _.isEmpty(draft.bcc)
|
||||
if enabledFields isnt @state.enabledFields
|
||||
@setState({enabledFields})
|
||||
|
||||
_initiallyEnabledFields: (draft) ->
|
||||
return [] unless draft
|
||||
enabledFields = [Fields.To]
|
||||
enabledFields.push Fields.Cc if not _.isEmpty(draft.cc)
|
||||
enabledFields.push Fields.Bcc if not _.isEmpty(draft.bcc)
|
||||
enabledFields.push Fields.From if @_shouldShowFromField(draft)
|
||||
enabledFields.push Fields.Subject if @_shouldEnableSubject()
|
||||
enabledFields.push Fields.Body
|
||||
return enabledFields
|
||||
|
||||
_shouldShowFromField: (draft) =>
|
||||
return true if draft
|
||||
return false
|
||||
|
||||
_shouldEnableSubject: =>
|
||||
if _.isEmpty(@props.draft.subject ? "") then return true
|
||||
else if @isForwardedMessage() then return true
|
||||
else if @props.draft.replyToMessageId then return false
|
||||
else return true
|
||||
|
||||
_onChangeParticipants: (changes) =>
|
||||
@props.session.changes.add(changes)
|
||||
Actions.draftParticipantsChanged(@props.draft.clientId, changes)
|
||||
|
||||
_onChangeSubject: (event) =>
|
||||
@props.session.changes.add(subject: event.target.value)
|
||||
|
||||
module.exports = ComposerHeader
|
|
@ -26,10 +26,8 @@ FileUpload = require './file-upload'
|
|||
ImageFileUpload = require './image-file-upload'
|
||||
|
||||
ComposerEditor = require './composer-editor'
|
||||
ComposerHeaderActions = require './composer-header-actions'
|
||||
SendActionButton = require './send-action-button'
|
||||
ExpandedParticipants = require './expanded-participants'
|
||||
CollapsedParticipants = require './collapsed-participants'
|
||||
ComposerHeader = require './composer-header'
|
||||
|
||||
Fields = require './fields'
|
||||
|
||||
|
@ -38,15 +36,11 @@ Fields = require './fields'
|
|||
# Composer with new props.
|
||||
class ComposerView extends React.Component
|
||||
@displayName: 'ComposerView'
|
||||
|
||||
@containerRequired: false
|
||||
|
||||
@propTypes:
|
||||
draftClientId: React.PropTypes.string
|
||||
|
||||
# If this composer is part of an existing thread (like inline
|
||||
# composers) the threadId will be handed down
|
||||
threadId: React.PropTypes.string
|
||||
session: React.PropTypes.object.isRequired
|
||||
draft: React.PropTypes.object.isRequired
|
||||
|
||||
# Sometimes when changes in the composer happens it's desirable to
|
||||
# have the parent scroll to a certain location. A parent component can
|
||||
|
@ -56,37 +50,10 @@ class ComposerView extends React.Component
|
|||
|
||||
constructor: (@props) ->
|
||||
@state =
|
||||
draftReady: false
|
||||
to: []
|
||||
cc: []
|
||||
bcc: []
|
||||
from: []
|
||||
body: ""
|
||||
files: []
|
||||
uploads: []
|
||||
subject: ""
|
||||
accounts: []
|
||||
focusedField: Fields.To # Gets updated in @_initiallyFocusedField
|
||||
enabledFields: [] # Gets updated in @_initiallyEnabledFields
|
||||
showQuotedText: false
|
||||
|
||||
componentWillMount: =>
|
||||
@_prepareForDraft(@props.draftClientId)
|
||||
|
||||
shouldComponentUpdate: (nextProps, nextState) =>
|
||||
not Utils.isEqualReact(nextProps, @props) or
|
||||
not Utils.isEqualReact(nextState, @state)
|
||||
|
||||
componentDidMount: =>
|
||||
@_usubs = []
|
||||
@_usubs.push AccountStore.listen @_onAccountStoreChanged
|
||||
@_applyFieldFocus()
|
||||
|
||||
componentWillUnmount: =>
|
||||
@_unmounted = true # rarf
|
||||
@_teardownForDraft()
|
||||
@_deleteDraftIfEmpty()
|
||||
usub() for usub in @_usubs
|
||||
@_receivedNewSession() if @props.session
|
||||
|
||||
componentDidUpdate: (prevProps, prevState) =>
|
||||
# We want to use a temporary variable instead of putting this into the
|
||||
|
@ -102,33 +69,14 @@ class ComposerView extends React.Component
|
|||
# the editor hasn't actually finished rendering, so we need to wait for that
|
||||
# to happen by using the InjectedComponent's `onComponentDidRender` callback.
|
||||
# See `_renderEditor`
|
||||
bodyChanged = @state.body isnt prevState.body
|
||||
bodyChanged = @props.body isnt prevProps.body
|
||||
return if bodyChanged
|
||||
@_applyFieldFocus()
|
||||
|
||||
focus: =>
|
||||
if not @state.focusedField
|
||||
@setState(focusedField: @_initiallyFocusedField(@_proxy.draft()))
|
||||
else
|
||||
@_applyFieldFocus()
|
||||
@focusFieldWithName(@_initiallyFocusedField())
|
||||
# TODO
|
||||
|
||||
_keymapHandlers: ->
|
||||
'composer:send-message': => @_onPrimarySend()
|
||||
'composer:delete-empty-draft': => @_deleteDraftIfEmpty()
|
||||
'composer:show-and-focus-bcc': =>
|
||||
@_onAdjustEnabledFields(show: [Fields.Bcc])
|
||||
'composer:show-and-focus-cc': =>
|
||||
@_onAdjustEnabledFields(show: [Fields.Cc])
|
||||
'composer:focus-to': =>
|
||||
@_onAdjustEnabledFields(show: [Fields.To])
|
||||
"composer:show-and-focus-from": => # TODO
|
||||
"composer:undo": @undo
|
||||
"composer:redo": @redo
|
||||
|
||||
_applyFieldFocus: =>
|
||||
@_applyFieldFocusTo(@state.focusedField)
|
||||
|
||||
_applyFieldFocusTo: (fieldName) =>
|
||||
focusFieldWithName: (fieldName) =>
|
||||
return unless @refs[fieldName]
|
||||
|
||||
$el = React.findDOMNode(@refs[fieldName])
|
||||
|
@ -139,75 +87,69 @@ class ComposerView extends React.Component
|
|||
$el.select()
|
||||
$el.focus()
|
||||
|
||||
_keymapHandlers: ->
|
||||
'composer:send-message': => @_onPrimarySend()
|
||||
'composer:delete-empty-draft': =>
|
||||
@_destroyDraft() if @props.draft.pristine
|
||||
'composer:show-and-focus-bcc': =>
|
||||
@refs.header.showField(Fields.Bcc)
|
||||
'composer:show-and-focus-cc': =>
|
||||
@refs.header.showField(Fields.Cc)
|
||||
'composer:focus-to': =>
|
||||
@refs.header.showField(Fields.To)
|
||||
"composer:show-and-focus-from": => # TODO
|
||||
"composer:undo": @undo
|
||||
"composer:redo": @redo
|
||||
|
||||
componentWillReceiveProps: (newProps) =>
|
||||
@_ignoreNextTrigger = false
|
||||
if newProps.draftClientId isnt @props.draftClientId
|
||||
# When we're given a new draft draftClientId, we have to stop listening to our
|
||||
# current DraftStoreProxy, create a new one and listen to that. The simplest
|
||||
# way to do this is to just re-call registerListeners.
|
||||
@_teardownForDraft()
|
||||
@_prepareForDraft(newProps.draftClientId)
|
||||
if newProps.session isnt @props.session
|
||||
@_receivedNewSession()
|
||||
|
||||
_prepareForDraft: (draftClientId) =>
|
||||
@unlisteners = []
|
||||
return unless draftClientId
|
||||
|
||||
# UndoManager must be ready before we call _onDraftChanged for the first time
|
||||
_receivedNewSession: =>
|
||||
@undoManager = new UndoManager
|
||||
DraftStore.sessionForClientId(draftClientId).then(@_setupSession)
|
||||
@_saveToHistory()
|
||||
|
||||
_setupSession: (proxy) =>
|
||||
return if @_unmounted
|
||||
return unless proxy.draftClientId is @props.draftClientId
|
||||
@_proxy = proxy
|
||||
@_preloadImages(@_proxy.draft()?.files)
|
||||
@unlisteners.push @_proxy.listen(@_onDraftChanged)
|
||||
@_onDraftChanged()
|
||||
@setState({
|
||||
showQuotedText: Utils.isForwardedMessage(@props.draft)
|
||||
})
|
||||
|
||||
_preloadImages: (files=[]) ->
|
||||
files.forEach (file) ->
|
||||
@props.draft.files.forEach (file) ->
|
||||
if Utils.shouldDisplayAsImage(file)
|
||||
Actions.fetchFile(file)
|
||||
|
||||
_teardownForDraft: =>
|
||||
unlisten() for unlisten in @unlisteners
|
||||
if @_proxy
|
||||
@_proxy.changes.commit()
|
||||
|
||||
render: ->
|
||||
classes = "message-item-white-wrap composer-outer-wrap #{@props.className ? ""}"
|
||||
dropCoverDisplay = if @state.isDropping then 'block' else 'none'
|
||||
|
||||
<KeyCommandsRegion
|
||||
localHandlers={@_keymapHandlers()}
|
||||
className={classes}
|
||||
onFocusIn={@_onFocusIn}
|
||||
className={"message-item-white-wrap composer-outer-wrap #{@props.className ? ""}"}
|
||||
tabIndex="-1"
|
||||
ref="composerWrap">
|
||||
{@_renderComposer()}
|
||||
<DropZone
|
||||
className="composer-inner-wrap"
|
||||
shouldAcceptDrop={@_shouldAcceptDrop}
|
||||
onDragStateChange={ ({isDropping}) => @setState({isDropping}) }
|
||||
onDrop={@_onDrop}>
|
||||
<div className="composer-drop-cover" style={display: dropCoverDisplay}>
|
||||
<div className="centered">
|
||||
<RetinaImg
|
||||
name="composer-drop-to-attach.png"
|
||||
mode={RetinaImg.Mode.ContentIsMask}/>
|
||||
Drop to attach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="composer-content-wrap">
|
||||
{@_renderContentScrollRegion()}
|
||||
</div>
|
||||
|
||||
<div className="composer-action-bar-wrap">
|
||||
{@_renderActionsRegion()}
|
||||
</div>
|
||||
</DropZone>
|
||||
</KeyCommandsRegion>
|
||||
|
||||
_renderComposer: =>
|
||||
<DropZone className="composer-inner-wrap"
|
||||
shouldAcceptDrop={@_shouldAcceptDrop}
|
||||
onDragStateChange={ ({isDropping}) => @setState({isDropping}) }
|
||||
onDrop={@_onDrop}>
|
||||
<div className="composer-drop-cover" style={display: if @state.isDropping then 'block' else 'none'}>
|
||||
<div className="centered">
|
||||
<RetinaImg name="composer-drop-to-attach.png" mode={RetinaImg.Mode.ContentIsMask}/>
|
||||
Drop to attach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="composer-content-wrap" onKeyDown={@_onKeyDown}>
|
||||
{@_renderScrollRegion()}
|
||||
|
||||
</div>
|
||||
<div className="composer-action-bar-wrap">
|
||||
{@_renderActionsRegion()}
|
||||
</div>
|
||||
</DropZone>
|
||||
|
||||
_renderScrollRegion: ->
|
||||
_renderContentScrollRegion: ->
|
||||
if NylasEnv.isComposerWindow()
|
||||
<ScrollRegion className="compose-body-scroll" ref="scrollregion">
|
||||
{@_renderContent()}
|
||||
|
@ -217,97 +159,21 @@ class ComposerView extends React.Component
|
|||
|
||||
_renderContent: =>
|
||||
<div className="composer-centered">
|
||||
<div className="composer-header">
|
||||
{if @state.draftReady
|
||||
<ComposerHeaderActions
|
||||
draftClientId={@props.draftClientId}
|
||||
focusedField={@state.focusedField}
|
||||
enabledFields={@state.enabledFields}
|
||||
onAdjustEnabledFields={@_onAdjustEnabledFields}
|
||||
/>
|
||||
}
|
||||
{if @state.focusedField in Fields.ParticipantFields
|
||||
<ExpandedParticipants
|
||||
to={@state.to} cc={@state.cc} bcc={@state.bcc}
|
||||
from={@state.from}
|
||||
ref="expandedParticipants"
|
||||
accounts={@state.accounts}
|
||||
draftReady={@state.draftReady}
|
||||
focusedField={@state.focusedField}
|
||||
enabledFields={@state.enabledFields}
|
||||
onPopoutComposer={@_onPopoutComposer}
|
||||
onChangeParticipants={@_onChangeParticipants}
|
||||
onChangeFocusedField={@_onChangeFocusedField}
|
||||
onAdjustEnabledFields={@_onAdjustEnabledFields} />
|
||||
else
|
||||
<CollapsedParticipants
|
||||
to={@state.to} cc={@state.cc} bcc={@state.bcc}
|
||||
onPopoutComposer={@_onPopoutComposer}
|
||||
onClick={@_onExpandParticipantFields} />
|
||||
}
|
||||
|
||||
{@_renderSubject()}
|
||||
</div>
|
||||
<div className="compose-body"
|
||||
ref="composeBody"
|
||||
onMouseUp={@_onMouseUpComposerBody}
|
||||
onMouseDown={@_onMouseDownComposerBody}>
|
||||
<ComposerHeader
|
||||
ref="header"
|
||||
draft={@props.draft}
|
||||
session={@props.session}
|
||||
/>
|
||||
<div
|
||||
className="compose-body"
|
||||
ref="composeBody"
|
||||
onMouseUp={@_onMouseUpComposerBody}
|
||||
onMouseDown={@_onMouseDownComposerBody}>
|
||||
{@_renderBodyRegions()}
|
||||
{@_renderFooterRegions()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
_onKeyDown: (event) =>
|
||||
if event.key is "Tab"
|
||||
@_onTabDown(event)
|
||||
return
|
||||
|
||||
_onTabDown: (event) =>
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@_onShiftFocusedField(if event.shiftKey then -1 else 1)
|
||||
|
||||
_onShiftFocusedField: (dir) =>
|
||||
enabledFields = _.filter @state.enabledFields, (field) ->
|
||||
Fields.Order[field] >= 0
|
||||
enabledFields = _.sortBy enabledFields, (field) ->
|
||||
Fields.Order[field]
|
||||
i = enabledFields.indexOf @state.focusedField
|
||||
newI = Math.min(Math.max(i + dir, 0), enabledFields.length - 1)
|
||||
@_onChangeFocusedField(enabledFields[newI])
|
||||
|
||||
_onChangeFocusedField: (focusedField) =>
|
||||
@setState({focusedField})
|
||||
if focusedField in Fields.ParticipantFields
|
||||
@_lastFocusedParticipantField = focusedField
|
||||
|
||||
_onExpandParticipantFields: =>
|
||||
@_onChangeFocusedField(@_lastFocusedParticipantField ? Fields.To)
|
||||
|
||||
_onAdjustEnabledFields: ({show, hide}={}) =>
|
||||
show = show ? []; hide = hide ? []
|
||||
enabledFields = _.difference(@state.enabledFields.concat(show), hide)
|
||||
|
||||
if hide.length > 0 and enabledFields.indexOf(@state.focusedField) is -1
|
||||
@_onShiftFocusedField(-1)
|
||||
|
||||
@setState({enabledFields})
|
||||
|
||||
if show.length > 0
|
||||
@_onChangeFocusedField(show[0])
|
||||
|
||||
_renderSubject: ->
|
||||
if Fields.Subject in @state.enabledFields
|
||||
<div key="subject-wrap" className="compose-subject-wrap">
|
||||
<input type="text"
|
||||
name="subject"
|
||||
ref={Fields.Subject}
|
||||
placeholder="Subject"
|
||||
value={@state.subject}
|
||||
onFocus={ => @setState(focusedField: Fields.Subject) }
|
||||
onChange={@_onChangeSubject}/>
|
||||
</div>
|
||||
|
||||
_renderBodyRegions: =>
|
||||
<span ref="composerBodyWrap">
|
||||
{@_renderEditor()}
|
||||
|
@ -317,15 +183,13 @@ class ComposerView extends React.Component
|
|||
|
||||
_renderEditor: ->
|
||||
exposedProps =
|
||||
body: @_removeQuotedText(@state.body)
|
||||
draftClientId: @props.draftClientId
|
||||
body: @_removeQuotedText(@props.draft.body)
|
||||
draftClientId: @props.draft.clientId
|
||||
parentActions: {
|
||||
getComposerBoundingRect: @_getComposerBoundingRect
|
||||
scrollTo: @props.scrollTo
|
||||
}
|
||||
initialSelectionSnapshot: @_recoveredSelection
|
||||
onFocus: => @setState(focusedField: Fields.Body)
|
||||
onBlur: => @setState(focusedField: null)
|
||||
onFilePaste: @_onFilePaste
|
||||
onBodyChanged: @_onBodyChanged
|
||||
|
||||
|
@ -347,9 +211,6 @@ class ComposerView extends React.Component
|
|||
]}
|
||||
exposedProps={exposedProps} />
|
||||
|
||||
_onEditorBodyDidRender: =>
|
||||
@_applyFieldFocus()
|
||||
|
||||
# The contenteditable decides when to request a scroll based on the
|
||||
# position of the cursor and its relative distance to this composer
|
||||
# component. We provide it our boundingClientRect so it can calculate
|
||||
|
@ -362,26 +223,27 @@ class ComposerView extends React.Component
|
|||
else return QuotedHTMLTransformer.removeQuotedHTML(html)
|
||||
|
||||
_showQuotedText: (html) =>
|
||||
if @state.showQuotedText then return html
|
||||
else return QuotedHTMLTransformer.appendQuotedHTML(html, @state.body)
|
||||
if @state.showQuotedText
|
||||
return html
|
||||
else
|
||||
return QuotedHTMLTransformer.appendQuotedHTML(html, @props.draft.body)
|
||||
|
||||
_renderQuotedTextControl: ->
|
||||
if QuotedHTMLTransformer.hasQuotedHTML(@state.body)
|
||||
if QuotedHTMLTransformer.hasQuotedHTML(@props.draft.body)
|
||||
<a className="quoted-text-control" onClick={@_onToggleQuotedText}>
|
||||
<span className="dots">•••</span>
|
||||
</a>
|
||||
else return []
|
||||
else
|
||||
[]
|
||||
|
||||
_onToggleQuotedText: =>
|
||||
@setState showQuotedText: not @state.showQuotedText
|
||||
|
||||
_renderFooterRegions: =>
|
||||
return <div></div> unless @props.draftClientId
|
||||
|
||||
<div className="composer-footer-region">
|
||||
<InjectedComponentSet
|
||||
matching={role: "Composer:Footer"}
|
||||
exposedProps={draftClientId:@props.draftClientId, threadId: @props.threadId}
|
||||
exposedProps={draftClientId: @props.draft.clientId, threadId: @props.draft.threadId}
|
||||
direction="column"/>
|
||||
</div>
|
||||
|
||||
|
@ -392,10 +254,10 @@ class ComposerView extends React.Component
|
|||
</div>
|
||||
|
||||
_renderFileAttachments: ->
|
||||
nonImageFiles = @_nonImageFiles(@state.files).map((file) =>
|
||||
nonImageFiles = @_nonImageFiles(@props.draft.files).map((file) =>
|
||||
@_renderFileAttachment(file, "Attachment")
|
||||
)
|
||||
imageFiles = @_imageFiles(@state.files).map((file) =>
|
||||
imageFiles = @_imageFiles(@props.draft.files).map((file) =>
|
||||
@_renderFileAttachment(file, "Attachment:Image")
|
||||
)
|
||||
nonImageFiles.concat(imageFiles)
|
||||
|
@ -405,7 +267,7 @@ class ComposerView extends React.Component
|
|||
file: file
|
||||
removable: true
|
||||
targetPath: FileDownloadStore.pathForFile(file)
|
||||
messageClientId: @props.draftClientId
|
||||
messageClientId: @props.draft.clientId
|
||||
|
||||
if role is "Attachment"
|
||||
className = "file-wrap"
|
||||
|
@ -418,10 +280,10 @@ class ComposerView extends React.Component
|
|||
exposedProps={props} />
|
||||
|
||||
_renderUploadAttachments: ->
|
||||
nonImageUploads = @_nonImageFiles(@state.uploads).map((upload) ->
|
||||
nonImageUploads = @_nonImageFiles(@props.draft.uploads).map((upload) ->
|
||||
<FileUpload key={upload.id} upload={upload} />
|
||||
)
|
||||
imageUploads = @_imageFiles(@state.uploads).map((upload) ->
|
||||
imageUploads = @_imageFiles(@props.draft.uploads).map((upload) ->
|
||||
<ImageFileUpload key={upload.id} upload={upload} />
|
||||
)
|
||||
nonImageUploads.concat(imageUploads)
|
||||
|
@ -433,38 +295,40 @@ class ComposerView extends React.Component
|
|||
_.reject(files, Utils.shouldDisplayAsImage)
|
||||
|
||||
_renderActionsRegion: =>
|
||||
return <div></div> unless @props.draftClientId
|
||||
<div className="composer-action-bar-content">
|
||||
<InjectedComponentSet
|
||||
className="composer-action-bar-plugins"
|
||||
matching={role: "Composer:ActionButton"}
|
||||
exposedProps={draftClientId: @props.draftClientId, threadId: @props.threadId} />
|
||||
exposedProps={draftClientId: @props.draft.clientId, threadId: @props.draft.threadId} />
|
||||
|
||||
<button className="btn btn-toolbar btn-trash" style={order: 100}
|
||||
title="Delete draft"
|
||||
onClick={@_destroyDraft}>
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className="btn btn-toolbar btn-trash"
|
||||
style={order: 100}
|
||||
title="Delete draft"
|
||||
onClick={@_destroyDraft}>
|
||||
<RetinaImg name="icon-composer-trash.png" mode={RetinaImg.Mode.ContentIsMask} />
|
||||
</button>
|
||||
|
||||
<button className="btn btn-toolbar btn-attach" style={order: 50}
|
||||
title="Attach file"
|
||||
onClick={@_selectAttachment}>
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className="btn btn-toolbar btn-attach"
|
||||
style={order: 50}
|
||||
title="Attach file"
|
||||
onClick={@_selectAttachment}>
|
||||
<RetinaImg name="icon-composer-attachment.png" mode={RetinaImg.Mode.ContentIsMask} />
|
||||
</button>
|
||||
|
||||
<div style={order: 0, flex: 1} />
|
||||
|
||||
<SendActionButton draft={@_proxy?.draft()}
|
||||
ref="sendActionButton"
|
||||
isValidDraft={@_isValidDraft} />
|
||||
|
||||
<SendActionButton
|
||||
tabIndex={-1}
|
||||
draft={@props.draft}
|
||||
ref="sendActionButton"
|
||||
isValidDraft={@_isValidDraft}
|
||||
/>
|
||||
</div>
|
||||
|
||||
isForwardedMessage: =>
|
||||
return false if not @_proxy
|
||||
draft = @_proxy.draft()
|
||||
Utils.isForwardedMessage(draft)
|
||||
|
||||
# This lets us click outside of the `contenteditable`'s `contentBody`
|
||||
# and simulate what happens when you click beneath the text *in* the
|
||||
# contentEditable.
|
||||
|
@ -486,112 +350,18 @@ class ComposerView extends React.Component
|
|||
@refs[Fields.Body].focusAbsoluteEnd()
|
||||
@_mouseDownTarget = null
|
||||
|
||||
# When a user focuses the composer, it's possible that no input is
|
||||
# initially focused. If this happens, we focus the contenteditable. If
|
||||
# we didn't focus the contenteditable, the user may start typing and
|
||||
# erroneously trigger keyboard shortcuts.
|
||||
_onFocusIn: (event) =>
|
||||
return unless @_proxy
|
||||
return if DOMUtils.closest(event.target, DOMUtils.inputTypes())
|
||||
@setState(focusedField: @_initiallyFocusedField(@_proxy.draft()))
|
||||
|
||||
_onMouseMoveComposeBody: (event) =>
|
||||
if @_mouseComposeBody is "down" then @_mouseComposeBody = "move"
|
||||
|
||||
_onDraftChanged: =>
|
||||
return if @_ignoreNextTrigger
|
||||
return unless @_proxy
|
||||
draft = @_proxy.draft()
|
||||
_initiallyFocusedField: ->
|
||||
{pristine, to, subject} = @props.draft
|
||||
|
||||
if not @_initialHistorySave
|
||||
@_saveToHistory()
|
||||
@_initialHistorySave = true
|
||||
|
||||
state =
|
||||
to: draft.to
|
||||
cc: draft.cc
|
||||
bcc: draft.bcc
|
||||
from: draft.from
|
||||
body: draft.body
|
||||
files: draft.files
|
||||
uploads: draft.uploads
|
||||
subject: draft.subject
|
||||
accounts: @_getAccountsForSend()
|
||||
|
||||
if !@state.draftReady
|
||||
_.extend state,
|
||||
draftReady: true
|
||||
focusedField: @_initiallyFocusedField(draft)
|
||||
enabledFields: @_initiallyEnabledFields(draft)
|
||||
showQuotedText: @isForwardedMessage()
|
||||
|
||||
state = @_verifyEnabledFields(draft, state)
|
||||
|
||||
@setState(state)
|
||||
|
||||
_initiallyFocusedField: (draft) ->
|
||||
return Fields.To if draft.to.length is 0
|
||||
return Fields.Subject if (draft.subject ? "").trim().length is 0
|
||||
|
||||
shouldFocusBody = NylasEnv.isComposerWindow() or draft.pristine or
|
||||
return Fields.To if to.length is 0
|
||||
return Fields.Subject if (subject ? "").trim().length is 0
|
||||
return Fields.Body if NylasEnv.isComposerWindow() or pristine or
|
||||
(FocusedContentStore.didFocusUsingClick('thread') is true)
|
||||
return Fields.Body if shouldFocusBody
|
||||
return null
|
||||
|
||||
_verifyEnabledFields: (draft, state) ->
|
||||
enabledFields = @state.enabledFields.concat(state.enabledFields)
|
||||
updated = false
|
||||
if draft.cc.length > 0
|
||||
updated = true
|
||||
enabledFields.push(Fields.Cc)
|
||||
|
||||
if draft.bcc.length > 0
|
||||
updated = true
|
||||
enabledFields.push(Fields.Bcc)
|
||||
|
||||
if updated
|
||||
state.enabledFields = _.uniq(enabledFields)
|
||||
|
||||
return state
|
||||
|
||||
_initiallyEnabledFields: (draft) ->
|
||||
enabledFields = [Fields.To]
|
||||
enabledFields.push Fields.Cc if not _.isEmpty(draft.cc)
|
||||
enabledFields.push Fields.Bcc if not _.isEmpty(draft.bcc)
|
||||
enabledFields.push Fields.From if @_shouldShowFromField(draft)
|
||||
enabledFields.push Fields.Subject if @_shouldEnableSubject()
|
||||
enabledFields.push Fields.Body
|
||||
return enabledFields
|
||||
|
||||
_getAccountsForSend: =>
|
||||
if @_proxy.draft()?.threadId
|
||||
[AccountStore.accountForId(@_proxy.draft().accountId)]
|
||||
else
|
||||
AccountStore.accounts()
|
||||
|
||||
# When the account store changes, the From field may or may not still
|
||||
# be in scope. We need to make sure to update our enabled fields.
|
||||
_onAccountStoreChanged: =>
|
||||
return unless @_proxy
|
||||
accounts = @_getAccountsForSend()
|
||||
enabledFields = if @_shouldShowFromField(@_proxy?.draft())
|
||||
@state.enabledFields.concat [Fields.From]
|
||||
else
|
||||
_.without(@state.enabledFields, Fields.From)
|
||||
@setState {enabledFields, accounts}
|
||||
|
||||
_shouldShowFromField: (draft) =>
|
||||
return true if draft
|
||||
return false
|
||||
|
||||
_shouldEnableSubject: =>
|
||||
return false unless @_proxy
|
||||
draft = @_proxy.draft()
|
||||
if _.isEmpty(draft.subject ? "") then return true
|
||||
else if @isForwardedMessage() then return true
|
||||
else if draft.replyToMessageId then return false
|
||||
else return true
|
||||
|
||||
_shouldAcceptDrop: (event) =>
|
||||
# Ensure that you can't pick up a file and drop it on the same draft
|
||||
nonNativeFilePath = @_nonNativeFilePathForDrop(event)
|
||||
|
@ -620,76 +390,37 @@ class ComposerView extends React.Component
|
|||
_onDrop: (e) =>
|
||||
# Accept drops of real files from other applications
|
||||
for file in e.dataTransfer.files
|
||||
Actions.addAttachment({filePath: file.path, messageClientId: @props.draftClientId})
|
||||
Actions.addAttachment({filePath: file.path, messageClientId: @props.draft.clientId})
|
||||
|
||||
# Accept drops from attachment components / images within the app
|
||||
if (uri = @_nonNativeFilePathForDrop(e))
|
||||
Actions.addAttachment({filePath: uri, messageClientId: @props.draftClientId})
|
||||
Actions.addAttachment({filePath: uri, messageClientId: @props.draft.clientId})
|
||||
|
||||
_onFilePaste: (path) =>
|
||||
Actions.addAttachment({filePath: path, messageClientId: @props.draftClientId})
|
||||
|
||||
_onChangeParticipants: (changes={}) =>
|
||||
@_addToProxy(changes)
|
||||
Actions.draftParticipantsChanged(@props.draftClientId, changes)
|
||||
|
||||
_onChangeSubject: (event) =>
|
||||
@_addToProxy(subject: event.target.value)
|
||||
Actions.addAttachment({filePath: path, messageClientId: @props.draft.clientId})
|
||||
|
||||
_onBodyChanged: (event) =>
|
||||
return unless @_proxy
|
||||
|
||||
newBody = @_showQuotedText(event.target.value)
|
||||
|
||||
# The body changes extremely frequently (on every key stroke). To keep
|
||||
# performance up, we don't want to trigger every single key stroke
|
||||
# since that will cause an entire composer re-render. We, however,
|
||||
# never want to lose any data, so we still add data to the proxy on
|
||||
# every keystroke.
|
||||
#
|
||||
# We want to use debounce instead of throttle because we don't want ot
|
||||
# trigger janky re-renders mid quick-type. Let's just do it at the end
|
||||
# when you're done typing and about to move onto something else.
|
||||
@_addToProxy({body: newBody}, {fromBodyChange: true})
|
||||
@_throttledTrigger ?= _.debounce =>
|
||||
@_ignoreNextTrigger = false
|
||||
@_proxy.trigger()
|
||||
, 100
|
||||
|
||||
@_throttledTrigger()
|
||||
@_addToProxy({body: @_showQuotedText(event.target.value)})
|
||||
return
|
||||
|
||||
_addToProxy: (changes={}, source={}) =>
|
||||
return unless @_proxy and @_proxy.draft()
|
||||
|
||||
selections = @_getSelections()
|
||||
@props.session.changes.add(changes)
|
||||
|
||||
oldDraft = @_proxy.draft()
|
||||
return if _.all changes, (change, key) -> _.isEqual(change, oldDraft[key])
|
||||
|
||||
# Other extensions might want to hear about changes immediately. We
|
||||
# only need to prevent this view from re-rendering until we're done
|
||||
# throttling body changes.
|
||||
if source.fromBodyChange then @_ignoreNextTrigger = true
|
||||
|
||||
@_proxy.changes.add(changes)
|
||||
|
||||
@_saveToHistory(selections) unless source.fromUndoManager
|
||||
if not source.fromUndoManager
|
||||
@_saveToHistory(selections)
|
||||
|
||||
_isValidDraft: (options = {}) =>
|
||||
return false unless @_proxy
|
||||
|
||||
# We need to check the `DraftStore` because the `DraftStore` is
|
||||
# immediately and synchronously updated as soon as this function
|
||||
# fires. Since `setState` is asynchronous, if we used that as our only
|
||||
# check, then we might get a false reading.
|
||||
return false if DraftStore.isSendingDraft(@props.draftClientId)
|
||||
return false if DraftStore.isSendingDraft(@props.draft.clientId)
|
||||
|
||||
draft = @_proxy.draft()
|
||||
{remote} = require('electron')
|
||||
dialog = remote.require('dialog')
|
||||
|
||||
allRecipients = [].concat(draft.to, draft.cc, draft.bcc)
|
||||
allRecipients = [].concat(@props.draft.to, @props.draft.cc, @props.draft.bcc)
|
||||
for contact in allRecipients
|
||||
if not ContactStore.isValidContact(contact)
|
||||
dealbreaker = "#{contact.email} is not a valid email address - please remove or edit it before sending."
|
||||
|
@ -705,16 +436,16 @@ class ComposerView extends React.Component
|
|||
})
|
||||
return false
|
||||
|
||||
bodyIsEmpty = draft.body is @_proxy.draftPristineBody()
|
||||
forwarded = Utils.isForwardedMessage(draft)
|
||||
hasAttachment = (draft.files ? []).length > 0 or (draft.uploads ? []).length > 0
|
||||
bodyIsEmpty = @props.draft.body is @props.session.draftPristineBody()
|
||||
forwarded = Utils.isForwardedMessage(@props.draft)
|
||||
hasAttachment = (@props.draft.files ? []).length > 0 or (@props.draft.uploads ? []).length > 0
|
||||
|
||||
warnings = []
|
||||
|
||||
if draft.subject.length is 0
|
||||
if @props.draft.subject.length is 0
|
||||
warnings.push('without a subject line')
|
||||
|
||||
if @_mentionsAttachment(draft.body) and not hasAttachment
|
||||
if @_mentionsAttachment(@props.draft.body) and not hasAttachment
|
||||
warnings.push('without an attachment')
|
||||
|
||||
if bodyIsEmpty and not forwarded and not hasAttachment
|
||||
|
@ -723,7 +454,7 @@ class ComposerView extends React.Component
|
|||
# Check third party warnings added via Composer extensions
|
||||
for extension in ExtensionRegistry.Composer.extensions()
|
||||
continue unless extension.warningsForSending
|
||||
warnings = warnings.concat(extension.warningsForSending({draft}))
|
||||
warnings = warnings.concat(extension.warningsForSending({draft: @props.draft}))
|
||||
|
||||
if warnings.length > 0 and not options.force
|
||||
response = dialog.showMessageBox(remote.getCurrentWindow(), {
|
||||
|
@ -748,10 +479,10 @@ class ComposerView extends React.Component
|
|||
return body.indexOf("attach") >= 0
|
||||
|
||||
_destroyDraft: =>
|
||||
Actions.destroyDraft(@props.draftClientId)
|
||||
Actions.destroyDraft(@props.draft.clientId)
|
||||
|
||||
_selectAttachment: =>
|
||||
Actions.selectAttachment({messageClientId: @props.draftClientId})
|
||||
Actions.selectAttachment({messageClientId: @props.draft.clientId})
|
||||
|
||||
undo: (event) =>
|
||||
event.preventDefault()
|
||||
|
@ -760,7 +491,7 @@ class ComposerView extends React.Component
|
|||
return unless historyItem.state?
|
||||
|
||||
@_recoveredSelection = historyItem.currentSelection
|
||||
@_addToProxy historyItem.state, fromUndoManager: true
|
||||
@_addToProxy(historyItem.state, fromUndoManager: true)
|
||||
@_recoveredSelection = null
|
||||
|
||||
redo: (event) =>
|
||||
|
@ -770,7 +501,7 @@ class ComposerView extends React.Component
|
|||
return unless historyItem.state?
|
||||
|
||||
@_recoveredSelection = historyItem.currentSelection
|
||||
@_addToProxy historyItem.state, fromUndoManager: true
|
||||
@_addToProxy(historyItem.state, fromUndoManager: true)
|
||||
@_recoveredSelection = null
|
||||
|
||||
_getSelections: =>
|
||||
|
@ -778,20 +509,17 @@ class ComposerView extends React.Component
|
|||
previousSelection: @refs[Fields.Body]?.getPreviousSelection?()
|
||||
|
||||
_saveToHistory: (selections) =>
|
||||
return unless @_proxy
|
||||
selections ?= @_getSelections()
|
||||
|
||||
newDraft = @_proxy.draft()
|
||||
|
||||
historyItem =
|
||||
previousSelection: selections.previousSelection
|
||||
currentSelection: selections.currentSelection
|
||||
state:
|
||||
body: _.clone newDraft.body
|
||||
subject: _.clone newDraft.subject
|
||||
to: _.clone newDraft.to
|
||||
cc: _.clone newDraft.cc
|
||||
bcc: _.clone newDraft.bcc
|
||||
body: _.clone @props.draft.body
|
||||
subject: _.clone @props.draft.subject
|
||||
to: _.clone @props.draft.to
|
||||
cc: _.clone @props.draft.cc
|
||||
bcc: _.clone @props.draft.bcc
|
||||
|
||||
lastState = @undoManager.current()
|
||||
if lastState?
|
||||
|
@ -799,8 +527,4 @@ class ComposerView extends React.Component
|
|||
|
||||
@undoManager.saveToHistory(historyItem)
|
||||
|
||||
_deleteDraftIfEmpty: =>
|
||||
return unless @_proxy
|
||||
if @_proxy.draft().pristine then Actions.destroyDraft(@props.draftClientId)
|
||||
|
||||
module.exports = ComposerView
|
||||
|
|
|
@ -1,143 +0,0 @@
|
|||
_ = require 'underscore'
|
||||
React = require 'react'
|
||||
AccountContactField = require './account-contact-field'
|
||||
ParticipantsTextField = require './participants-text-field'
|
||||
{Actions} = require 'nylas-exports'
|
||||
{RetinaImg} = require 'nylas-component-kit'
|
||||
|
||||
Fields = require './fields'
|
||||
|
||||
class ExpandedParticipants extends React.Component
|
||||
@displayName: "ExpandedParticipants"
|
||||
|
||||
@propTypes:
|
||||
# Arrays of Contact objects.
|
||||
to: React.PropTypes.array
|
||||
cc: React.PropTypes.array
|
||||
bcc: React.PropTypes.array
|
||||
from: React.PropTypes.array
|
||||
|
||||
# We need to know if the draft is ready so we can enable and disable
|
||||
# ParticipantTextFields.
|
||||
#
|
||||
# It's possible for a ParticipantTextField, before the draft is
|
||||
# ready, to start the request to `add`, `remove`, or `edit`. This
|
||||
# happens when there are multiple drafts rendering, each requesting
|
||||
# focus. A blur event gets fired before the draft is loaded, causing
|
||||
# logic to run that sets an empty field. These requests are
|
||||
# asynchronous. They may resolve after the draft is in fact ready.
|
||||
# This is bad because the desire to `remove` participants may have
|
||||
# been made with an empty, non-loaded draft, but executed on the new
|
||||
# draft that was loaded in the time it took the async request to
|
||||
# return.
|
||||
draftReady: React.PropTypes.bool
|
||||
|
||||
# The account to which the current draft belongs
|
||||
accounts: React.PropTypes.array
|
||||
|
||||
# The field that should be focused
|
||||
focusedField: React.PropTypes.string
|
||||
|
||||
# An enum array of visible fields. Can be any constant in the `Fields`
|
||||
# dict. We are passed these as props instead of holding it as state
|
||||
# since this component is frequently unmounted and re-mounted every
|
||||
# time it is displayed
|
||||
enabledFields: React.PropTypes.array
|
||||
|
||||
# Callback for when a user changes which fields should be visible
|
||||
onAdjustEnabledFields: React.PropTypes.func
|
||||
|
||||
# Callback for the participants change
|
||||
onChangeParticipants: React.PropTypes.func
|
||||
|
||||
# Callback for the field focus changes
|
||||
onChangeFocusedField: React.PropTypes.func
|
||||
|
||||
@defaultProps:
|
||||
to: []
|
||||
cc: []
|
||||
bcc: []
|
||||
from: []
|
||||
accounts: []
|
||||
draftReady: false
|
||||
enabledFields: []
|
||||
|
||||
constructor: (@props={}) ->
|
||||
|
||||
componentDidMount: =>
|
||||
@_applyFocusedField()
|
||||
|
||||
componentDidUpdate: ->
|
||||
@_applyFocusedField()
|
||||
|
||||
render: ->
|
||||
<div className="expanded-participants" ref="participantWrap">
|
||||
{@_renderFields()}
|
||||
</div>
|
||||
|
||||
_applyFocusedField: ->
|
||||
if @props.focusedField
|
||||
return unless @refs[@props.focusedField]
|
||||
if @refs[@props.focusedField].focus
|
||||
@refs[@props.focusedField].focus()
|
||||
else
|
||||
React.findDOMNode(@refs[@props.focusedField]).focus()
|
||||
|
||||
_renderFields: =>
|
||||
# Note: We need to physically add and remove these elements, not just hide them.
|
||||
# If they're hidden, shift-tab between fields breaks.
|
||||
fields = []
|
||||
fields.push(
|
||||
<ParticipantsTextField
|
||||
ref={Fields.To}
|
||||
key="to"
|
||||
field='to'
|
||||
change={@props.onChangeParticipants}
|
||||
className="composer-participant-field to-field"
|
||||
draftReady={@props.draftReady}
|
||||
onFocus={ => @props.onChangeFocusedField(Fields.To) }
|
||||
participants={to: @props['to'], cc: @props['cc'], bcc: @props['bcc']} />
|
||||
)
|
||||
|
||||
if Fields.Cc in @props.enabledFields
|
||||
fields.push(
|
||||
<ParticipantsTextField
|
||||
ref={Fields.Cc}
|
||||
key="cc"
|
||||
field='cc'
|
||||
draftReady={@props.draftReady}
|
||||
change={@props.onChangeParticipants}
|
||||
onEmptied={ => @props.onAdjustEnabledFields(hide: [Fields.Cc]) }
|
||||
onFocus={ => @props.onChangeFocusedField(Fields.Cc) }
|
||||
className="composer-participant-field cc-field"
|
||||
participants={to: @props['to'], cc: @props['cc'], bcc: @props['bcc']} />
|
||||
)
|
||||
|
||||
if Fields.Bcc in @props.enabledFields
|
||||
fields.push(
|
||||
<ParticipantsTextField
|
||||
ref={Fields.Bcc}
|
||||
key="bcc"
|
||||
field='bcc'
|
||||
draftReady={@props.draftReady}
|
||||
change={@props.onChangeParticipants}
|
||||
onEmptied={ => @props.onAdjustEnabledFields(hide: [Fields.Bcc]) }
|
||||
onFocus={ => @props.onChangeFocusedField(Fields.Bcc) }
|
||||
className="composer-participant-field bcc-field"
|
||||
participants={to: @props['to'], cc: @props['cc'], bcc: @props['bcc']} />
|
||||
)
|
||||
|
||||
if Fields.From in @props.enabledFields
|
||||
fields.push(
|
||||
<AccountContactField
|
||||
key="from"
|
||||
ref={Fields.From}
|
||||
onChange={({from}) => @props.onChangeParticipants({from})}
|
||||
onFocus={ => @props.onChangeFocusedField(Fields.From) }
|
||||
accounts={@props.accounts}
|
||||
value={@props.from?[0]} />
|
||||
)
|
||||
|
||||
fields
|
||||
|
||||
module.exports = ExpandedParticipants
|
|
@ -5,7 +5,7 @@ Fields =
|
|||
From: "fromField"
|
||||
Subject: "textFieldSubject"
|
||||
Body: "contentBody"
|
||||
Fields.ParticipantFields = [Fields.To, Fields.Cc, Fields.Bcc, Fields.From]
|
||||
Fields.ParticipantFields = [Fields.To, Fields.Cc, Fields.Bcc]
|
||||
|
||||
Fields.Order =
|
||||
"textFieldTo": 1
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
_ = require 'underscore'
|
||||
React = require 'react'
|
||||
|
||||
{DraftSessionContainer} = require('nylas-component-kit');
|
||||
{AccountStore,
|
||||
DatabaseStore,
|
||||
Message,
|
||||
|
@ -29,7 +30,9 @@ class ComposerWithWindowProps extends React.Component
|
|||
|
||||
render: ->
|
||||
<div className="composer-full-window">
|
||||
<ComposerView draftClientId={@state.draftClientId} />
|
||||
<DraftSessionContainer draftClientId={@state.draftClientId}>
|
||||
<ComposerView />
|
||||
</DraftSessionContainer>
|
||||
</div>
|
||||
|
||||
_showInitialErrorDialog: (msg) ->
|
||||
|
|
|
@ -8,9 +8,6 @@ class ParticipantsTextField extends React.Component
|
|||
@displayName: 'ParticipantsTextField'
|
||||
|
||||
@propTypes:
|
||||
# The tab index of the ParticipantsTextField
|
||||
tabIndex: React.PropTypes.string,
|
||||
|
||||
# The name of the field, used for both display purposes and also
|
||||
# to modify the `participants` provided.
|
||||
field: React.PropTypes.string,
|
||||
|
@ -31,24 +28,8 @@ class ParticipantsTextField extends React.Component
|
|||
|
||||
onFocus: React.PropTypes.func
|
||||
|
||||
# We need to know if the draft is ready so we can enable and disable
|
||||
# ParticipantTextFields.
|
||||
#
|
||||
# It's possible for a ParticipantTextField, before the draft is
|
||||
# ready, to start the request to `add`, `remove`, or `edit`. This
|
||||
# happens when there are multiple drafts rendering, each requesting
|
||||
# focus. A blur event gets fired before the draft is loaded, causing
|
||||
# logic to run that sets an empty field. These requests are
|
||||
# asynchronous. They may resolve after the draft is in fact ready.
|
||||
# This is bad because the desire to `remove` participants may have
|
||||
# been made with an empty, non-loaded draft, but executed on the new
|
||||
# draft that was loaded in the time it took the async request to
|
||||
# return.
|
||||
draftReady: React.PropTypes.bool
|
||||
|
||||
@defaultProps:
|
||||
visible: true
|
||||
draftReady: false
|
||||
|
||||
shouldComponentUpdate: (nextProps, nextState) =>
|
||||
not Utils.isEqualReact(nextProps, @props) or
|
||||
|
@ -72,7 +53,6 @@ class ParticipantsTextField extends React.Component
|
|||
onEmptied={@props.onEmptied}
|
||||
onFocus={@props.onFocus}
|
||||
onTokenAction={@_showContextMenu}
|
||||
tabIndex={@props.tabIndex}
|
||||
menuClassSet={classSet}
|
||||
menuPrompt={@props.field}
|
||||
/>
|
||||
|
@ -116,7 +96,6 @@ class ParticipantsTextField extends React.Component
|
|||
return [new Contact(email: string, name: null)]
|
||||
|
||||
_remove: (values) =>
|
||||
return unless @props.draftReady
|
||||
field = @props.field
|
||||
updates = {}
|
||||
updates[field] = _.reject @props.participants[field], (p) ->
|
||||
|
@ -126,7 +105,6 @@ class ParticipantsTextField extends React.Component
|
|||
@props.change(updates)
|
||||
|
||||
_edit: (token, replacementString) =>
|
||||
return unless @props.draftReady
|
||||
field = @props.field
|
||||
tokenIndex = @props.participants[field].indexOf(token)
|
||||
@_tokensForString(replacementString).then (replacements) =>
|
||||
|
@ -142,7 +120,6 @@ class ParticipantsTextField extends React.Component
|
|||
# The `tokensPromise` may be formed with an empty draft, but resolved
|
||||
# after a draft was prepared. This would cause the bad data to be
|
||||
# propagated.
|
||||
return unless @props.draftReady
|
||||
|
||||
# If the input is a string, parse out email addresses and build
|
||||
# an array of contact objects. For each email address wrapped in
|
||||
|
|
|
@ -82,6 +82,7 @@ class SendActionButton extends React.Component
|
|||
|
||||
_renderSingleDefaultButton: =>
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className={"btn btn-toolbar btn-normal btn-emphasis btn-text btn-send"}
|
||||
style={order: -100}
|
||||
onClick={@_onPrimaryClick}>
|
||||
|
|
|
@ -50,7 +50,6 @@ describe 'ParticipantsTextField', ->
|
|||
field={@fieldName}
|
||||
tabIndex={@tabIndex}
|
||||
visible={true}
|
||||
draftReady={true}
|
||||
participants={@participants}
|
||||
change={@propChange} />
|
||||
)
|
||||
|
|
|
@ -7,7 +7,7 @@ MessageItem = require './message-item'
|
|||
DraftStore,
|
||||
MessageStore} = require 'nylas-exports'
|
||||
|
||||
{InjectedComponent} = require 'nylas-component-kit'
|
||||
{InjectedComponent, DraftSessionContainer} = require 'nylas-component-kit'
|
||||
|
||||
class MessageItemContainer extends React.Component
|
||||
@displayName = 'MessageItemContainer'
|
||||
|
@ -60,17 +60,17 @@ class MessageItemContainer extends React.Component
|
|||
isLastMsg={@props.isLastMsg} />
|
||||
|
||||
_renderComposer: =>
|
||||
exposedProps =
|
||||
mode: "inline"
|
||||
draftClientId: @props.message.clientId
|
||||
threadId: @props.thread.id
|
||||
scrollTo: @props.scrollTo
|
||||
|
||||
<InjectedComponent
|
||||
ref="message"
|
||||
matching={role: "Composer"}
|
||||
className={@_classNames()}
|
||||
exposedProps={exposedProps}/>
|
||||
<DraftSessionContainer draftClientId={@props.message.clientId}>
|
||||
<InjectedComponent
|
||||
ref="message"
|
||||
matching={role: "Composer"}
|
||||
className={@_classNames()}
|
||||
exposedProps={{
|
||||
mode: "inline"
|
||||
threadId: @props.thread.id
|
||||
scrollTo: @props.scrollTo
|
||||
}}/>
|
||||
</DraftSessionContainer>
|
||||
|
||||
_classNames: => classNames
|
||||
"draft": @props.message.draft
|
||||
|
|
|
@ -5,7 +5,7 @@ class CalendarButton extends React.Component
|
|||
@displayName: 'CalendarButton'
|
||||
|
||||
render: =>
|
||||
<button className="btn btn-toolbar" onClick={@_onClick} title="Insert calendar availability…">
|
||||
<button tabIndex={-1} className="btn btn-toolbar" onClick={@_onClick} title="Insert calendar availability…">
|
||||
<RetinaImg url="nylas://quick-schedule/assets/icon-composer-quickschedule@2x.png" mode={RetinaImg.Mode.ContentIsMask} />
|
||||
</button>
|
||||
|
||||
|
|
|
@ -75,7 +75,7 @@ class SendLaterButton extends Component {
|
|||
|
||||
if (scheduledDate === 'saving') {
|
||||
return (
|
||||
<button className={className} title="Saving send date...">
|
||||
<button className={className} title="Saving send date..." tabIndex={-1}>
|
||||
<RetinaImg
|
||||
name="inline-loading-spinner.gif"
|
||||
mode={RetinaImg.Mode.ContentDark}
|
||||
|
@ -94,7 +94,7 @@ class SendLaterButton extends Component {
|
|||
}
|
||||
}
|
||||
return (
|
||||
<button className={className} title="Send later…" onClick={this.onClick}>
|
||||
<button className={className} title="Send later…" onClick={this.onClick} tabIndex={-1}>
|
||||
<RetinaImg name="icon-composer-sendlater.png" mode={RetinaImg.Mode.ContentIsMask} />
|
||||
{dateInterpretation}
|
||||
<span> </span>
|
||||
|
|
|
@ -181,22 +181,3 @@
|
|||
'* u': 'native!'
|
||||
'* s': 'native!'
|
||||
'* t': 'native!'
|
||||
|
||||
# Tabs are a bit different because simple elements (like text inputs) we
|
||||
# want to use our custom `core:focus-next`. Other more complex ones, like
|
||||
# `contenteditable`, we want to have a more controlled effect over.
|
||||
'body input, body textarea':
|
||||
'tab': 'core:focus-next'
|
||||
'shift-tab': 'core:focus-previous'
|
||||
|
||||
# So our contenteditable control can do its own thing
|
||||
'body webview, body *[contenteditable]':
|
||||
'tab': 'native!'
|
||||
'shift-tab': 'native!'
|
||||
|
||||
# For menus
|
||||
'body .menu, body .menu, body .menu input':
|
||||
# and by "native!" I actually mean for it to just let React deal with
|
||||
# it.
|
||||
'tab': 'native!'
|
||||
'shift-tab': 'native!'
|
||||
|
|
|
@ -27,7 +27,7 @@ class ButtonDropdown extends React.Component
|
|||
'bordered': @props.bordered isnt false
|
||||
|
||||
if @props.primaryClick
|
||||
<div ref="button" onBlur={@_onBlur} tabIndex={999} className={"#{classes} #{@props.className ? ''}"} style={@props.style}>
|
||||
<div ref="button" onBlur={@_onBlur} tabIndex={-1} className={"#{classes} #{@props.className ? ''}"} style={@props.style}>
|
||||
<div className="primary-item"
|
||||
title={@props.primaryTitle ? ""}
|
||||
onClick={@props.primaryClick}>
|
||||
|
@ -41,7 +41,7 @@ class ButtonDropdown extends React.Component
|
|||
</div>
|
||||
</div>
|
||||
else
|
||||
<div ref="button" onBlur={@_onBlur} tabIndex={999} className={"#{classes} #{@props.className ? ''}"} style={@props.style}>
|
||||
<div ref="button" onBlur={@_onBlur} tabIndex={-1} className={"#{classes} #{@props.className ? ''}"} style={@props.style}>
|
||||
<div className="only-item"
|
||||
title={@props.primaryTitle ? ""}
|
||||
onClick={@toggleDropdown}>
|
||||
|
|
|
@ -13,24 +13,24 @@ class ListManager extends ContenteditableExtension
|
|||
@_collapseAdjacentLists(editor)
|
||||
|
||||
@onKeyDown: ({editor, event}) ->
|
||||
@_spaceEntered = event.key is " "
|
||||
if DOMUtils.isInList()
|
||||
if event.key is "Backspace" and DOMUtils.atStartOfList()
|
||||
event.preventDefault()
|
||||
@outdentListItem(editor)
|
||||
else if event.key is "Tab" and editor.currentSelection().isCollapsed
|
||||
event.preventDefault()
|
||||
if event.shiftKey
|
||||
@outdentListItem(editor)
|
||||
else
|
||||
editor.indent()
|
||||
else
|
||||
# Do nothing, let the event through.
|
||||
@originalInput = null
|
||||
else
|
||||
@originalInput = null
|
||||
|
||||
return event
|
||||
# @_spaceEntered = event.key is " "
|
||||
# if DOMUtils.isInList()
|
||||
# if event.key is "Backspace" and DOMUtils.atStartOfList()
|
||||
# event.preventDefault()
|
||||
# @outdentListItem(editor)
|
||||
# else if event.key is "Tab" and editor.currentSelection().isCollapsed
|
||||
# event.preventDefault()
|
||||
# if event.shiftKey
|
||||
# @outdentListItem(editor)
|
||||
# else
|
||||
# editor.indent()
|
||||
# else
|
||||
# # Do nothing, let the event through.
|
||||
# @originalInput = null
|
||||
# else
|
||||
# @originalInput = null
|
||||
#
|
||||
# return event
|
||||
|
||||
@bulletRegex: -> /^[*-]\s/
|
||||
|
||||
|
|
60
src/components/draft-session-container.cjsx
Normal file
60
src/components/draft-session-container.cjsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
React = require 'react'
|
||||
_ = require 'underscore'
|
||||
{DraftStore, Actions} = require 'nylas-exports'
|
||||
|
||||
class DraftSessionContainer extends React.Component
|
||||
@displayName: 'DraftSessionContainer'
|
||||
@propTypes:
|
||||
children: React.PropTypes.element
|
||||
draftClientId: React.PropTypes.string
|
||||
|
||||
constructor: ->
|
||||
@state =
|
||||
session: null
|
||||
draft: null
|
||||
|
||||
componentWillMount: =>
|
||||
@_unmounted = false
|
||||
@_prepareForDraft(@props.draftClientId)
|
||||
|
||||
componentWillUnmount: =>
|
||||
@_unmounted = true
|
||||
@_teardownForDraft()
|
||||
@_deleteDraftIfEmpty()
|
||||
|
||||
componentWillReceiveProps: (newProps) =>
|
||||
if newProps.draftClientId isnt @props.draftClientId
|
||||
@_teardownForDraft()
|
||||
@_prepareForDraft(newProps.draftClientId)
|
||||
|
||||
_prepareForDraft: (draftClientId) =>
|
||||
return unless draftClientId
|
||||
|
||||
DraftStore.sessionForClientId(draftClientId).then (session) =>
|
||||
return if @_unmounted
|
||||
return if session.draftClientId isnt @props.draftClientId
|
||||
|
||||
@_sessionUnlisten = session.listen =>
|
||||
@setState(draft: session.draft())
|
||||
|
||||
@setState({
|
||||
session: session,
|
||||
draft: session.draft()
|
||||
})
|
||||
|
||||
_teardownForDraft: =>
|
||||
if @state.session
|
||||
@state.session.changes.commit()
|
||||
@_sessionUnlisten() if @_sessionUnlisten
|
||||
|
||||
_deleteDraftIfEmpty: =>
|
||||
return unless @state.draft
|
||||
if @state.draft.pristine
|
||||
Actions.destroyDraft(@props.draftClientId)
|
||||
|
||||
render: ->
|
||||
return <span/> unless @state.draft
|
||||
otherProps = _.omit(@props, Object.keys(@constructor.propTypes))
|
||||
React.cloneElement(@props.children, _.extend({}, otherProps, @state))
|
||||
|
||||
module.exports = DraftSessionContainer
|
|
@ -97,42 +97,40 @@ class KeyCommandsRegion extends React.Component
|
|||
onFocusOut: ->
|
||||
|
||||
constructor: ->
|
||||
@state = {focused: false}
|
||||
@_goingout = false
|
||||
@_lostFocusToElement = null
|
||||
@state =
|
||||
focused: false
|
||||
|
||||
@_in = (event) =>
|
||||
@_goingout = false
|
||||
@_lastFocusElement = event.target
|
||||
@_losingFocusToElement = null
|
||||
@props.onFocusIn(event) if @state.focused is false
|
||||
@setState(focused: true)
|
||||
|
||||
@_processOutDebounced = _.debounce =>
|
||||
return unless @_losingFocusToElement
|
||||
return unless @state.focused
|
||||
|
||||
# This happens when component that used to have the focus is
|
||||
# unmounted. An example is the url input field of the
|
||||
# FloatingToolbar in the Composer's Contenteditable
|
||||
return if React.findDOMNode(@).contains(document.activeElement)
|
||||
|
||||
# This prevents the strange effect of an input appearing to have focus
|
||||
# when the element receiving focus does not support selection (like a
|
||||
# div with tabIndex=-1)
|
||||
if @_losingFocusToElement.tagName isnt 'INPUT'
|
||||
document.getSelection().empty()
|
||||
|
||||
@props.onFocusOut(@_lastFocusElement)
|
||||
@setState({focused: false})
|
||||
@_losingFocusToElement = null
|
||||
, 100
|
||||
|
||||
@_out = (event) =>
|
||||
@_goingout = true
|
||||
setTimeout =>
|
||||
return unless @_goingout
|
||||
|
||||
# If we're unmounted the `@_goingout` flag will catch the unmount
|
||||
# @_goingout is set to true when we umount
|
||||
#
|
||||
# It's posible for a focusout event to fire from within a region
|
||||
# that we're actually focsued on.
|
||||
#
|
||||
# This happens when component that used to have the focus is
|
||||
# unmounted. An example is the url input field of the
|
||||
# FloatingToolbar in the Composer's Contenteditable
|
||||
el = React.findDOMNode(@)
|
||||
return if el.contains document.activeElement
|
||||
|
||||
# This prevents the strange effect of an input appearing to have focus
|
||||
# when the element receiving focus does not support selection (like a
|
||||
# div with tabIndex=-1)
|
||||
if event.relatedTarget and event.relatedTarget.tagName isnt 'INPUT'
|
||||
document.getSelection().empty()
|
||||
|
||||
@props.onFocusOut() if @state.focused is true
|
||||
@setState(focused: false)
|
||||
@_goingout = false
|
||||
, 100
|
||||
|
||||
@_lastFocusElement = event.target
|
||||
@_losingFocusToElement = event.relatedTarget
|
||||
@_processOutDebounced()
|
||||
|
||||
componentWillReceiveProps: (newProps) ->
|
||||
@_unmountListeners()
|
||||
|
|
|
@ -127,7 +127,7 @@ export default class MetadataComposerToggleButton extends React.Component {
|
|||
}
|
||||
|
||||
return (
|
||||
<button className={className} onClick={this._onClick} title={title}>
|
||||
<button className={className} onClick={this._onClick} title={title} tabIndex={-1}>
|
||||
<RetinaImg {...attrs} mode={RetinaImg.Mode.ContentIsMask} />
|
||||
</button>
|
||||
);
|
||||
|
|
|
@ -95,7 +95,7 @@ class Token extends React.Component
|
|||
draggable="true"
|
||||
onDoubleClick={@_onDoubleClick}
|
||||
onClick={@_onSelect}>
|
||||
<button className="action" onClick={@_onAction}>
|
||||
<button className="action" onClick={@_onAction} tabIndex={-1}>
|
||||
<RetinaImg mode={RetinaImg.Mode.ContentIsMask} name="composer-caret.png" />
|
||||
</button>
|
||||
{@props.children}
|
||||
|
@ -244,12 +244,6 @@ class TokenizingTextField extends React.Component
|
|||
# Called when the input is focused
|
||||
onFocus: React.PropTypes.func
|
||||
|
||||
# The tabIndex of the input item
|
||||
tabIndex: React.PropTypes.oneOfType([
|
||||
React.PropTypes.number
|
||||
React.PropTypes.string
|
||||
])
|
||||
|
||||
# A Prompt used in the head of the menu
|
||||
menuPrompt: React.PropTypes.string
|
||||
|
||||
|
@ -304,7 +298,7 @@ class TokenizingTextField extends React.Component
|
|||
onFocus: @_onInputFocused
|
||||
onChange: @_onInputChanged
|
||||
disabled: @props.disabled
|
||||
tabIndex: @props.tabIndex
|
||||
tabIndex: 0
|
||||
value: @state.inputValue
|
||||
|
||||
# If we can't accept additional tokens, override the events that would
|
||||
|
@ -386,14 +380,21 @@ class TokenizingTextField extends React.Component
|
|||
else if event.key in ["Escape"]
|
||||
@_refreshCompletions("", clear: true)
|
||||
|
||||
else if event.key in ["Tab", "Enter"] or event.keyCode is 188 # comma
|
||||
event.preventDefault()
|
||||
return if (@state.inputValue ? "").trim().length is 0
|
||||
event.stopPropagation()
|
||||
if @state.completions.length > 0
|
||||
@_addToken(@refs.completions.getSelectedItem() || @state.completions[0])
|
||||
else
|
||||
@_addInputValue()
|
||||
else if event.key in ["Tab", "Enter"]
|
||||
@_onInputTrySubmit()
|
||||
|
||||
else if event.keyCode is 188 # comma
|
||||
event.preventDefault() # never allow commas in the field
|
||||
@_onInputTrySubmit()
|
||||
|
||||
_onInputTrySubmit: =>
|
||||
return if (@state.inputValue ? "").trim().length is 0
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if @state.completions.length > 0
|
||||
@_addToken(@refs.completions.getSelectedItem() || @state.completions[0])
|
||||
else
|
||||
@_addInputValue()
|
||||
|
||||
_onInputChanged: (event) =>
|
||||
val = event.target.value.trimLeft()
|
||||
|
|
|
@ -46,6 +46,7 @@ class NylasComponentKit
|
|||
@load "TimeoutTransitionGroup", 'timeout-transition-group'
|
||||
@load "MetadataComposerToggleButton", 'metadata-composer-toggle-button'
|
||||
@load "ConfigPropContainer", "config-prop-container"
|
||||
@load "DraftSessionContainer", 'draft-session-container'
|
||||
@load "DisclosureTriangle", "disclosure-triangle"
|
||||
@load "EditableList", "editable-list"
|
||||
@load "OutlineViewItem", "outline-view-item"
|
||||
|
|
|
@ -88,9 +88,9 @@ class ToolbarWindowControls extends React.Component
|
|||
|
||||
render: =>
|
||||
<div name="ToolbarWindowControls" className="toolbar-window-controls alt-#{@state.alt}">
|
||||
<button className="close" onClick={ -> NylasEnv.close()}></button>
|
||||
<button className="minimize" onClick={ -> NylasEnv.minimize()}></button>
|
||||
<button className="maximize" onClick={@_onMaximize}></button>
|
||||
<button tabIndex={-1} className="close" onClick={ -> NylasEnv.close()}></button>
|
||||
<button tabIndex={-1} className="minimize" onClick={ -> NylasEnv.minimize()}></button>
|
||||
<button tabIndex={-1} className="maximize" onClick={@_onMaximize}></button>
|
||||
</div>
|
||||
|
||||
_onAlt: (event) =>
|
||||
|
|
|
@ -86,10 +86,6 @@ class WindowEventHandler
|
|||
ReactRemote = require './react-remote/react-remote-parent'
|
||||
ReactRemote.toggleContainerVisible()
|
||||
|
||||
@subscribeToCommand $(document), 'core:focus-next', @focusNext
|
||||
|
||||
@subscribeToCommand $(document), 'core:focus-previous', @focusPrevious
|
||||
|
||||
document.addEventListener 'keydown', @onKeydown
|
||||
|
||||
# "Pinch to zoom" on the Mac gets translated by the system into a
|
||||
|
@ -244,58 +240,6 @@ class WindowEventHandler
|
|||
}))
|
||||
menu.popup(remote.getCurrentWindow())
|
||||
|
||||
eachTabIndexedElement: (callback) ->
|
||||
for element in $('[tabindex]')
|
||||
element = $(element)
|
||||
continue if element.isDisabled()
|
||||
|
||||
tabIndex = parseInt(element.attr('tabindex'))
|
||||
continue unless tabIndex >= 0
|
||||
|
||||
callback(element, tabIndex)
|
||||
|
||||
focusNext: =>
|
||||
focusedTabIndex = parseInt($(':focus').attr('tabindex')) or -Infinity
|
||||
|
||||
nextElement = null
|
||||
nextTabIndex = Infinity
|
||||
lowestElement = null
|
||||
lowestTabIndex = Infinity
|
||||
@eachTabIndexedElement (element, tabIndex) ->
|
||||
if tabIndex < lowestTabIndex
|
||||
lowestTabIndex = tabIndex
|
||||
lowestElement = element
|
||||
|
||||
if focusedTabIndex < tabIndex < nextTabIndex
|
||||
nextTabIndex = tabIndex
|
||||
nextElement = element
|
||||
|
||||
if nextElement?
|
||||
nextElement.focus()
|
||||
else if lowestElement?
|
||||
lowestElement.focus()
|
||||
|
||||
focusPrevious: =>
|
||||
focusedTabIndex = parseInt($(':focus').attr('tabindex')) or Infinity
|
||||
|
||||
previousElement = null
|
||||
previousTabIndex = -Infinity
|
||||
highestElement = null
|
||||
highestTabIndex = -Infinity
|
||||
@eachTabIndexedElement (element, tabIndex) ->
|
||||
if tabIndex > highestTabIndex
|
||||
highestTabIndex = tabIndex
|
||||
highestElement = element
|
||||
|
||||
if focusedTabIndex > tabIndex > previousTabIndex
|
||||
previousTabIndex = tabIndex
|
||||
previousElement = element
|
||||
|
||||
if previousElement?
|
||||
previousElement.focus()
|
||||
else if highestElement?
|
||||
highestElement.focus()
|
||||
|
||||
showDevModeMessages: ->
|
||||
return unless NylasEnv.isMainWindow()
|
||||
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
<script src="index.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
@ -12,6 +12,10 @@ body {
|
|||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
*:focus {
|
||||
border:2px solid red !important;
|
||||
}
|
||||
|
||||
nylas-workspace {
|
||||
display: block;
|
||||
height: 100%;
|
||||
|
|
Loading…
Reference in a new issue