feat(contenteditable): a React compatible contenteditable

Summary:
toolbar popup displays

restore caret protection on contenteditable

BAD - can't use cursor saving and restoring with react :(

_findNode works

saves and restores cursor state

contenteditable fixes to support cursor

comments on cursor

initial undo manager

extract undo manager and move up in stack

make undo manager a mixin

adding selection snapshots in composer

fixes in undo manager

selection saves selection states properly

move UndoManager and fix draft

selection state can now select backwards

selection works backwards and click not overridden

change bold class to allow for bolding and unbolding

styling of hover component

can set links in composer

bold and italic clicking works. text seleciton works

show link modal on hover

selection fixes

Test Plan: TODO

Reviewers: bengotow

Reviewed By: bengotow

Differential Revision: https://review.inboxapp.com/D1249
This commit is contained in:
Evan Morikawa 2015-03-02 15:33:58 -08:00
parent e1fc34a562
commit e7868df1ad
17 changed files with 925 additions and 139 deletions

View file

@ -1,5 +1,3 @@
# All Inbox Globals go here.
module.exports =
# The Task Queue
@ -35,6 +33,9 @@ module.exports =
Event: require '../src/flux/models/event'
SalesforceTask: require '../src/flux/models/salesforce-task'
# Mixins
UndoManager: require '../src/flux/undo-manager'
# Stores
DraftStore: require '../src/flux/stores/draft-store'
ThreadStore: require '../src/flux/stores/thread-store'

View file

@ -2,17 +2,16 @@ React = require 'react'
_ = require 'underscore-plus'
{Actions,
UndoManager,
DraftStore,
FileUploadStore,
ComponentRegistry} = require 'inbox-exports'
FileUploads = require './file-uploads.cjsx'
ContenteditableToolbar = require './contenteditable-toolbar.cjsx'
ContenteditableComponent = require './contenteditable-component.cjsx'
ParticipantsTextField = require './participants-text-field.cjsx'
idGen = 0
# The ComposerView is a unique React component because it (currently) is a
# singleton. Normally, the React way to do things would be to re-render the
# Composer with new props. As an alternative, we can call `setProps` to
@ -41,11 +40,14 @@ ComposerView = React.createClass
@_prepareForDraft()
componentDidMount: ->
@undoManager = new UndoManager
@keymap_unsubscriber = atom.commands.add '.composer-outer-wrap', {
'composer:show-and-focus-bcc': @_showAndFocusBcc
'composer:show-and-focus-cc': @_showAndFocusCc
'composer:focus-to': => @focus "textFieldTo"
'composer:send-message': => @_sendDraft()
"core:undo": @undo
"core:redo": @redo
}
if @props.mode is "fullwindow"
# Need to delay so the component can be fully painted. Focus doesn't
@ -58,6 +60,14 @@ ComposerView = React.createClass
@_teardownForDraft()
@keymap_unsubscriber.dispose()
componentDidUpdate: ->
# We want to use a temporary variable instead of putting this into the
# state. This is because the selection is a transient property that
# only needs to be applied once. It's not a long-living property of
# the state. We could call `setState` here, but this saves us from a
# re-rendering.
@_recoveredSelection = null if @_recoveredSelection?
componentWillReceiveProps: (newProps) ->
if newProps.localId != @props.localId
# When we're given a new draft localId, we have to stop listening to our
@ -70,7 +80,7 @@ ComposerView = React.createClass
@_proxy = DraftStore.sessionForLocalId(@props.localId)
if @_proxy.draft()
@_onDraftChanged()
@unlisteners = []
@unlisteners.push @_proxy.listen(@_onDraftChanged)
@unlisteners.push ComponentRegistry.listen (event) =>
@ -125,7 +135,7 @@ ComposerView = React.createClass
<ParticipantsTextField
ref="textFieldTo"
field='to'
change={@_proxy.changes.add}
change={@_onChangeParticipants}
participants={to: @state['to'], cc: @state['cc'], bcc: @state['bcc']}
tabIndex='102'/>
@ -133,7 +143,7 @@ ComposerView = React.createClass
ref="textFieldCc"
field='cc'
visible={@state.showcc}
change={@_proxy.changes.add}
change={@_onChangeParticipants}
participants={to: @state['to'], cc: @state['cc'], bcc: @state['bcc']}
tabIndex='103'/>
@ -141,7 +151,7 @@ ComposerView = React.createClass
ref="textFieldBcc"
field='bcc'
visible={@state.showcc}
change={@_proxy.changes.add}
change={@_onChangeParticipants}
participants={to: @state['to'], cc: @state['cc'], bcc: @state['bcc']}
tabIndex='104'/>
@ -158,12 +168,12 @@ ComposerView = React.createClass
onChange={@_onChangeSubject}/>
</div>
<div className="compose-body"
onClick={=> @focus("contentBody")}>
<div className="compose-body">
<ContenteditableComponent ref="contentBody"
onChange={@_onChangeBody}
html={@state.body}
tabIndex="109" />
html={@state.body}
onChange={@_onChangeBody}
initialSelectionSnapshot={@_recoveredSelection}
tabIndex="109" />
</div>
<div className="attachments-area" >
@ -177,7 +187,6 @@ ComposerView = React.createClass
<button className="btn btn-send"
tabIndex="110"
onClick={@_sendDraft}><i className="fa fa-send"></i>&nbsp;Send</button>
<ContenteditableToolbar />
<button className="btn btn-icon"
onClick={@_attachFile}><i className="fa fa-paperclip"></i></button>
{@_footerComponents()}
@ -201,6 +210,9 @@ ComposerView = React.createClass
_onDraftChanged: ->
draft = @_proxy.draft()
if not @_initialHistorySave
@_saveToHistory()
@_initialHistorySave = true
state =
to: draft.to
cc: draft.cc
@ -217,11 +229,18 @@ ComposerView = React.createClass
@setState(state)
_onChangeSubject: (event) ->
@_proxy.changes.add(subject: event.target.value)
_onChangeParticipants: (changes={}) -> @_addToProxy(changes)
_onChangeSubject: (event) -> @_addToProxy(subject: event.target.value)
_onChangeBody: (event) -> @_addToProxy(body: event.target.value)
_onChangeBody: (event) ->
@_proxy.changes.add(body: event.target.value)
_addToProxy: (changes={}, source={}) ->
selections = @_getSelections()
oldDraft = @_proxy.draft()
return if _.all changes, (change, key) -> change == oldDraft[key]
@_proxy.changes.add(changes)
@_saveToHistory(selections) unless source.fromUndoManager
_popoutComposer: ->
@_proxy.changes.commit()
@ -273,3 +292,49 @@ ComposerView = React.createClass
_showAndFocusCc: ->
@setState {showcc: true}
@focus "textFieldCc"
undo: (event) ->
event.preventDefault()
event.stopPropagation()
historyItem = @undoManager.undo() ? {}
return unless historyItem.state?
@_recoveredSelection = historyItem.currentSelection
@_addToProxy historyItem.state, fromUndoManager: true
redo: (event) ->
event.preventDefault()
event.stopPropagation()
historyItem = @undoManager.redo() ? {}
return unless historyItem.state?
@_recoveredSelection = historyItem.currentSelection
@_addToProxy historyItem.state, fromUndoManager: true
_getSelections: ->
currentSelection: @refs.contentBody?.getCurrentSelection?()
previousSelection: @refs.contentBody?.getPreviousSelection?()
_saveToHistory: (selections) ->
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
lastState = @undoManager.current()
if lastState?
lastState.currentSelection = historyItem.previousSelection
@undoManager.saveToHistory(historyItem)

View file

@ -2,64 +2,471 @@ _ = require 'underscore-plus'
React = require 'react'
sanitizeHtml = require 'sanitize-html'
{Utils} = require 'inbox-exports'
FloatingToolbar = require './floating-toolbar.cjsx'
module.exports =
ContenteditableComponent = React.createClass
getInitialState: ->
toolbarTop: 0
toolbarMode: "buttons"
toolbarLeft: 0
editAreaWidth: 9999 # This will get set on first selection
toolbarVisible: false
editQuotedText: false
getEditableNode: ->
@refs.contenteditable.getDOMNode()
componentDidMount: ->
@_setupSelectionListeners()
@_setupLinkHoverListeners()
render: ->
quotedTextClass = React.addons.classSet
"quoted-text-toggle": true
'hidden': @_htmlQuotedTextStart() is -1
'state-on': @state.editQuotedText
componentWillUnmount: ->
@_teardownSelectionListeners()
@_teardownLinkHoverListeners()
<div className="contenteditable-container">
<div id="contenteditable"
ref="contenteditable"
className="scribe native-key-bindings"
contentEditable
onInput={@_onChange}
onPaste={@_onPaste}
tabIndex={@props.tabIndex}
onBlur={@_onChange}
dangerouslySetInnerHTML={{__html: @_htmlForDisplay()}}></div>
<a className={quotedTextClass} onClick={@_onToggleQuotedText}></a>
</div>
componentWillReceiveProps: (nextProps) ->
if nextProps.initialSelectionSnapshot?
@_setSelectionSnapshot(nextProps.initialSelectionSnapshot)
shouldComponentUpdate: (nextProps, nextState) ->
return true if nextState.editQuotedText is not @state.editQuotedText
html = @getEditableNode().innerHTML
return (nextProps.html isnt html) and (document.activeElement isnt @getEditableNode())
componentWillUpdate: ->
@_teardownLinkHoverListeners()
componentDidUpdate: ->
if (@props.html != @getEditableNode().innerHTML)
@getEditableNode().innerHTML = @_htmlForDisplay()
@_setupLinkHoverListeners()
@_restoreSelection()
render: ->
<div className="contenteditable-container">
<FloatingToolbar ref="floatingToolbar"
top={@state.toolbarTop}
left={@state.toolbarLeft}
visible={@state.toolbarVisible}
tabIndex={@props.tabIndex}
onSaveUrl={@_onSaveUrl}
initialMode={@state.toolbarMode}
onMouseEnter={@_onTooltipMouseEnter}
onMouseLeave={@_onTooltipMouseLeave}
linkToModify={@state.linkToModify}
contentPadding={@CONTENT_PADDING}
editAreaWidth={@state.editAreaWidth} />
<div id="contenteditable"
ref="contenteditable"
contentEditable
tabIndex={@props.tabIndex}
onBlur={@_onBlur}
onPaste={@_onPaste}
onInput={@_onInput}
onMouseUp={@_onMouseUp}
onMouseDown={@_onMouseDown}
dangerouslySetInnerHTML={@_dangerouslySetInnerHTML()}></div>
<a className={@_quotedTextClasses()} onClick={@_onToggleQuotedText}></a>
</div>
focus: ->
@getEditableNode().focus()
@_editableNode().focus() if @isMounted()
_onChange: (evt) ->
html = @getEditableNode().innerHTML
_onInput: (event) ->
@_setNewSelectionState()
html = @_unapplyHTMLDisplayFilters(@_editableNode().innerHTML)
@props.onChange(target: value: html)
# If we aren't displaying quoted text, add the quoted
# text to the end of the visible text
if not @state.editQuotedText
quoteStart = @_htmlQuotedTextStart()
html += @props.html.substr(quoteStart)
_onBlur: (event) ->
# The delay here is necessary to see if the blur was caused by us
# navigating to the toolbar and focusing on the set-url input.
_.delay =>
return unless @isMounted() # Who knows what can happen in 50ms
@_hideToolbar()
, 50
if html != @lastHtml
@props.onChange({target: {value: html}}) if @props.onChange
@lastHtml = html
_editableNode: -> @refs.contenteditable.getDOMNode()
_onToggleQuotedText: ->
@setState
editQuotedText: !@state.editQuotedText
_getAllLinks: ->
Array.prototype.slice.call(@_editableNode().querySelectorAll("*[href]"))
_dangerouslySetInnerHTML: ->
__html: @_applyHTMLDisplayFilters(@props.html)
_applyHTMLDisplayFilters: (html) ->
html = @_ensureNotCompletelyBlank(html)
html = @_removeQuotedTextFromHTML(html) unless @state.editQuotedText
return html
_unapplyHTMLDisplayFilters: (html) ->
html = @_addQuotedTextToHTML(html) unless @state.editQuotedText
return html
######### SELECTION MANAGEMENT ##########
#
# Saving and restoring a selection is difficult with React.
#
# React only handles Input and Textarea elements:
# https://github.com/facebook/react/blob/master/src/browser/ui/ReactInputSelection.js
# This is because they expose a very convenient `selectionStart` and
# `selectionEnd` integer.
#
# Contenteditable regions are trickier. They require the more
# sophisticated `Range` and `Selection` APIs.
#
# Range docs:
# http://www.w3.org/TR/DOM-Level-2-Traversal-Range/ranges.html
#
# Selection API docs:
# http://www.w3.org/TR/selection-api/#dfn-range
#
# A Contenteditable region can have arbitrary html inside of it. This
# means that a selection start point can be some node (the `anchorNode`)
# and its end point can be a completely different node (the `focusNode`)
#
# When React re-renders, all of the DOM nodes may change. They may
# look exactly the same, but have different object references.
#
# This means that your old references to `anchorNode` and `focusNode`
# may be bad and no longer in scope or painted.
#
# In order to restore the selection properly we need to re-find the
# equivalent `anchorNode` and `focusNode`. Luckily we can use the
# `isEqualNode` method to get a shallow comparison of the nodes.
#
# Unfortunately it's possible for `isEqualNode` to match more than one
# node since two nodes may look very similar.
#
# To fix this we need to keep track of the original indices to determine
# which node is most likely the matching one.
# http://www.w3.org/TR/selection-api/#selectstart-event
_setupSelectionListeners: ->
@_onSelectionChange = => @_setNewSelectionState()
document.addEventListener "selectionchange", @_onSelectionChange
_teardownSelectionListeners: ->
document.removeEventListener("selectionchange", @_onSelectionChange)
getCurrentSelection: -> _.clone(@_selection ? {})
getPreviousSelection: -> _.clone(@_previousSelection ? {})
# Every time the cursor changes we need to preserve its location and
# state.
#
# We can't use React's `state` variable because cursor position is not
# natrually supported in the virtual DOM.
#
# We also need to make sure that node references are cloned so they
# don't change out from underneath us.
#
# We also need to keep references to the previous selection state in
# order for undo/redo to work properly.
#
# We need to be sure to deeply `cloneNode`. This is because sometimes
# our anchorNodes are divs with nested <br> tags. If we don't do a deep
# clone then when `isEqualNode` is run it will erroneously return false
# and our selection restoration will fail
_setNewSelectionState: ->
selection = document.getSelection()
return if @_checkSameSelection(selection)
return if not @_selectionInScope(selection)
@_lastSelection =
anchorNode: selection.anchorNode?.cloneNode(true)
anchorOffset: selection.anchorOffset
focusNode: selection.focusNode?.cloneNode(true)
focusOffset: selection.focusOffset
try
range = selection.getRangeAt(0)
catch
return
return if not range?
@_previousSelection = @_selection
@_selection =
startNode: range.startContainer?.cloneNode(true)
startOffset: range.startOffset
startNodeIndex: @_getNodeIndex(range.startContainer)
endNode: range.endContainer?.cloneNode(true)
endOffset: range.endOffset
endNodeIndex: @_getNodeIndex(range.endContainer)
@_refreshToolbarState()
return @_selection
_setSelectionSnapshot: (selection) -> @_selection = selection
# When we're dragging we don't want to the restoring the cursor as we're
# dragging. Doing so caused selecting backwards to break because the
# Selection API does not yet expose the selection "direction". When we
# would go to reset the cursor selection, it would reset to the wrong
# state.
_onMouseDown: (event) ->
@_ignoreSelectionRestoration = true
return true
_onMouseUp: ->
@_ignoreSelectionRestoration = false
return true
# We manually restore the selection on every render and when we need to
# move the selection around manually.
#
# force - when set to true it will not care whether or not the selection
# is already in the box. Normally we only restore when the
# contenteditable is in focus
# collapse - Can either be "end" or "start". When we reset the
# selection, we'll collapse the range into a single caret
# position
_restoreSelection: ({force, collapse}={}) ->
return if @_ignoreSelectionRestoration
return if not @_selection?
return if document.activeElement isnt @_editableNode() and not force
return if not @_selection.startNode? or not @_selection.endNode?
range = document.createRange()
startNode = @_findSimilarNodes(@_selection.startNode)[@_selection.startNodeIndex]
endNode = @_findSimilarNodes(@_selection.endNode)[@_selection.endNodeIndex]
return if not startNode? or not endNode?
startIndex = Math.min(@_selection.startOffset ? 0, @_selection.endOffset ? 0)
startIndex = Math.min(startIndex, startNode.length)
endIndex = Math.max(@_selection.startOffset ? 0, @_selection.endOffset ? 0)
endIndex = Math.min(endIndex, endNode.length)
if collapse is "end"
startNode = endNode
startIndex = endIndex
else if collapse is "start"
endNode = startNode
endIndex = endIndex
try
range.setStart(startNode, startIndex)
range.setEnd(endNode, endIndex)
catch
return
selection = document.getSelection()
@_teardownSelectionListeners()
selection.removeAllRanges()
selection.addRange(range)
@_setupSelectionListeners()
# We need to break each node apart and cache since the `selection`
# object will mutate underneath us.
_checkSameSelection: (selection) ->
return true if not selection?
return false if not @_lastSelection
return false if not selection.anchorNode? or not selection.focusNode?
anchorEqual = selection.anchorNode.isEqualNode @_lastSelection.anchorNode
focusEqual = selection.focusNode.isEqualNode @_lastSelection.focusNode
anchorOffsetEqual = selection.anchorOffset == @_lastSelection.anchorOffset
focusOffsetEqual = selection.focusOffset == @_lastSelection.focusOffset
if not anchorOffsetEqual and not focusOffsetEqual
# This means the selection is the same, but just from the opposite
# direction. We don't care in this case, so check the reciprocal as
# well.
anchorOffsetEqual = selection.anchorOffset == @_lastSelection.focusOffset
focusOffsetEqual = selection.focusOffset == @_lastSelection.anchorOffset
if (@_lastSelection? and
anchorEqual and
anchorOffsetEqual and
focusEqual and
focusOffsetEqual)
return true
else
return false
_getNodeIndex: (nodeToFind) ->
@_findSimilarNodes(nodeToFind).indexOf nodeToFind
_findSimilarNodes: (nodeToFind) ->
nodeList = []
treeWalker = document.createTreeWalker @_editableNode()
while treeWalker.nextNode()
if treeWalker.currentNode.isEqualNode nodeToFind
nodeList.push(treeWalker.currentNode)
return nodeList
_isEqualNode: ->
# This is so the contenteditable can have a non-blank TextNode for the
# selection to lock onto.
_ensureNotCompletelyBlank: (html) ->
if html.length is 0
return "&nbsp;"
else return html
_linksInside: (selection) ->
return _.filter @_getAllLinks(), (link) ->
selection.containsNode(link, true)
####### TOOLBAR ON SELECTION #########
# We want the toolbar's state to be declaratively defined from other
# states.
#
# There are a variety of conditions that the toolbar should display:
# 1. When you're hovering over a link
# 2. When you've arrow-keyed the cursor into a link
# 3. When you have selected a range of text.
_refreshToolbarState: ->
if @_linkHoveringOver
url = @_linkHoveringOver.getAttribute('href')
rect = @_linkHoveringOver.getBoundingClientRect()
[left, top, editAreaWidth] = @_getToolbarPos(rect)
@setState
toolbarVisible: true
toolbarMode: "edit-link"
toolbarTop: top
toolbarLeft: left
linkToModify: @_linkHoveringOver
editAreaWidth: editAreaWidth
else
selection = document.getSelection()
# TODO do something smarter then this in the future
linksInside = [] # @_linksInside(selection)
if selection.isCollapsed and linksInside.length is 0
@_hideToolbar()
else
if selection.isCollapsed and linksInside.length > 0
linkRect = linksInside[0].getBoundingClientRect()
[left, top, editAreaWidth] = @_getToolbarPos(linkRect)
else
selectionRect = selection.getRangeAt(0).getBoundingClientRect()
[left, top, editAreaWidth] = @_getToolbarPos(selectionRect)
@setState
toolbarVisible: true
toolbarMode: "buttons"
toolbarTop: top
toolbarLeft: left
linkToModify: null
editAreaWidth: editAreaWidth
# See selection API: http://www.w3.org/TR/selection-api/
_selectionInScope: (selection) ->
return false if not selection?
return false if not @isMounted()
editNode = @refs.contenteditable.getDOMNode()
return (editNode.contains(selection.anchorNode) and
editNode.contains(selection.focusNode))
CONTENT_PADDING: 15
_getToolbarPos: (referenceRect) ->
TOP_PADDING = 10
editArea = @refs.contenteditable.getDOMNode().getBoundingClientRect()
calcLeft = (referenceRect.left - editArea.left) + referenceRect.width/2
calcLeft = Math.min(Math.max(calcLeft, @CONTENT_PADDING), editArea.width)
calcTop = referenceRect.top - editArea.top + referenceRect.height + TOP_PADDING
return [calcLeft, calcTop, editArea.width]
_hideToolbar: ->
if not @_focusedOnToolbar() and @state.toolbarVisible
@setState toolbarVisible: false
_focusedOnToolbar: ->
@refs.floatingToolbar.getDOMNode().contains(document.activeElement)
# This needs to be in the contenteditable area because we need to first
# restore the selection before calling the `execCommand`
#
# If the url is empty, that means we want to remove the url.
_onSaveUrl: (url, linkToModify) ->
if linkToModify?
linkToModify = @_findSimilarNodes(linkToModify)?[0]?.childNodes[0]
return if not linkToModify?
range = document.createRange()
try
range.setStart(linkToModify, 0)
range.setEnd(linkToModify, linkToModify.length)
catch
return
selection = document.getSelection()
@_teardownSelectionListeners()
selection.removeAllRanges()
selection.addRange(range)
if url.trim().length is 0
document.execCommand("unlink", false)
else
document.execCommand("createLink", false, url)
@_setupSelectionListeners()
else
@_restoreSelection(force: true)
if document.getSelection().isCollapsed
# TODO
else
if url.trim().length is 0
document.execCommand("unlink", false)
else
document.execCommand("createLink", false, url)
@_restoreSelection(force: true, collapse: "end")
_setupLinkHoverListeners: ->
HOVER_IN_DELAY = 250
HOVER_OUT_DELAY = 1000
@_links = []
links = @_getAllLinks()
return if links.length is 0
links.forEach (link) =>
enterTimeout = null
leaveTimeout = null
enterListener = (event) =>
enterTimeout = setTimeout =>
return unless @isMounted()
@_linkHoveringOver = link
@_refreshToolbarState()
, HOVER_IN_DELAY
leaveListener = (event) =>
leaveTimeout = setTimeout =>
@_linkHoveringOver = null
return unless @isMounted()
return if @refs.floatingToolbar.isHovering
@_refreshToolbarState()
, HOVER_OUT_DELAY
clearTimeout(enterTimeout)
link.addEventListener "mouseenter", enterListener
link.addEventListener "mouseleave", leaveListener
@_links.push
link: link
enterTimeout: enterTimeout
leaveTimeout: leaveTimeout
enterListener: enterListener
leaveListener: leaveListener
_onTooltipMouseEnter: ->
clearTimeout(@_clearTooltipTimeout) if @_clearTooltipTimeout?
_onTooltipMouseLeave: ->
@_clearTooltipTimeout = setTimeout =>
@_refreshToolbarState()
, 500
_teardownLinkHoverListeners: ->
while @_links.length > 0
linkData = @_links.pop()
clearTimeout linkData.enterTimeout
clearTimeout linkData.leaveTimeout
linkData.link.removeEventListener "mouseenter", linkData.enterListener
linkData.link.removeEventListener "mouseleave", linkData.leaveListener
####### CLEAN PASTE #########
_onPaste: (evt) ->
html = evt.clipboardData.getData("text/html") ? ""
@ -97,16 +504,29 @@ ContenteditableComponent = React.createClass
cleanHtml.replace(/<p>/gi, "").replace(/<\/p>/gi, "<br/><br/>")
####### QUOTED TEXT #########
_onToggleQuotedText: ->
@setState
editQuotedText: !@state.editQuotedText
_quotedTextClasses: -> React.addons.classSet
"quoted-text-control": true
"no-quoted-text": @_htmlQuotedTextStart() is -1
"show-quoted-text": @state.editQuotedText
_htmlQuotedTextStart: ->
@props.html.search(/<[^>]*gmail_quote/)
_htmlForDisplay: ->
if @state.editQuotedText
@props.html
else
quoteStart = @_htmlQuotedTextStart()
if quoteStart is -1
return @props.html
else
return @props.html.substr(0, quoteStart)
_removeQuotedTextFromHTML: (html) ->
quoteStart = @_htmlQuotedTextStart()
if quoteStart is -1 then return html
else return html.substr(0, quoteStart)
_addQuotedTextToHTML: (innerHTML) ->
quoteStart = @_htmlQuotedTextStart()
if quoteStart is -1 then return innerHTML
else return (innerHTML + @props.html.substr(quoteStart))

View file

@ -1,36 +1,41 @@
React = require 'react'
# OBSOLETE: Use FloatingToolbar instead.
# However, We may decide to change back to a static toolbar in the footer,
# so don't delete me yet.
module.exports =
ContenteditableToolbar = React.createClass
render: ->
style =
display: @state.show and 'initial' or 'none'
<div className="compose-toolbar-wrap" onBlur={@onBlur}>
<button className="btn btn-icon btn-formatting"
onClick={=> @setState { show: !@state.show }}
><i className="fa fa-font"></i></button>
<div ref="toolbar" className="compose-toolbar" style={style}>
<button className="btn btn-bold" onClick={@onClick} data-command-name="bold"><strong>B</strong></button>
<button className="btn btn-italic" onClick={@onClick} data-command-name="italic"><em>I</em></button>
<button className="btn btn-underline" onClick={@onClick} data-command-name="underline"><span style={'textDecoration': 'underline'}>U</span></button>
</div>
</div>
getInitialState: ->
show: false
componentDidUpdate: (lastProps, lastState) ->
if !lastState.show and @state.show
@refs.toolbar.getDOMNode().focus()
onClick: (event) ->
cmd = event.currentTarget.getAttribute 'data-command-name'
document.execCommand(cmd, false, null)
true
onBlur: (event) ->
target = event.nativeEvent.relatedTarget
if target? and target.getAttribute 'data-command-name'
return
@setState
show: false
# React = require 'react'
#
# module.exports =
# ContenteditableToolbar = React.createClass
# render: ->
# style =
# display: @state.show and 'initial' or 'none'
# <div className="compose-toolbar-wrap" onBlur={@onBlur}>
# <button className="btn btn-icon btn-formatting"
# onClick={=> @setState { show: !@state.show }}
# ><i className="fa fa-font"></i></button>
# <div ref="toolbar" className="compose-toolbar" style={style}>
# <button className="btn btn-bold" onClick={@onClick} data-command-name="bold"><strong>B</strong></button>
# <button className="btn btn-italic" onClick={@onClick} data-command-name="italic"><em>I</em></button>
# <button className="btn btn-underline" onClick={@onClick} data-command-name="underline"><span style={'textDecoration': 'underline'}>U</span></button>
# </div>
# </div>
#
# getInitialState: ->
# show: false
#
# componentDidUpdate: (lastProps, lastState) ->
# if !lastState.show and @state.show
# @refs.toolbar.getDOMNode().focus()
#
# onClick: (event) ->
# cmd = event.currentTarget.getAttribute 'data-command-name'
# document.execCommand(cmd, false, null)
# true
#
# onBlur: (event) ->
# target = event.nativeEvent.relatedTarget
# if target? and target.getAttribute 'data-command-name'
# return
# @setState
# show: false

View file

@ -0,0 +1,157 @@
_ = require 'underscore-plus'
React = require 'react'
{CompositeDisposable} = require 'event-kit'
module.exports =
FloatingToolbar = React.createClass
getInitialState: ->
mode: "buttons"
urlInputValue: @_initialUrl() ? ""
componentDidMount: ->
@isHovering = false
@subscriptions = new CompositeDisposable()
@_saveUrl = _.debounce @__saveUrl, 10
componentWillReceiveProps: (nextProps) ->
@setState
mode: nextProps.initialMode
urlInputValue: @_initialUrl(nextProps)
componentWillUnmount: ->
@subscriptions?.dispose()
@isHovering = false
componentDidUpdate: ->
if @state.mode is "edit-link" and not @props.linkToModify
# Note, it's important that we're focused on the urlInput because
# the parent of this component needs to know to not hide us on their
# onBlur method.
@refs.urlInput.getDOMNode().focus() if @isMounted()
render: ->
<div ref="floatingToolbar"
className="floating-toolbar toolbar" style={@_toolbarStyles()}>
<div className="toolbar-pointer" style={@_toolbarPointerStyles()}></div>
{@_toolbarType()}
</div>
_toolbarType: ->
if @state.mode is "buttons" then @_renderButtons()
else if @state.mode is "edit-link" then @_renderLink()
else return <div></div>
_renderButtons: ->
<div className="toolbar-buttons">
<button className="btn btn-bold btn-icon"
onClick={@_execCommand}
data-command-name="bold"><strong>B</strong></button>
<button className="btn btn-italic btn-icon"
onClick={@_execCommand}
data-command-name="italic"><em>I</em></button>
<button className="btn btn-link btn-icon"
onClick={@_showLink}
data-command-name="link"><i className="fa fa-link"></i></button>
</div>
_renderLink: ->
removeBtn = ""
if @_initialUrl()
removeBtn = <button className="btn btn-icon"
onMouseDown={@_removeUrl}><i className="fa fa-times"></i></button>
<div className="toolbar-new-link"
onMouseEnter={@_onMouseEnter}
onMouseLeave={@_onMouseLeave}>
<i className="fa fa-link preview-btn-icon"></i>
<input type="text"
ref="urlInput"
value={@state.urlInputValue}
onBlur={@_saveUrl}
onKeyPress={@_saveUrlOnEnter}
onChange={@_onInputChange}
className="floating-toolbar-input"
placeholder="Paste or type a link" />
<button className="btn btn-icon"
onKeyPress={@_saveUrlOnEnter}
onMouseDown={@_saveUrl}><i className="fa fa-check"></i></button>
{removeBtn}
</div>
_onMouseEnter: ->
@isHovering = true
@props.onMouseEnter?()
_onMouseLeave: ->
@isHovering = false
if @props.linkToModify and document.activeElement isnt @refs.urlInput.getDOMNode()
@props.onMouseLeave?()
_initialUrl: (props=@props) ->
props.linkToModify?.getAttribute?('href')
_onInputChange: (event) ->
@setState urlInputValue: event.target.value
_saveUrlOnEnter: (event) ->
if event.key is "Enter" and @state.urlInputValue.trim().length > 0
@_saveUrl()
# We signify the removal of a url with an empty string. This protects us
# from the case where people delete the url text and hit save. In that
# case we also want to remove the link.
_removeUrl: ->
@setState urlInputValue: ""
@props.onSaveUrl "", @props.linkToModify
__saveUrl: ->
@props.onSaveUrl @state.urlInputValue, @props.linkToModify
_execCommand: (event) ->
cmd = event.currentTarget.getAttribute 'data-command-name'
document.execCommand(cmd, false, null)
true
_toolbarStyles: ->
styles =
left: @_toolbarLeft()
top: @props.top
display: if @props.visible then "block" else "none"
return styles
_toolbarLeft: ->
CONTENT_PADDING = @props.contentPadding ? 15
max = @props.editAreaWidth - @_halfWidth()*2 - CONTENT_PADDING
left = Math.min(Math.max(@props.left - @_halfWidth(), CONTENT_PADDING), max)
return left
_toolbarPointerStyles: ->
CONTENT_PADDING = @props.contentPadding ? 15
POINTER_WIDTH = 6 + 2 #2px of border-radius
max = @props.editAreaWidth - CONTENT_PADDING
min = CONTENT_PADDING
absoluteLeft = Math.max(Math.min(@props.left, max), min)
relativeLeft = absoluteLeft - @_toolbarLeft()
left = Math.max(Math.min(relativeLeft, @_halfWidth()*2-POINTER_WIDTH), POINTER_WIDTH)
styles =
left: left
return styles
_halfWidth: ->
# We can't calculate the width of the floating toolbar declaratively
# because it hasn't been rendered yet. As such, we'll keep the width
# fixed to make it much eaier.
TOOLBAR_BUTTONS_WIDTH = 86#px
TOOLBAR_URL_WIDTH = 210#px
if @state.mode is "buttons"
TOOLBAR_BUTTONS_WIDTH / 2
else if @state.mode is "edit-link"
TOOLBAR_URL_WIDTH / 2
else
TOOLBAR_BUTTONS_WIDTH / 2
_showLink: ->
@setState mode: "edit-link"

View file

@ -34,27 +34,27 @@ describe "ContenteditableComponent", ->
it "should include a content-editable div", ->
expect(ReactTestUtils.findRenderedDOMComponentWithAttr(@component, 'contentEditable')).toBeDefined()
describe "quoted-text-toggle", ->
describe "quoted-text-control", ->
it "should be rendered", ->
expect(ReactTestUtils.findRenderedDOMComponentWithClass(@component, 'quoted-text-toggle')).toBeDefined()
expect(ReactTestUtils.findRenderedDOMComponentWithClass(@component, 'quoted-text-control')).toBeDefined()
it "should be visible if the html contains quoted text", ->
@toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@componentWithQuote, 'quoted-text-toggle')
expect(@toggle.props.className.indexOf('hidden') >= 0).toBe(false)
@toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@componentWithQuote, 'quoted-text-control')
expect(@toggle.props.className.indexOf('no-quoted-text') >= 0).toBe(false)
it "should be have `state-on` if editQuotedText is true", ->
it "should be have `show-quoted-text` if editQuotedText is true", ->
@componentWithQuote.setState(editQuotedText: true)
@toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@componentWithQuote, 'quoted-text-toggle')
expect(@toggle.props.className.indexOf('state-on') >= 0).toBe(true)
@toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@componentWithQuote, 'quoted-text-control')
expect(@toggle.props.className.indexOf('show-quoted-text') >= 0).toBe(true)
it "should not have `state-on` if editQuotedText is false", ->
it "should not have `show-quoted-text` if editQuotedText is false", ->
@componentWithQuote.setState(editQuotedText: false)
@toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@componentWithQuote, 'quoted-text-toggle')
expect(@toggle.props.className.indexOf('state-on') >= 0).toBe(false)
@toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@componentWithQuote, 'quoted-text-control')
expect(@toggle.props.className.indexOf('show-quoted-text') >= 0).toBe(false)
it "should be hidden otherwise", ->
@toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@component, 'quoted-text-toggle')
expect(@toggle.props.className.indexOf('hidden') >= 0).toBe(true)
@toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@component, 'quoted-text-control')
expect(@toggle.props.className.indexOf('no-quoted-text') >= 0).toBe(true)
describe "when editQuotedText is false", ->
it "should only display HTML up to the beginning of the quoted text", ->
@ -85,12 +85,14 @@ describe "ContenteditableComponent", ->
@performEdit('Test <strong>New HTML</strong>')
expect(@onChange).toHaveBeenCalled()
it "should not fire if the html is the same", ->
# One day we may make this more efficient. For now we aggressively
# re-render because of the manual cursor positioning.
it "should fire if the html is the same", ->
expect(@onChange.callCount).toBe(0)
@performEdit(@changedHtmlWithoutQuote)
expect(@onChange.callCount).toBe(1)
@performEdit(@changedHtmlWithoutQuote)
expect(@onChange.callCount).toBe(1)
expect(@onChange.callCount).toBe(2)
describe "when editQuotedText is true", ->
it "should call `props.onChange` with the entire HTML string", ->

View file

@ -17,11 +17,11 @@
.compose-body {
margin-bottom: 0;
position: relative;
.contenteditable-container {
position:absolute;
width:100%;
height:100%;
position: absolute;
width: 100%;
min-height: 100%;
div[contenteditable] {
// Ensure that the contentEditable always fills the window,
@ -33,6 +33,37 @@
}
}
.toolbar {
@padding: 0.5em;
.btn {
background: transparent;
font-size: 16px;
height: auto;
border-radius: 0;
padding: @padding*0.75 @padding;
margin: 0;
color: rgba(255,255,255, 0.6);
box-shadow: none;
&:first-child {
padding-left: 1.5*@padding;
}
&:last-child {
padding-right: 1.5*@padding;
}
&:hover, &:active {
color: rgba(255,255,255, 1);
background: transparent;
}
}
.preview-btn-icon {
position: relative;
top: 1px;
padding: 0 @padding;
}
}
.composer-inner-wrap {
height: 100%;
display: flex;
@ -40,6 +71,39 @@
padding-bottom: 57px;
position: relative;
a:hover {
cursor: pointer;
}
.floating-toolbar {
z-index: 10;
position: absolute;
background: @background-color-accent;
border-radius: 2px;
color: @text-color-inverse;
.toolbar-pointer {
content: " ";
position: absolute;
width: 0;
height: 0;
top: -13px;
left: 50%;
margin-left: -6px;
border: 7px solid transparent;
border-bottom-color: @background-color-accent;
border-bottom-width: 6px;
}
.floating-toolbar-input {
display: inline;
width: auto;
color: @text-color-inverse;
position: relative;
top: 1px;
}
}
.composer-header {
padding: 11px 15px 5px 15px;
@ -63,6 +127,8 @@
}
input, textarea, div[contenteditable] {
position: relative;
z-index: 1;
display: block;
background: inherit;
width: 100%;
@ -116,15 +182,22 @@
display: flex;
cursor: text;
overflow: auto;
margin: 0.7em 15px 15px 15px;
position: relative;
.quoted-text-toggle {
margin:0;
.quoted-text-control {
position: absolute;
bottom: 10px;
left: 15px;
margin: 0;
}
div[contenteditable] {
min-height: 150px;
padding: 0.7em 15px 0 15px;
margin-bottom: 37px;
}
.contenteditable-container {
width: 100%;
}
}

View file

@ -41,7 +41,13 @@
.bold() {
font-family: "Proxima Nova Bold", sans-serif;
font-weight: normal;
// NOTE: It is important that this not be "normal". It would seem that under
// the hood, contenteditable and document.execCommand use the font-weight to
// determine if something is bolded or not. Setting this to normal prevented
// execCommand from unbolding text.
font-weight: bold;
font-style: normal;
letter-spacing: 0.3px;
}

View file

@ -31,11 +31,6 @@ MessageItem = React.createClass
@_storeUnlisten() if @_storeUnlisten
render: ->
quotedTextClass = React.addons.classSet
"quoted-text-toggle": true
'hidden': !Utils.containsQuotedText(@props.message.body)
'state-on': @state.showQuotedText
messageActions = ComponentRegistry.findAllViewsByRole('MessageAction')
messageIndicators = ComponentRegistry.findAllViewsByRole('MessageIndicator')
attachments = @_attachmentComponents()
@ -67,9 +62,13 @@ MessageItem = React.createClass
<EmailFrame showQuotedText={@state.showQuotedText}>
{@_formatBody()}
</EmailFrame>
<a className={quotedTextClass} onClick={@_toggleQuotedText}></a>
<a className={@_quotedTextClasses()} onClick={@_toggleQuotedText}></a>
</div>
_quotedTextClasses: -> React.addons.classSet
"quoted-text-control": true
'no-quoted-text': !Utils.containsQuotedText(@props.message.body)
'show-quoted-text': @state.showQuotedText
# Eventually, _formatBody will run a series of registered body transformers.
# For now, it just runs a few we've hardcoded here, which are all synchronous.

View file

@ -192,8 +192,8 @@ describe "MessageItem", ->
it "should show the `show quoted text` toggle in the off state", ->
@createComponent()
toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@component, 'quoted-text-toggle')
expect(toggle.getDOMNode().className.indexOf('state-on')).toBe(-1)
toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@component, 'quoted-text-control')
expect(toggle.getDOMNode().className.indexOf('show-quoted-text')).toBe(-1)
it "should be initialized to true if the message contains `Forwarded`...", ->
@message.body = """
@ -233,8 +233,8 @@ describe "MessageItem", ->
@component.setState(showQuotedText: true)
it "should show the `show quoted text` toggle in the on state", ->
toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@component, 'quoted-text-toggle')
expect(toggle.getDOMNode().className.indexOf('state-on') > 0).toBe(true)
toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@component, 'quoted-text-control')
expect(toggle.getDOMNode().className.indexOf('show-quoted-text') > 0).toBe(true)
it "should pass the value into the EmailFrame", ->
frame = ReactTestUtils.findRenderedComponentWithType(@component, EmailFrameStub)

View file

@ -85,6 +85,7 @@
'shift-backspace': 'native!'
'delete': 'native!'
'shift-delete': 'native!'
'cmd-y': 'native!'
'cmd-z': 'native!'
'cmd-Z': 'native!'
'cmd-x': 'native!'
@ -95,6 +96,10 @@
'cmd-V': 'native!'
'cmd-a': 'native!'
'cmd-A': 'native!'
'cmd-b': 'native!'
'cmd-i': 'native!'
'cmd-u': 'native!'
'ctrl-y': 'native!'
'ctrl-z': 'native!'
'ctrl-Z': 'native!'
'ctrl-x': 'native!'
@ -105,6 +110,9 @@
'ctrl-V': 'native!'
'ctrl-a': 'native!'
'ctrl-A': 'native!'
'ctrl-b': 'native!'
'ctrl-i': 'native!'
'ctrl-u': 'native!'
'a': 'native!'
'b': 'native!'
'c': 'native!'

View file

@ -31,3 +31,8 @@
'cmd-ctrl-f': 'window:toggle-full-screen'
'ctrl-alt-cmd-l': 'window:reload'
'cmd-alt-ctrl-p': 'window:run-package-specs'
'body div *[contenteditable]':
'cmd-z': 'core:undo'
'cmd-Z': 'core:redo'
'cmd-y': 'core:redo'

View file

@ -28,3 +28,8 @@
'F11': 'window:toggle-full-screen'
'ctrl-alt-r': 'window:reload'
'ctrl-alt-p': 'window:run-package-specs'
'body div *[contenteditable]':
'ctrl-z': 'core:undo'
'ctrl-Z': 'core:redo'
'ctrl-y': 'core:redo'

View file

@ -28,3 +28,8 @@
'F11': 'window:toggle-full-screen'
'ctrl-alt-r': 'window:reload'
'ctrl-alt-p': 'window:run-package-specs'
'body div *[contenteditable]':
'ctrl-z': 'core:undo'
'ctrl-Z': 'core:redo'
'ctrl-y': 'core:redo'

View file

@ -84,7 +84,8 @@ class InboxAPI
Actions.longPollOffline()
connection.onDeltas (deltas) =>
@_handleDeltas(deltas)
# TODO DO NOT FORGET TO UNCOMMENT ME
# @_handleDeltas(deltas)
connection.start()
connection

View file

@ -0,0 +1,34 @@
_ = require 'underscore-plus'
module.exports =
class UndoManager
constructor: ->
@_position = -1
@_history = []
@_MAX_HISTORY_SIZE = 100
current: ->
return @_history[@_position]
undo: ->
if @_position > 0
@_position -= 1
return @_history[@_position]
else return null
redo: ->
if @_position < (@_history.length - 1)
@_position += 1
return @_history[@_position]
else return null
immediatelySaveToHistory: (historyItem) =>
if not _.isEqual((_.last(@_history) ? {}), historyItem)
@_position += 1
@_history.length = @_position
@_history.push(historyItem)
while @_history.length > @_MAX_HISTORY_SIZE
@_history.shift()
@_position -= 1
saveToHistory: _.debounce(UndoManager::immediatelySaveToHistory, 300)

View file

@ -1,6 +1,6 @@
@import "ui-variables";
.quoted-text-toggle {
.quoted-text-control {
background-color: #f7f7f7;
border-radius: 5px;
padding: 7px;
@ -10,11 +10,11 @@
color: #333;
border: 1px solid #eee;
line-height: 16px;
margin-bottom: 10px;
margin-bottom: 15px;
margin-left: 15px;
cursor: pointer;
&.hidden {
&.no-quoted-text {
display:none;
}
&:hover {
@ -22,7 +22,7 @@
background-color: @background-color-secondary;
text-decoration:none;
}
&.state-on:before {
&.show-quoted-text:before {
content:'Hide Quoted Text';
}
&:before {