mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-01-31 12:30:14 +08:00
fix(composer): support Chinese & others - handle composition events
Summary: ignores composition event commands until they're done. We then simply update the new state after that happens. Some additional refactoring: - The <Contenteditable /> prop is 'value' instead of 'html' to make it look more like a standard React controlled input - Removed `filters` prop and `footerElements` prop from Contenteditable. These could easily be moved into the composer (where they belong). - Moved contenteditable and a few of its helper classes into their own folder. - Moved `UndoManager` up out of the `flux` folder into `src`. Currently undo/redo is only in the composer when all contenteditables should have the basic funcionality. Will refactor this later. - Fix tests Test Plan: manual Reviewers: bengotow Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D2211
This commit is contained in:
parent
595f80f75f
commit
eea25a307d
15 changed files with 94 additions and 83 deletions
|
@ -70,7 +70,7 @@ class PreferencesSignatures extends React.Component
|
|||
_renderCurrentSignature: ->
|
||||
<Contenteditable
|
||||
ref="signatureInput"
|
||||
html={@state.currentSignature}
|
||||
value={@state.currentSignature}
|
||||
onChange={@_onEditSignature}
|
||||
spellcheck={false}
|
||||
floatingToolbar={false} />
|
||||
|
|
|
@ -26,8 +26,6 @@ ImageFileUpload = require './image-file-upload'
|
|||
ExpandedParticipants = require './expanded-participants'
|
||||
CollapsedParticipants = require './collapsed-participants'
|
||||
|
||||
ContenteditableFilter = require './contenteditable-filter'
|
||||
|
||||
Fields = require './fields'
|
||||
|
||||
# The ComposerView is a unique React component because it (currently) is a
|
||||
|
@ -269,21 +267,20 @@ class ComposerView extends React.Component
|
|||
</div>
|
||||
|
||||
_renderBody: =>
|
||||
<span>
|
||||
<span ref="composerBodyWrap">
|
||||
{@_renderBodyContenteditable()}
|
||||
{@_renderQuotedTextControl()}
|
||||
{@_renderAttachments()}
|
||||
</span>
|
||||
|
||||
_renderBodyContenteditable: ->
|
||||
<Contenteditable
|
||||
ref={Fields.Body}
|
||||
html={@state.body}
|
||||
value={@_removeQuotedText(@state.body)}
|
||||
onFocus={ => @setState focusedField: Fields.Body}
|
||||
filters={@_editableFilters()}
|
||||
onChange={@_onChangeBody}
|
||||
onScrollTo={@props.onRequestScrollTo}
|
||||
onFilePaste={@_onFilePaste}
|
||||
footerElements={@_editableFooterElements()}
|
||||
onScrollToBottom={@_onScrollToBottom()}
|
||||
lifecycleCallbacks={@_contenteditableLifecycleCallbacks()}
|
||||
getComposerBoundingRect={@_getComposerBoundingRect}
|
||||
|
@ -333,18 +330,6 @@ class ComposerView extends React.Component
|
|||
position: ScrollRegion.ScrollPosition.Bottom
|
||||
else return null
|
||||
|
||||
_editableFilters: ->
|
||||
return [@_quotedTextFilter()]
|
||||
|
||||
_quotedTextFilter: ->
|
||||
filter = new ContenteditableFilter
|
||||
filter.beforeDisplay = @_removeQuotedText
|
||||
filter.afterDisplay = @_showQuotedText
|
||||
return filter
|
||||
|
||||
_editableFooterElements: ->
|
||||
@_renderQuotedTextControl()
|
||||
|
||||
_removeQuotedText: (html) =>
|
||||
if @state.showQuotedText then return html
|
||||
else return QuotedHTMLParser.removeQuotedHTML(html)
|
||||
|
@ -614,6 +599,8 @@ class ComposerView extends React.Component
|
|||
_onChangeBody: (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,
|
||||
|
@ -623,7 +610,7 @@ class ComposerView extends React.Component
|
|||
# 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: event.target.value}, {fromBodyChange: true})
|
||||
@_addToProxy({body: newBody}, {fromBodyChange: true})
|
||||
@_throttledTrigger ?= _.debounce =>
|
||||
@_ignoreNextTrigger = false
|
||||
@_proxy.trigger()
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
class ContenteditableFilter
|
||||
# Gets called immediately before insert the HTML into the DOM. This is
|
||||
# useful for modifying what the user sees compared to the data we're
|
||||
# storing.
|
||||
beforeDisplay: ->
|
||||
|
||||
# Gets called just after the content has changed but just before we save
|
||||
# out the new HTML. The inverse of `beforeDisplay`
|
||||
afterDisplay: ->
|
||||
|
||||
module.exports = ContenteditableFilter
|
|
@ -8,17 +8,26 @@ React = require "react/addons"
|
|||
ReactTestUtils = React.addons.TestUtils
|
||||
|
||||
Fields = require '../lib/fields'
|
||||
Composer = require "../lib/composer-view",
|
||||
{Contenteditable} = require 'nylas-component-kit'
|
||||
Composer = require "../lib/composer-view"
|
||||
{DraftStore} = require 'nylas-exports'
|
||||
|
||||
describe "Contenteditable", ->
|
||||
describe "Composer Quoted Text", ->
|
||||
beforeEach ->
|
||||
@onChange = jasmine.createSpy('onChange')
|
||||
@htmlNoQuote = 'Test <strong>HTML</strong><br>'
|
||||
@htmlWithQuote = 'Test <strong>HTML</strong><br><blockquote class="gmail_quote">QUOTE</blockquote>'
|
||||
|
||||
@composer = ReactTestUtils.renderIntoDocument(<Composer draftClientId="unused"/>)
|
||||
spyOn(@composer, "_onChangeBody")
|
||||
@composer._proxy = trigger: ->
|
||||
spyOn(@composer, "_addToProxy")
|
||||
|
||||
spyOn(@composer, "_setupSession")
|
||||
spyOn(@composer, "_teardownForDraft")
|
||||
spyOn(@composer, "_deleteDraftIfEmpty")
|
||||
spyOn(@composer, "_renderAttachments")
|
||||
|
||||
afterEach ->
|
||||
DraftStore._cleanupAllSessions()
|
||||
|
||||
# Must be called with the test's scope
|
||||
setHTML = (newHTML) ->
|
||||
|
@ -34,6 +43,7 @@ describe "Contenteditable", ->
|
|||
showQuotedText: true
|
||||
@contentEditable = @composer.refs[Fields.Body]
|
||||
@$contentEditable = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@contentEditable, 'contentEditable'))
|
||||
@$composerBodyWrap = React.findDOMNode(@composer.refs.composerBodyWrap)
|
||||
|
||||
it 'should not display any quoted text', ->
|
||||
expect(@$contentEditable.innerHTML).toBe @htmlNoQuote
|
||||
|
@ -42,11 +52,11 @@ describe "Contenteditable", ->
|
|||
textToAdd = "MORE <strong>TEXT</strong>!"
|
||||
expect(@$contentEditable.innerHTML).toBe @htmlNoQuote
|
||||
setHTML.call(@, textToAdd + @htmlNoQuote)
|
||||
ev = @composer._onChangeBody.mostRecentCall.args[0]
|
||||
expect(ev.target.value).toEqual(textToAdd + @htmlNoQuote)
|
||||
ev = @composer._addToProxy.mostRecentCall.args[0].body
|
||||
expect(ev).toEqual(textToAdd + @htmlNoQuote)
|
||||
|
||||
it 'should not render the quoted-text-control toggle', ->
|
||||
toggles = ReactTestUtils.scryRenderedDOMComponentsWithClass(@contentEditable, 'quoted-text-control')
|
||||
toggles = ReactTestUtils.scryRenderedDOMComponentsWithClass(@composer, 'quoted-text-control')
|
||||
expect(toggles.length).toBe 0
|
||||
|
||||
|
||||
|
@ -57,27 +67,28 @@ describe "Contenteditable", ->
|
|||
showQuotedText: true
|
||||
@contentEditable = @composer.refs[Fields.Body]
|
||||
@$contentEditable = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@contentEditable, 'contentEditable'))
|
||||
@$composerBodyWrap = React.findDOMNode(@composer.refs.composerBodyWrap)
|
||||
|
||||
it 'should display the quoted text', ->
|
||||
expect(@$contentEditable.innerHTML).toBe @htmlWithQuote
|
||||
|
||||
it "should call `_onChangeBody` with the entire HTML string", ->
|
||||
it "should call `_addToProxy` with the entire HTML string", ->
|
||||
textToAdd = "MORE <strong>TEXT</strong>!"
|
||||
expect(@$contentEditable.innerHTML).toBe @htmlWithQuote
|
||||
setHTML.call(@, textToAdd + @htmlWithQuote)
|
||||
ev = @composer._onChangeBody.mostRecentCall.args[0]
|
||||
expect(ev.target.value).toEqual(textToAdd + @htmlWithQuote)
|
||||
ev = @composer._addToProxy.mostRecentCall.args[0].body
|
||||
expect(ev).toEqual(textToAdd + @htmlWithQuote)
|
||||
|
||||
it "should allow the quoted text to be changed", ->
|
||||
newText = 'Test <strong>NEW 1 HTML</strong><blockquote class="gmail_quote">QUOTE CHANGED!!!</blockquote>'
|
||||
expect(@$contentEditable.innerHTML).toBe @htmlWithQuote
|
||||
setHTML.call(@, newText)
|
||||
ev = @composer._onChangeBody.mostRecentCall.args[0]
|
||||
expect(ev.target.value).toEqual(newText)
|
||||
ev = @composer._addToProxy.mostRecentCall.args[0].body
|
||||
expect(ev).toEqual(newText)
|
||||
|
||||
describe 'quoted text control toggle button', ->
|
||||
beforeEach ->
|
||||
@toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@contentEditable, 'quoted-text-control')
|
||||
@toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@composer, 'quoted-text-control')
|
||||
|
||||
it 'should be rendered', ->
|
||||
expect(@toggle).toBeDefined()
|
||||
|
@ -92,6 +103,7 @@ describe "Contenteditable", ->
|
|||
showQuotedText: false
|
||||
@contentEditable = @composer.refs[Fields.Body]
|
||||
@$contentEditable = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@contentEditable, 'contentEditable'))
|
||||
@$composerBodyWrap = React.findDOMNode(@composer.refs.composerBodyWrap)
|
||||
|
||||
# The quoted text dom parser wraps stuff inertly in body tags
|
||||
wrapBody = (html) -> "<head></head><body>#{html}</body>"
|
||||
|
@ -99,27 +111,27 @@ describe "Contenteditable", ->
|
|||
it 'should not display any quoted text', ->
|
||||
expect(@$contentEditable.innerHTML).toBe @htmlNoQuote
|
||||
|
||||
it "should let you change the text, and then append the quoted text part to the end before firing `_onChangeBody`", ->
|
||||
it "should let you change the text, and then append the quoted text part to the end before firing `_addToProxy`", ->
|
||||
textToAdd = "MORE <strong>TEXT</strong>!"
|
||||
expect(@$contentEditable.innerHTML).toBe @htmlNoQuote
|
||||
setHTML.call(@, textToAdd + @htmlNoQuote)
|
||||
ev = @composer._onChangeBody.mostRecentCall.args[0]
|
||||
ev = @composer._addToProxy.mostRecentCall.args[0].body
|
||||
# Note that we expect the version WITH a quote while setting the
|
||||
# version withOUT a quote.
|
||||
expect(ev.target.value).toEqual(wrapBody(textToAdd + @htmlWithQuote))
|
||||
expect(ev).toEqual(wrapBody(textToAdd + @htmlWithQuote))
|
||||
|
||||
it "should let you add more html that looks like quoted text, and still properly appends the old quoted text", ->
|
||||
textToAdd = "Yo <blockquote class=\"gmail_quote\">I'm a fake quote</blockquote>"
|
||||
expect(@$contentEditable.innerHTML).toBe @htmlNoQuote
|
||||
setHTML.call(@, textToAdd + @htmlNoQuote)
|
||||
ev = @composer._onChangeBody.mostRecentCall.args[0]
|
||||
ev = @composer._addToProxy.mostRecentCall.args[0].body
|
||||
# Note that we expect the version WITH a quote while setting the
|
||||
# version withOUT a quote.
|
||||
expect(ev.target.value).toEqual(wrapBody(textToAdd + @htmlWithQuote))
|
||||
expect(ev).toEqual(wrapBody(textToAdd + @htmlWithQuote))
|
||||
|
||||
describe 'quoted text control toggle button', ->
|
||||
beforeEach ->
|
||||
@toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@contentEditable, 'quoted-text-control')
|
||||
@toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@composer, 'quoted-text-control')
|
||||
|
||||
it 'should be rendered', ->
|
||||
expect(@toggle).toBeDefined()
|
|
@ -1,4 +1,4 @@
|
|||
ClipboardService = require '../../src/components/clipboard-service'
|
||||
ClipboardService = require '../../src/components/contenteditable/clipboard-service'
|
||||
|
||||
describe "ClipboardService", ->
|
||||
beforeEach ->
|
||||
|
|
|
@ -6,7 +6,7 @@ _ = require "underscore"
|
|||
fs = require 'fs'
|
||||
React = require "react/addons"
|
||||
ReactTestUtils = React.addons.TestUtils
|
||||
Contenteditable = require "../../src/components/contenteditable",
|
||||
Contenteditable = require "../../src/components/contenteditable/contenteditable",
|
||||
|
||||
describe "Contenteditable", ->
|
||||
beforeEach ->
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
UndoManager = require "../src/flux/undo-manager"
|
||||
UndoManager = require "../src/undo-manager"
|
||||
|
||||
describe "UndoManager", ->
|
||||
beforeEach ->
|
||||
|
|
|
@ -1,20 +1,38 @@
|
|||
_ = require 'underscore'
|
||||
React = require 'react'
|
||||
|
||||
{Utils,
|
||||
DOMUtils} = require 'nylas-exports'
|
||||
|
||||
{Utils, DOMUtils} = require 'nylas-exports'
|
||||
ClipboardService = require './clipboard-service'
|
||||
FloatingToolbarContainer = require './floating-toolbar-container'
|
||||
|
||||
###
|
||||
Public: A modern, well-behaved, React-compatible contenteditable
|
||||
|
||||
This <Contenteditable /> component is fully React-compatible and behaves
|
||||
like a standard controlled input.
|
||||
|
||||
```javascript
|
||||
getInitialState: function() {
|
||||
return {value: '<strong>Hello!</strong>'};
|
||||
},
|
||||
handleChange: function(event) {
|
||||
this.setState({value: event.target.value});
|
||||
},
|
||||
render: function() {
|
||||
var value = this.state.value;
|
||||
return <Contenteditable type="text" value={value} onChange={this.handleChange} />;
|
||||
}
|
||||
```
|
||||
###
|
||||
class Contenteditable extends React.Component
|
||||
@displayName: "Contenteditable"
|
||||
@propTypes:
|
||||
html: React.PropTypes.string
|
||||
initialSelectionSnapshot: React.PropTypes.object
|
||||
|
||||
filters: React.PropTypes.array
|
||||
footerElements: React.PropTypes.node
|
||||
@propTypes:
|
||||
|
||||
# The current html state, as a string, of the contenteditable.
|
||||
value: React.PropTypes.string
|
||||
|
||||
initialSelectionSnapshot: React.PropTypes.object
|
||||
|
||||
# Passes an absolute top coordinate to scroll to.
|
||||
onChange: React.PropTypes.func.isRequired
|
||||
|
@ -23,7 +41,7 @@ class Contenteditable extends React.Component
|
|||
onScrollToBottom: React.PropTypes.func
|
||||
|
||||
# A series of callbacks that can get executed at various points along
|
||||
# the contenteditable. Has the keys:
|
||||
# the contenteditable.
|
||||
lifecycleCallbacks: React.PropTypes.object
|
||||
|
||||
spellcheck: React.PropTypes.bool
|
||||
|
@ -31,7 +49,6 @@ class Contenteditable extends React.Component
|
|||
floatingToolbar: React.PropTypes.bool
|
||||
|
||||
@defaultProps:
|
||||
filters: []
|
||||
spellcheck: true
|
||||
floatingToolbar: true
|
||||
lifecycleCallbacks:
|
||||
|
@ -105,16 +122,20 @@ class Contenteditable extends React.Component
|
|||
ref="contenteditable"
|
||||
contentEditable
|
||||
spellCheck={false}
|
||||
onBlur={@_onBlur}
|
||||
onFocus={@_onFocus}
|
||||
onClick={@_onClick}
|
||||
onPaste={@clipboardService.onPaste}
|
||||
onInput={@_onInput}
|
||||
onKeyDown={@_onKeyDown}
|
||||
dangerouslySetInnerHTML={@_dangerouslySetInnerHTML()}></div>
|
||||
{@props.footerElements}
|
||||
dangerouslySetInnerHTML={__html: @props.value}
|
||||
{...@_eventHandlers()}></div>
|
||||
</div>
|
||||
|
||||
_eventHandlers: =>
|
||||
onBlur: @_onBlur
|
||||
onFocus: @_onFocus
|
||||
onClick: @_onClick
|
||||
onPaste: @clipboardService.onPaste
|
||||
onInput: @_onInput
|
||||
onKeyDown: @_onKeyDown
|
||||
onCompositionEnd: @_onCompositionEnd
|
||||
onCompositionStart: @_onCompositionStart
|
||||
|
||||
focus: =>
|
||||
@_editableNode().focus()
|
||||
|
||||
|
@ -145,6 +166,13 @@ class Contenteditable extends React.Component
|
|||
# Note: Related to composer-view#_onClickComposeBody
|
||||
event.stopPropagation()
|
||||
|
||||
_onCompositionStart: =>
|
||||
@_teardownSelectionListeners()
|
||||
|
||||
_onCompositionEnd: =>
|
||||
@_setupSelectionListeners()
|
||||
@_onInput()
|
||||
|
||||
_onKeyDown: (event) =>
|
||||
if event.key is "Tab"
|
||||
@_onTabDown(event)
|
||||
|
@ -184,10 +212,7 @@ class Contenteditable extends React.Component
|
|||
@_createLists()
|
||||
|
||||
_saveNewHtml: ->
|
||||
html = @_editableNode().innerHTML
|
||||
for filter in @props.filters
|
||||
html = filter.afterDisplay(html)
|
||||
@props.onChange(target: {value: html})
|
||||
@props.onChange(target: {value: @_editableNode().innerHTML})
|
||||
|
||||
# Determines if the user wants to add an ordered or unordered list.
|
||||
_createLists: ->
|
||||
|
@ -479,12 +504,6 @@ class Contenteditable extends React.Component
|
|||
_editableNode: =>
|
||||
React.findDOMNode(@refs.contenteditable)
|
||||
|
||||
_dangerouslySetInnerHTML: =>
|
||||
html = @props.html
|
||||
for filter in @props.filters
|
||||
html = filter.beforeDisplay(html)
|
||||
return __html: html
|
||||
|
||||
######### SELECTION MANAGEMENT ##########
|
||||
#
|
||||
# Saving and restoring a selection is difficult with React.
|
|
@ -157,6 +157,10 @@ class DraftStore
|
|||
session.teardown()
|
||||
delete @_draftSessions[session.draftClientId]
|
||||
|
||||
_cleanupAllSessions: ->
|
||||
for draftClientId, session of @_draftSessions
|
||||
@_doneWithSession(session)
|
||||
|
||||
_onBeforeUnload: =>
|
||||
promises = []
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ class NylasComponentKit
|
|||
@load "DraggableImg", 'draggable-img'
|
||||
@load "EventedIFrame", 'evented-iframe'
|
||||
@load "ButtonDropdown", 'button-dropdown'
|
||||
@load "Contenteditable", 'contenteditable'
|
||||
@load "Contenteditable", 'contenteditable/contenteditable'
|
||||
@load "MultiselectList", 'multiselect-list'
|
||||
@load "InjectedComponent", 'injected-component'
|
||||
@load "TokenizingTextField", 'tokenizing-text-field'
|
||||
|
|
|
@ -128,7 +128,7 @@ class NylasExports
|
|||
@load "MessageUtils", 'flux/models/message-utils'
|
||||
|
||||
# Services
|
||||
@load "UndoManager", 'flux/undo-manager'
|
||||
@load "UndoManager", 'undo-manager'
|
||||
@load "SoundRegistry", 'sound-registry'
|
||||
@load "QuotedHTMLParser", 'services/quoted-html-parser'
|
||||
@load "QuotedPlainTextParser", 'services/quoted-plain-text-parser'
|
||||
|
|
Loading…
Reference in a new issue