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:
Ben Gotow 2016-03-28 21:54:27 -07:00
parent 0f8725a747
commit ce7dcaa337
30 changed files with 523 additions and 750 deletions

View file

@ -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}

View file

@ -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>
);

View file

@ -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…">

View file

@ -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…">

View file

@ -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>

View file

@ -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();
}
}
}

View file

@ -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()

View 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

View file

@ -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">&bull;&bull;&bull;</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

View file

@ -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

View file

@ -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

View file

@ -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) ->

View file

@ -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

View file

@ -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}>

View file

@ -50,7 +50,6 @@ describe 'ParticipantsTextField', ->
field={@fieldName}
tabIndex={@tabIndex}
visible={true}
draftReady={true}
participants={@participants}
change={@propChange} />
)

View file

@ -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

View file

@ -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>

View file

@ -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>&nbsp;</span>

View file

@ -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!'

View file

@ -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}>

View file

@ -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/

View 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

View file

@ -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()

View file

@ -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>
);

View file

@ -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()

View file

@ -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"

View file

@ -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) =>

View file

@ -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()

View file

@ -39,7 +39,7 @@
}
</style>
<script src="index.js"></script>
</head>
<body>

View file

@ -12,6 +12,10 @@ body {
-webkit-font-smoothing: antialiased;
}
*:focus {
border:2px solid red !important;
}
nylas-workspace {
display: block;
height: 100%;