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:
Evan Morikawa 2015-10-30 20:03:33 -04:00
parent 595f80f75f
commit eea25a307d
15 changed files with 94 additions and 83 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
ClipboardService = require '../../src/components/clipboard-service'
ClipboardService = require '../../src/components/contenteditable/clipboard-service'
describe "ClipboardService", ->
beforeEach ->

View file

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

View file

@ -1,4 +1,4 @@
UndoManager = require "../src/flux/undo-manager"
UndoManager = require "../src/undo-manager"
describe "UndoManager", ->
beforeEach ->

View file

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

View file

@ -157,6 +157,10 @@ class DraftStore
session.teardown()
delete @_draftSessions[session.draftClientId]
_cleanupAllSessions: ->
for draftClientId, session of @_draftSessions
@_doneWithSession(session)
_onBeforeUnload: =>
promises = []

View file

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

View file

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