Mailspring/internal_packages/composer/lib/contenteditable-component.cjsx

1125 lines
40 KiB
Plaintext
Raw Normal View History

_ = require 'underscore'
fix(drafts): Various improvements and fixes to drafts, draft state management Summary: This diff contains a few major changes: 1. Scribe is no longer used for the text editor. It's just a plain contenteditable region. The toolbar items (bold, italic, underline) still work. Scribe was causing React inconcistency issues in the following scenario: - View thread with draft, edit draft - Move to another thread - Move back to thread with draft - Move to another thread. Notice that one or more messages from thread with draft are still there. There may be a way to fix this, but I tried for hours and there are Github Issues open on it's repository asking for React compatibility, so it may be fixed soon. For now contenteditable is working great. 2. Action.saveDraft() is no longer debounced in the DraftStore. Instead, firing that action causes the save to happen immediately, and the DraftStoreProxy has a new "DraftChangeSet" class which is responsbile for batching saves as the user interacts with the ComposerView. There are a couple big wins here: - In the future, we may want to be able to call Action.saveDraft() in other situations and it should behave like a normal action. We may also want to expose the DraftStoreProxy as an easy way of backing interactive draft UI. - Previously, when you added a contact to To/CC/BCC, this happened: <input> -> Action.saveDraft -> (delay!!) -> Database -> DraftStore -> DraftStoreProxy -> View Updates Increasing the delay to something reasonable like 200msec meant there was 200msec of lag before you saw the new view state. To fix this, I created a new class called DraftChangeSet which is responsible for accumulating changes as they're made and firing Action.saveDraft. "Adding" a change to the change set also causes the Draft provided by the DraftStoreProxy to change immediately (the changes are a temporary layer on top of the database object). This means no delay while changes are being applied. There's a better explanation in the source! This diff includes a few minor fixes as well: 1. Draft.state is gone—use Message.object = draft instead 2. String model attributes should never be null 3. Pre-send checks that can cancel draft send 4. Put the entire curl history and task queue into feedback reports 5. Cache localIds for extra speed 6. Move us up to latest React Test Plan: No new tests - once we lock down this new design I'll write tests for the DraftChangeSet Reviewers: evan Reviewed By: evan Differential Revision: https://review.inboxapp.com/D1125
2015-02-04 08:24:31 +08:00
React = require 'react'
classNames = require 'classnames'
sanitizeHtml = require 'sanitize-html'
{DOMUtils, Utils, QuotedHTMLParser, DraftStore} = require 'nylas-exports'
FloatingToolbar = require './floating-toolbar'
fix(drafts): Various improvements and fixes to drafts, draft state management Summary: This diff contains a few major changes: 1. Scribe is no longer used for the text editor. It's just a plain contenteditable region. The toolbar items (bold, italic, underline) still work. Scribe was causing React inconcistency issues in the following scenario: - View thread with draft, edit draft - Move to another thread - Move back to thread with draft - Move to another thread. Notice that one or more messages from thread with draft are still there. There may be a way to fix this, but I tried for hours and there are Github Issues open on it's repository asking for React compatibility, so it may be fixed soon. For now contenteditable is working great. 2. Action.saveDraft() is no longer debounced in the DraftStore. Instead, firing that action causes the save to happen immediately, and the DraftStoreProxy has a new "DraftChangeSet" class which is responsbile for batching saves as the user interacts with the ComposerView. There are a couple big wins here: - In the future, we may want to be able to call Action.saveDraft() in other situations and it should behave like a normal action. We may also want to expose the DraftStoreProxy as an easy way of backing interactive draft UI. - Previously, when you added a contact to To/CC/BCC, this happened: <input> -> Action.saveDraft -> (delay!!) -> Database -> DraftStore -> DraftStoreProxy -> View Updates Increasing the delay to something reasonable like 200msec meant there was 200msec of lag before you saw the new view state. To fix this, I created a new class called DraftChangeSet which is responsible for accumulating changes as they're made and firing Action.saveDraft. "Adding" a change to the change set also causes the Draft provided by the DraftStoreProxy to change immediately (the changes are a temporary layer on top of the database object). This means no delay while changes are being applied. There's a better explanation in the source! This diff includes a few minor fixes as well: 1. Draft.state is gone—use Message.object = draft instead 2. String model attributes should never be null 3. Pre-send checks that can cancel draft send 4. Put the entire curl history and task queue into feedback reports 5. Cache localIds for extra speed 6. Move us up to latest React Test Plan: No new tests - once we lock down this new design I'll write tests for the DraftChangeSet Reviewers: evan Reviewed By: evan Differential Revision: https://review.inboxapp.com/D1125
2015-02-04 08:24:31 +08:00
linkUUID = 0
genLinkId = -> linkUUID += 1; return linkUUID
class ContenteditableComponent extends React.Component
@displayName = "Contenteditable"
@propTypes =
2015-03-06 08:00:56 +08:00
html: React.PropTypes.string
style: React.PropTypes.object
2015-03-06 08:02:47 +08:00
tabIndex: React.PropTypes.string
2015-03-06 08:00:56 +08:00
onChange: React.PropTypes.func.isRequired
mode: React.PropTypes.object
onFilePaste: React.PropTypes.func
onChangeMode: React.PropTypes.func
initialSelectionSnapshot: React.PropTypes.object
2015-03-06 08:00:56 +08:00
# Passes an absolute top coordinate to scroll to.
onScrollTo: React.PropTypes.func
onScrollToBottom: React.PropTypes.func
constructor: (@props) ->
@state =
toolbarTop: 0
toolbarMode: "buttons"
toolbarLeft: 0
toolbarPos: "above"
editAreaWidth: 9999 # This will get set on first selection
toolbarVisible: false
componentDidMount: =>
@_editableNode().addEventListener('contextmenu', @_onShowContextualMenu)
@_setupSelectionListeners()
@_setupLinkHoverListeners()
@_setupGlobalMouseListener()
@_disposable = atom.commands.add '.contenteditable-container *', {
'core:focus-next': (event) =>
editableNode = @_editableNode()
range = @_getRangeInScope()
for extension in DraftStore.extensions()
extension.onFocusNext(editableNode, range, event) if extension.onFocusNext
'core:focus-previous': (event) =>
editableNode = @_editableNode()
range = @_getRangeInScope()
for extension in DraftStore.extensions()
extension.onFocusPrevious(editableNode, range, event) if extension.onFocusPrevious
}
@_cleanHTML()
shouldComponentUpdate: (nextProps, nextState) ->
not Utils.isEqualReact(nextProps, @props) or
not Utils.isEqualReact(nextState, @state)
componentWillUnmount: =>
@_editableNode().removeEventListener('contextmenu', @_onShowContextualMenu)
@_teardownSelectionListeners()
@_teardownLinkHoverListeners()
@_teardownGlobalMouseListener()
@_disposable.dispose()
componentWillReceiveProps: (nextProps) =>
if nextProps.initialSelectionSnapshot?
@_setSelectionSnapshot(nextProps.initialSelectionSnapshot)
@_refreshToolbarState()
componentWillUpdate: (nextProps, nextState) =>
@_teardownLinkHoverListeners()
componentDidUpdate: =>
@_cleanHTML()
@_setupLinkHoverListeners()
@_restoreSelection()
render: =>
<div className="contenteditable-container">
<FloatingToolbar ref="floatingToolbar"
top={@state.toolbarTop}
left={@state.toolbarLeft}
pos={@state.toolbarPos}
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
spellCheck={false}
tabIndex={@props.tabIndex}
2015-03-06 05:53:18 +08:00
style={@props.style ? {}}
onBlur={@_onBlur}
onClick={@_onClick}
onPaste={@_onPaste}
onInput={@_onInput}
onKeyDown={@_onKeyDown}
dangerouslySetInnerHTML={@_dangerouslySetInnerHTML()}></div>
{@_renderQuotedTextControl()}
</div>
_renderQuotedTextControl: ->
if QuotedHTMLParser.hasQuotedHTML(@props.html)
text = if @props.mode?.showQuotedText then "Hide" else "Show"
<a className="quoted-text-control" onClick={@_onToggleQuotedText}>
<span className="dots">&bull;&bull;&bull;</span>{text} previous
</a>
else return null
focus: =>
@_editableNode().focus()
selectEnd: =>
range = document.createRange()
range.selectNodeContents(@_editableNode())
range.collapse(false)
@_editableNode().focus()
selection = window.getSelection()
selection.removeAllRanges()
selection.addRange(range)
_onClick: (event) ->
# We handle mouseDown, mouseMove, mouseUp, but we want to stop propagation
# of `click` to make it clear that we've handled the event.
# Note: Related to composer-view#_onClickComposeBody
event.stopPropagation()
_onKeyDown: (event) =>
if event.key is "Tab"
@_onTabDown(event)
return
_onInput: =>
return if @_ignoreInputChanges
@_ignoreInputChanges = true
@_dragging = false
@_runCoreFilters()
@_runExtensionFilters()
@_prepareForReactContenteditable()
@_saveSelectionState()
html = @_unapplyHTMLDisplayFilters(@_editableNode().innerHTML)
@props.onChange(target: {value: html})
@_ignoreInputChanges = false
return
_runCoreFilters: ->
@_createLists()
# Determines if the user wants to add an ordered or unordered list.
_createLists: ->
# The `execCommand` will update the DOM and move the cursor. Since
# this is happening in the middle of an `_onInput` callback, we want
# the whole operation to look "atomic". As such we'll do any necessary
# DOM cleanup and fire the `exec` command with the listeners off, then
# re-enable at the end.
updateDOM = (command) =>
@_teardownSelectionListeners()
document.execCommand(command)
selection = document.getSelection()
selection.anchorNode.parentElement.innerHTML = ""
@_setupSelectionListeners()
text = @_textContentAtCursor()
if (/^\d\.\s$/).test text
updateDOM("insertOrderedList")
else if (/^[*-]\s$/).test text
updateDOM("insertUnorderedList")
_onTabDown: (event) ->
event.preventDefault()
selection = document.getSelection()
if selection?.isCollapsed
# https://developer.mozilla.org/en-US/docs/Web/API/Element/closest
if selection.anchorNode instanceof HTMLElement
anchorElement = selection.anchorNode
else
anchorElement = selection.anchorNode.parentElement
# Only Elements (not Text nodes) have the `closest` method
if anchorElement.closest("li")
if event.shiftKey
document.execCommand("outdent")
else
document.execCommand("indent")
return
else if event.shiftKey
if @_atTabChar() then @_removeLastCharacter()
else
document.execCommand("insertText", false, "\t")
else
if event.shiftKey
document.execCommand("insertText", false, "")
else
document.execCommand("insertText", false, "\t")
_selectionInText: (selection) ->
return false unless selection
return selection.isCollapsed and selection.anchorNode.nodeType is Node.TEXT_NODE and selection.anchorOffset > 0
_atTabChar: ->
selection = document.getSelection()
if @_selectionInText(selection)
return selection.anchorNode.textContent[selection.anchorOffset - 1] is "\t"
else return false
_removeLastCharacter: ->
selection = document.getSelection()
if @_selectionInText(selection)
node = selection.anchorNode
offset = selection.anchorOffset
@_teardownSelectionListeners()
selection.setBaseAndExtent(node, offset - 1, node, offset)
document.execCommand("delete")
@_setupSelectionListeners()
_textContentAtCursor: ->
selection = document.getSelection()
if selection.isCollapsed
2015-08-20 04:35:12 +08:00
return selection.anchorNode?.textContent
else return null
_runExtensionFilters: ->
for extension in DraftStore.extensions()
extension.onInput(@_editableNode(), event) if extension.onInput
# This component works by re-rendering on every change and restoring the
# selection. This is also how standard React controlled inputs work too.
#
# Since the contets of the contenteditable are complex, nested DOM
# structures, a simple replacement of the DOM is not easy. There are a
# variety of edge cases that we need to correct for and prepare both the
# HTML and the selection to be serialized without error.
_prepareForReactContenteditable: ->
@_cleanHTML()
@_cleanSelection()
# We need to clean the HTML on input to fix several edge cases that
# arise when we go to save the selection state and restore it on the
# next render.
_cleanHTML: ->
return unless @_editableNode()
# One issue is that we need to pre-normalize the HTML so it looks the
# same after it gets re-inserted. If we key selection markers off of an
# non normalized DOM, then they won't match up when the HTML gets reset.
#
# The Node.normalize() method puts the specified node and all of its
# sub-tree into a "normalized" form. In a normalized sub-tree, no text
# nodes in the sub-tree are empty and there are no adjacent text
# nodes.
@_editableNode().normalize()
@_fixLeadingBRCondition()
# An issue arises from <br/> tags immediately inside of divs. In this
# case the cursor's anchor node will not be the <br/> tag, but rather
# the entire enclosing element. Sometimes, that enclosing element is the
# container wrapping all of the content. The browser has a native
# built-in feature that will automatically scroll the page to the bottom
# of the current element that the cursor is in if the cursor is off the
# screen. In the given case, that element is the whole div. The net
# effect is that the browser will scroll erroneously to the bottom of
# the whole content div, which is likely NOT where the cursor is or the
# user wants. The solution to this is to replace this particular case
# with <span></span> tags and place the cursor in there.
_fixLeadingBRCondition: ->
treeWalker = document.createTreeWalker @_editableNode()
while treeWalker.nextNode()
currentNode = treeWalker.currentNode
if @_hasLeadingBRCondition(currentNode)
newNode = document.createElement("div")
newNode.appendChild(document.createElement("br"))
currentNode.replaceChild(newNode, currentNode.childNodes[0])
return
_hasLeadingBRCondition: (node) ->
childNodes = node.childNodes
return childNodes.length >= 2 and childNodes[0].nodeName is "BR"
# After an input, the selection can sometimes get itself into a state
# that either can't be restored properly, or will cause undersirable
# native behavior. This method, in combination with `_cleanHTML`, fixes
# each of those scenarios before we save and later restore the
# selection.
_cleanSelection: ->
selection = document.getSelection()
return unless selection.anchorNode? and selection.focusNode?
# The _unselectableNode case only is valid when it's at the very top
# (offset 0) of the node. If the offsets are > 0 that means we're
# trying to select somewhere within some sort of containing element.
# This is okay to do. The odd case only arises at the top of
# unselectable elements.
return if selection.anchorOffset > 0 or selection.focusOffset > 0
if selection.isCollapsed and @_unselectableNode(selection.focusNode)
@_teardownSelectionListeners()
treeWalker = document.createTreeWalker(selection.focusNode)
while treeWalker.nextNode()
currentNode = treeWalker.currentNode
if @_unselectableNode(currentNode)
selection.setBaseAndExtent(currentNode, 0, currentNode, 0)
break
@_setupSelectionListeners()
return
_unselectableNode: (node) ->
return true if not node
if node.nodeType is Node.TEXT_NODE and @_isBlankTextNode(node)
return true
else if node.nodeType is Node.ELEMENT_NODE
child = node.firstChild
return true if not child
hasText = (child.nodeType is Node.TEXT_NODE and not @_isBlankTextNode(node))
hasBr = (child.nodeType is Node.ELEMENT_NODE and node.nodeName is "BR")
return not hasText and not hasBr
fix(drafts): Various improvements and fixes to drafts, draft state management Summary: This diff contains a few major changes: 1. Scribe is no longer used for the text editor. It's just a plain contenteditable region. The toolbar items (bold, italic, underline) still work. Scribe was causing React inconcistency issues in the following scenario: - View thread with draft, edit draft - Move to another thread - Move back to thread with draft - Move to another thread. Notice that one or more messages from thread with draft are still there. There may be a way to fix this, but I tried for hours and there are Github Issues open on it's repository asking for React compatibility, so it may be fixed soon. For now contenteditable is working great. 2. Action.saveDraft() is no longer debounced in the DraftStore. Instead, firing that action causes the save to happen immediately, and the DraftStoreProxy has a new "DraftChangeSet" class which is responsbile for batching saves as the user interacts with the ComposerView. There are a couple big wins here: - In the future, we may want to be able to call Action.saveDraft() in other situations and it should behave like a normal action. We may also want to expose the DraftStoreProxy as an easy way of backing interactive draft UI. - Previously, when you added a contact to To/CC/BCC, this happened: <input> -> Action.saveDraft -> (delay!!) -> Database -> DraftStore -> DraftStoreProxy -> View Updates Increasing the delay to something reasonable like 200msec meant there was 200msec of lag before you saw the new view state. To fix this, I created a new class called DraftChangeSet which is responsible for accumulating changes as they're made and firing Action.saveDraft. "Adding" a change to the change set also causes the Draft provided by the DraftStoreProxy to change immediately (the changes are a temporary layer on top of the database object). This means no delay while changes are being applied. There's a better explanation in the source! This diff includes a few minor fixes as well: 1. Draft.state is gone—use Message.object = draft instead 2. String model attributes should never be null 3. Pre-send checks that can cancel draft send 4. Put the entire curl history and task queue into feedback reports 5. Cache localIds for extra speed 6. Move us up to latest React Test Plan: No new tests - once we lock down this new design I'll write tests for the DraftChangeSet Reviewers: evan Reviewed By: evan Differential Revision: https://review.inboxapp.com/D1125
2015-02-04 08:24:31 +08:00
else return false
_onBlur: (event) =>
@_dragging = false
# 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 =>
@_hideToolbar()
, 50
fix(drafts): Various improvements and fixes to drafts, draft state management Summary: This diff contains a few major changes: 1. Scribe is no longer used for the text editor. It's just a plain contenteditable region. The toolbar items (bold, italic, underline) still work. Scribe was causing React inconcistency issues in the following scenario: - View thread with draft, edit draft - Move to another thread - Move back to thread with draft - Move to another thread. Notice that one or more messages from thread with draft are still there. There may be a way to fix this, but I tried for hours and there are Github Issues open on it's repository asking for React compatibility, so it may be fixed soon. For now contenteditable is working great. 2. Action.saveDraft() is no longer debounced in the DraftStore. Instead, firing that action causes the save to happen immediately, and the DraftStoreProxy has a new "DraftChangeSet" class which is responsbile for batching saves as the user interacts with the ComposerView. There are a couple big wins here: - In the future, we may want to be able to call Action.saveDraft() in other situations and it should behave like a normal action. We may also want to expose the DraftStoreProxy as an easy way of backing interactive draft UI. - Previously, when you added a contact to To/CC/BCC, this happened: <input> -> Action.saveDraft -> (delay!!) -> Database -> DraftStore -> DraftStoreProxy -> View Updates Increasing the delay to something reasonable like 200msec meant there was 200msec of lag before you saw the new view state. To fix this, I created a new class called DraftChangeSet which is responsible for accumulating changes as they're made and firing Action.saveDraft. "Adding" a change to the change set also causes the Draft provided by the DraftStoreProxy to change immediately (the changes are a temporary layer on top of the database object). This means no delay while changes are being applied. There's a better explanation in the source! This diff includes a few minor fixes as well: 1. Draft.state is gone—use Message.object = draft instead 2. String model attributes should never be null 3. Pre-send checks that can cancel draft send 4. Put the entire curl history and task queue into feedback reports 5. Cache localIds for extra speed 6. Move us up to latest React Test Plan: No new tests - once we lock down this new design I'll write tests for the DraftChangeSet Reviewers: evan Reviewed By: evan Differential Revision: https://review.inboxapp.com/D1125
2015-02-04 08:24:31 +08:00
_editableNode: =>
React.findDOMNode(@refs.contenteditable)
fix(drafts): Various improvements and fixes to drafts, draft state management Summary: This diff contains a few major changes: 1. Scribe is no longer used for the text editor. It's just a plain contenteditable region. The toolbar items (bold, italic, underline) still work. Scribe was causing React inconcistency issues in the following scenario: - View thread with draft, edit draft - Move to another thread - Move back to thread with draft - Move to another thread. Notice that one or more messages from thread with draft are still there. There may be a way to fix this, but I tried for hours and there are Github Issues open on it's repository asking for React compatibility, so it may be fixed soon. For now contenteditable is working great. 2. Action.saveDraft() is no longer debounced in the DraftStore. Instead, firing that action causes the save to happen immediately, and the DraftStoreProxy has a new "DraftChangeSet" class which is responsbile for batching saves as the user interacts with the ComposerView. There are a couple big wins here: - In the future, we may want to be able to call Action.saveDraft() in other situations and it should behave like a normal action. We may also want to expose the DraftStoreProxy as an easy way of backing interactive draft UI. - Previously, when you added a contact to To/CC/BCC, this happened: <input> -> Action.saveDraft -> (delay!!) -> Database -> DraftStore -> DraftStoreProxy -> View Updates Increasing the delay to something reasonable like 200msec meant there was 200msec of lag before you saw the new view state. To fix this, I created a new class called DraftChangeSet which is responsible for accumulating changes as they're made and firing Action.saveDraft. "Adding" a change to the change set also causes the Draft provided by the DraftStoreProxy to change immediately (the changes are a temporary layer on top of the database object). This means no delay while changes are being applied. There's a better explanation in the source! This diff includes a few minor fixes as well: 1. Draft.state is gone—use Message.object = draft instead 2. String model attributes should never be null 3. Pre-send checks that can cancel draft send 4. Put the entire curl history and task queue into feedback reports 5. Cache localIds for extra speed 6. Move us up to latest React Test Plan: No new tests - once we lock down this new design I'll write tests for the DraftChangeSet Reviewers: evan Reviewed By: evan Differential Revision: https://review.inboxapp.com/D1125
2015-02-04 08:24:31 +08:00
_getAllLinks: =>
Array.prototype.slice.call(@_editableNode().querySelectorAll("*[href]"))
_dangerouslySetInnerHTML: =>
__html: @_applyHTMLDisplayFilters(@props.html)
_applyHTMLDisplayFilters: (html) =>
if @props.mode?.showQuotedText
return html
else
return QuotedHTMLParser.removeQuotedHTML(html)
_unapplyHTMLDisplayFilters: (html) =>
if @props.mode?.showQuotedText
return html
else
return QuotedHTMLParser.appendQuotedHTML(html, @props.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: =>
@_ignoreInputChanges = false
document.addEventListener("selectionchange", @_saveSelectionState)
_teardownSelectionListeners: =>
document.removeEventListener("selectionchange", @_saveSelectionState)
@_ignoreInputChanges = true
getCurrentSelection: => _.clone(@_selection ? {})
getPreviousSelection: => _.clone(@_previousSelection ? {})
_getRangeInScope: =>
selection = document.getSelection()
return null if not @_selectionInScope(selection)
try
range = selection.getRangeAt(0)
catch
console.warn "Selection is not returning a range"
return document.createRange()
range
# 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
# naturally 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.
#
# The Selection API has the concept of an `anchorNode` and a
# `focusNode`. The `anchorNode` is where the selection started from and
# does not move. The `focusNode` is where the end of the selection
# currently is and may move. A "caret" is simply a selection whose
# anchorNode == focusNode and anchorOffset == focusOffset.
#
# An `anchorNode` is also known as a `startNode`, or `baseNode`. We use
# the alias `startNode` since I think it makes more intuitive sense.
#
# A `focusNode` is also known as an `endNode` or `focusNode`. I use the
# `endNode` alias since it makes more inuitive sense.
#
# When we restore the selection later, we need to find a node that looks
# the same as the one we saved (since they're different object
# references). Unfortunately there many be many nodes that "look" the
# same (match the `isEqualNode`) test. For example, say I have a bunch
# of lines with the TEXT_NODE "Foo". All of those will match
# `isEqualNode`. To fix this we assume there will be multiple matches
# and keep track of the index of the match. e.g. all "Foo" TEXT_NODEs
# may look alike, but I know I want the Nth "Foo" TEXT_NODE. We store
# this information in the `startNodeIndex` and `endNodeIndex` fields via
# the `getNodeIndex` method.
_saveSelectionState: =>
selection = document.getSelection()
return if @_checkSameSelection(selection)
return unless selection.anchorNode? and selection.focusNode?
2015-05-21 01:22:03 +08:00
return unless @_selectionInScope(selection)
@_previousSelection = @_selection
@_selection =
startNode: selection.anchorNode.cloneNode(true)
startOffset: selection.anchorOffset
startNodeIndex: @_getNodeIndex(selection.anchorNode)
endNode: selection.focusNode.cloneNode(true)
endOffset: selection.focusOffset
endNodeIndex: @_getNodeIndex(selection.focusNode)
isCollapsed: selection.isCollapsed
@_ensureSelectionVisible(selection)
@_refreshToolbarState()
return @_selection
# Determines whether the current (cursor) selection is at the end of the
# content.
#
# This must be run before a re-render since we use a strict object
# identity comparison instead of an equivalent `isEqualNode` comparison.
_atEndOfContent: (selection, containerScope=@_editableNode()) =>
if selection.isCollapsed
# We need to use `lastChild` instead of `lastElementChild` because
# we need to eventually check if the `selection.focusNode`, which is
# usually a TEXT node, is equal to the returned `lastChild`.
# `lastElementChild` will not return TEXT nodes.
#
# Unfortunately, `lastChild` can sometime return COMMENT nodes and
# other blank TEXT nodes that we don't want to compare to.
#
# For example, if you have the structure:
# <div>
# <p>Foo</p>
# </div>
#
# The div may have 2 childNodes and 1 childElementNode. The 2nd
# hidden childNode is a TEXT node with a data of "\n". I actually
# want to return the <p></p>.
#
# However, The <p> element may have 1 childNode and 0
# childElementNodes. In that case I DO want to return the TEXT node
# that has the data of "foo"
lastChild = @_lastNonBlankChildNode(containerScope)
# Special case for a completely empty contenteditable.
# In this case `lastChild` will be null, but we are definitely at
# the end of the content.
if containerScope is @_editableNode()
return true if containerScope.childNodes.length is 0
return false unless lastChild
# NOTE: `.contains` returns true if `lastChild` is equal to
# `selection.focusNode`
#
# See: http://ejohn.org/blog/comparing-document-position/
inLastChild = lastChild.contains(selection.focusNode)
# We should do true object identity here instead of `.isEqualNode`
isLastChild = lastChild is selection.focusNode
if isLastChild
if selection.focusNode?.length
atEndIndex = selection.focusOffset is selection.focusNode.length
else
atEndIndex = selection.focusOffset is 0
return atEndIndex
else if inLastChild
@_atEndOfContent(selection, lastChild)
else return false
else return false
_lastNonBlankChildNode: (node) ->
lastNode = null
for childNode in node.childNodes by -1
if childNode.nodeType is Node.TEXT_NODE
if @_isBlankTextNode(childNode)
continue
else
return childNode
else if childNode.nodeType is Node.ELEMENT_NODE
return childNode
else continue
return lastNode
_isBlankTextNode: (node) ->
return if not node?.data
# \u00a0 is &nbsp;
node.data.replace(/\u00a0/g, "x").trim().length is 0
_setSelectionSnapshot: (selection) =>
@_previousSelection = @_selection
@_selection = selection
# When the selectionState gets set by a parent (e.g. undo-ing and
# redo-ing) we need to make sure it's visible to the user.
#
# Unfortunately, we can't use the native `scrollIntoView` because it
# naively scrolls the whole window and doesn't know not to scroll if
# it's already in view. There's a new native method called
# `scrollIntoViewIfNeeded`, but this only works when the scroll
# container is a direct parent of the requested element. In this case
# the scroll container may be many levels up.
_ensureSelectionVisible: (selection) ->
# If our parent supports scroll to bottom, check for that
if @props.onScrollToBottom and @_atEndOfContent(selection)
@props.onScrollToBottom()
# Don't bother computing client rects if no scroll method has been provided
else if @props.onScrollTo
rangeInScope = @_getRangeInScope()
return unless rangeInScope
rect = rangeInScope.getBoundingClientRect()
if @_isEmptyBoudingRect(rect)
rect = @_getSelectionRectFromDOM(selection)
if rect
@props.onScrollTo({rect})
@_refreshToolbarState()
_isEmptyBoudingRect: (rect) ->
rect.top is 0 and rect.bottom is 0 and rect.left is 0 and rect.right is 0
_getSelectionRectFromDOM: (selection) ->
node = selection.anchorNode
if node.nodeType is Node.TEXT_NODE
r = document.createRange()
r.selectNodeContents(node)
return r.getBoundingClientRect()
else if node.nodeType is Node.ELEMENT_NODE
return node.getBoundingClientRect()
else
return null
# We use global listeners to determine whether or not dragging is
# happening. This is because dragging may stop outside the scope of
# this element. Note that the `dragstart` and `dragend` events don't
# detect text selection. They are for drag & drop.
_setupGlobalMouseListener: =>
@__onMouseDown = _.bind(@_onMouseDown, @)
@__onMouseMove = _.bind(@_onMouseMove, @)
@__onMouseUp = _.bind(@_onMouseUp, @)
window.addEventListener("mousedown", @__onMouseDown)
window.addEventListener("mouseup", @__onMouseUp)
_teardownGlobalMouseListener: =>
window.removeEventListener("mousedown", @__onMouseDown)
window.removeEventListener("mouseup", @__onMouseUp)
_onShowContextualMenu: (event) =>
@_hideToolbar()
event.preventDefault()
selection = document.getSelection()
range = selection.getRangeAt(0)
# On Windows, right-clicking a word does not select it at the OS-level.
# We need to implement this behavior locally for the rest of the logic here.
if range.collapsed
DOMUtils.selectWordContainingRange(range)
range = selection.getRangeAt(0)
text = range.toString()
remote = require('remote')
clipboard = require('clipboard')
Menu = remote.require('menu')
MenuItem = remote.require('menu-item')
spellchecker = require('spellchecker')
apply = (newtext) =>
range.deleteContents()
node = document.createTextNode(newtext)
range.insertNode(node)
range.selectNode(node)
selection.removeAllRanges()
selection.addRange(range)
for extension in DraftStore.extensions()
if extension.onSubstitutionPerformed
extension.onSubstitutionPerformed(@_editableNode())
learnSpelling = =>
spellchecker.add(text)
for extension in DraftStore.extensions()
if extension.onLearnSpelling
extension.onLearnSpelling(@_editableNode(), text)
cut = =>
clipboard.writeText(text)
apply('')
copy = =>
clipboard.writeText(text)
paste = =>
apply(clipboard.readText())
menu = new Menu()
if spellchecker.isMisspelled(text)
corrections = spellchecker.getCorrectionsForMisspelling(text)
if corrections.length > 0
corrections.forEach (correction) ->
menu.append(new MenuItem({ label: correction, click:( -> apply(correction))}))
else
menu.append(new MenuItem({ label: 'No Guesses Found', enabled: false}))
menu.append(new MenuItem({ type: 'separator' }))
menu.append(new MenuItem({ label: 'Learn Spelling', click: learnSpelling}))
menu.append(new MenuItem({ type: 'separator' }))
menu.append(new MenuItem({ label: 'Cut', click:cut}))
menu.append(new MenuItem({ label: 'Copy', click:copy}))
menu.append(new MenuItem({ label: 'Paste', click:paste}))
menu.popup(remote.getCurrentWindow())
_onMouseDown: (event) =>
@_mouseDownEvent = event
@_mouseHasMoved = false
window.addEventListener("mousemove", @__onMouseMove)
# We can't use the native double click event because that only fires
# on the second up-stroke
if Date.now() - (@_lastMouseDown ? 0) < 250
@_onDoubleDown(event)
@_lastMouseDown = 0 # to prevent triple down
else
@_lastMouseDown = Date.now()
_onDoubleDown: (event) =>
editable = @_editableNode()
return unless editable?
if editable is event.target or editable.contains(event.target)
@_doubleDown = true
_onMouseMove: (event) =>
if not @_mouseHasMoved
@_onDragStart(@_mouseDownEvent)
@_mouseHasMoved = true
_onMouseUp: (event) =>
window.removeEventListener("mousemove", @__onMouseMove)
if @_doubleDown
@_doubleDown = false
@_refreshToolbarState()
if @_mouseHasMoved
@_mouseHasMoved = false
@_onDragEnd(event)
editableNode = @_editableNode()
selection = document.getSelection()
return event unless @_selectionInScope(selection)
range = @_getRangeInScope()
if range
try
for extension in DraftStore.extensions()
extension.onMouseUp(editableNode, range, event) if extension.onMouseUp
catch e
console.log('DraftStore extension raised an error: '+e.toString())
event
_onDragStart: (event) =>
editable = @_editableNode()
return unless editable?
if editable is event.target or editable.contains(event.target)
@_dragging = true
_onDragEnd: (event) =>
if @_dragging
@_dragging = false
@_refreshToolbarState()
return event
# We restore the Selection via the `setBaseAndExtent` property of the
# `Selection` API
#
# See http://w3c.github.io/selection-api/#widl-Selection-setBaseAndExtent-void-Node-anchorNode-unsigned-long-anchorOffset-Node-focusNode-unsigned-long-focusOffset
#
# Since the last time we saved the `@_selection`, the DOM may have
# completely changed due to a re-render. To the user it may look
# identical, but the newly rendered region may be comprised of
# completely new DOM nodes. Our old node references may not exist
# anymore. As such, we have the task of re-finding the nodes again and
# creating a new selection that matches as accurately as possible.
#
# There are multiple ways of setting a new selection with the Selection
# API. One very common one is to create a new Range object and then call
# `addRange` on a selection instance. This does NOT work for us because
# `Range` objects are direction-less. A Selection's start node (aka
# anchor node aka base node) can be "after" a selection's end node (aka
# focus node aka extent node).
#
# 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 @_dragging
return if not @_selection?
return if document.activeElement isnt @_editableNode() and not force
return if not @_selection.startNode? or not @_selection.endNode?
newStartNode = @_findSimilarNodes(@_selection.startNode)[@_selection.startNodeIndex]
newEndNode = @_findSimilarNodes(@_selection.endNode)[@_selection.endNodeIndex]
return if not newStartNode? or not newEndNode?
@_teardownSelectionListeners()
selection = document.getSelection()
selection.setBaseAndExtent(newStartNode,
@_selection.startOffset,
newEndNode,
@_selection.endOffset)
@_ensureSelectionVisible(selection)
@_setupSelectionListeners()
# We need to break each node apart and cache since the `selection`
# object will mutate underneath us.
_checkSameSelection: (newSelection) =>
return true if not newSelection?
return false if not @_selection
return false if not newSelection.anchorNode? or not newSelection.focusNode?
anchorIndex = @_getNodeIndex(newSelection.anchorNode)
focusIndex = @_getNodeIndex(newSelection.focusNode)
anchorEqual = newSelection.anchorNode.isEqualNode @_selection.startNode
anchorIndexEqual = anchorIndex is @_selection.startNodeIndex
focusEqual = newSelection.focusNode.isEqualNode @_selection.endNode
focusIndexEqual = focusIndex is @_selection.endNodeIndex
if not anchorEqual and not focusEqual
# This means the newSelection is the same, but just from the opposite
# direction. We don't care in this case, so check the reciprocal as
# well.
anchorEqual = newSelection.anchorNode.isEqualNode @_selection.endNode
anchorIndexEqual = anchorIndex is @_selection.endNodeIndex
focusEqual = newSelection.focusNode.isEqualNode @_selection.startNode
focusIndexEqual = focusIndex is @_selection.startndNodeIndex
anchorOffsetEqual = newSelection.anchorOffset == @_selection.startOffset
focusOffsetEqual = newSelection.focusOffset == @_selection.endOffset
if not anchorOffsetEqual and not focusOffsetEqual
# This means the newSelection is the same, but just from the opposite
# direction. We don't care in this case, so check the reciprocal as
# well.
anchorOffsetEqual = newSelection.anchorOffset == @_selection.focusOffset
focusOffsetEqual = newSelection.focusOffset == @_selection.anchorOffset
if (anchorEqual and
anchorIndexEqual and
anchorOffsetEqual and
focusEqual and
focusIndexEqual and
focusOffsetEqual)
return true
else
return false
_getNodeIndex: (nodeToFind) =>
@_findSimilarNodes(nodeToFind).indexOf nodeToFind
_findSimilarNodes: (nodeToFind) =>
nodeList = []
editableNode = @_editableNode()
if nodeToFind.isEqualNode(editableNode)
nodeList.push(editableNode)
return nodeList
treeWalker = document.createTreeWalker editableNode
while treeWalker.nextNode()
if treeWalker.currentNode.isEqualNode nodeToFind
nodeList.push(treeWalker.currentNode)
return nodeList
_isEqualNode: =>
_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: =>
return if @_dragging or (@_doubleDown and not @state.toolbarVisible)
if @_linkHoveringOver
url = @_linkHoveringOver.getAttribute('href')
rect = @_linkHoveringOver.getBoundingClientRect()
[left, top, editAreaWidth, toolbarPos] = @_getToolbarPos(rect)
@setState
toolbarVisible: true
toolbarMode: "edit-link"
toolbarTop: top
toolbarLeft: left
toolbarPos: toolbarPos
linkToModify: @_linkHoveringOver
editAreaWidth: editAreaWidth
else if not @_selection? or @_selection.isCollapsed
@_hideToolbar()
else
rect = @_getRangeInScope()?.getBoundingClientRect()
if not rect or @_isEmptyBoudingRect(rect)
@_hideToolbar()
else
[left, top, editAreaWidth, toolbarPos] = @_getToolbarPos(rect)
@setState
toolbarVisible: true
toolbarMode: "buttons"
toolbarTop: top
toolbarLeft: left
toolbarPos: toolbarPos
linkToModify: null
editAreaWidth: editAreaWidth
_selectionInScope: (selection) =>
return false if not selection?
editable = @_editableNode()
return false if not editable?
return (editable.contains(selection.anchorNode) and
editable.contains(selection.focusNode))
CONTENT_PADDING: 15
_getToolbarPos: (referenceRect) =>
TOP_PADDING = 10
BORDER_RADIUS_PADDING = 15
editArea = @_editableNode().getBoundingClientRect()
calcLeft = (referenceRect.left - editArea.left) + referenceRect.width/2
calcLeft = Math.min(Math.max(calcLeft, @CONTENT_PADDING+BORDER_RADIUS_PADDING), editArea.width - BORDER_RADIUS_PADDING)
calcTop = referenceRect.top - editArea.top - 48
toolbarPos = "above"
if calcTop < TOP_PADDING
calcTop = referenceRect.top - editArea.top + referenceRect.height + TOP_PADDING + 4
toolbarPos = "below"
return [calcLeft, calcTop, editArea.width, toolbarPos]
_hideToolbar: =>
if not @_focusedOnToolbar() and @state.toolbarVisible
@setState toolbarVisible: false
_focusedOnToolbar: =>
React.findDOMNode(@refs.floatingToolbar)?.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) =>
link.hoverId = genLinkId()
@_links[link.hoverId] = {}
enterListener = (event) =>
@_clearLinkTimeouts()
@_linkHoveringOver = link
@_links[link.hoverId].enterTimeout = setTimeout =>
@_refreshToolbarState()
, HOVER_IN_DELAY
leaveListener = (event) =>
@_clearLinkTimeouts()
@_linkHoveringOver = null
@_links[link.hoverId].leaveTimeout = setTimeout =>
return if @refs.floatingToolbar.isHovering
@_refreshToolbarState()
, HOVER_OUT_DELAY
link.addEventListener "mouseenter", enterListener
link.addEventListener "mouseleave", leaveListener
@_links[link.hoverId].link = link
@_links[link.hoverId].enterListener = enterListener
@_links[link.hoverId].leaveListener = leaveListener
_clearLinkTimeouts: =>
for hoverId, linkData of @_links
clearTimeout(linkData.enterTimeout) if linkData.enterTimeout?
clearTimeout(linkData.leaveTimeout) if linkData.leaveTimeout?
_onTooltipMouseEnter: =>
clearTimeout(@_clearTooltipTimeout) if @_clearTooltipTimeout?
_onTooltipMouseLeave: =>
@_clearTooltipTimeout = setTimeout =>
@_refreshToolbarState()
, 500
_teardownLinkHoverListeners: =>
for hoverId, linkData of @_links
clearTimeout linkData.enterTimeout
clearTimeout linkData.leaveTimeout
linkData.link.removeEventListener "mouseenter", linkData.enterListener
linkData.link.removeEventListener "mouseleave", linkData.leaveListener
@_links = {}
####### CLEAN PASTE #########
_onPaste: (evt) =>
return if evt.clipboardData.items.length is 0
evt.preventDefault()
# If the pasteboard has a file on it, stream it to a teporary
# file and fire our `onFilePaste` event.
item = evt.clipboardData.items[0]
if item.kind is 'file' and @props.onFilePaste
blob = item.getAsFile()
ext = {'image/png': '.png', 'image/jpg': '.jpg', 'image/tiff': '.tiff'}[item.type] ? ''
temp = require 'temp'
path = require 'path'
fs = require 'fs'
reader = new FileReader()
reader.addEventListener 'loadend', =>
buffer = new Buffer(new Uint8Array(reader.result))
tmpFolder = temp.path('-nylas-attachment')
tmpPath = path.join(tmpFolder, "Pasted File#{ext}")
fs.mkdir tmpFolder, =>
fs.writeFile tmpPath, buffer, (err) =>
@props.onFilePaste(tmpPath)
reader.readAsArrayBuffer(blob)
else
# Look for text/html in any of the clipboard items and fall
# back to text/plain.
inputText = evt.clipboardData.getData("text/html") ? ""
type = "text/html"
if inputText.length is 0
inputText = evt.clipboardData.getData("text/plain") ? ""
type = "text/plain"
if inputText.length > 0
cleanHtml = @_sanitizeInput(inputText, type)
document.execCommand("insertHTML", false, cleanHtml)
return
# This is used primarily when pasting text in
_sanitizeInput: (inputText="", type="text/html") =>
if type is "text/plain"
inputText = Utils.encodeHTMLEntities(inputText)
inputText = inputText.replace(/[\r\n]|&#1[03];/g, "<br/>").
replace(/\s\s/g, " &nbsp;")
else
inputText = sanitizeHtml inputText.replace(/[\n\r]/g, "<br/>"),
allowedTags: ['p', 'b', 'i', 'em', 'strong', 'a', 'br', 'img', 'ul', 'ol', 'li', 'strike']
allowedAttributes:
a: ['href', 'name']
img: ['src', 'alt']
transformTags:
h1: "p"
h2: "p"
h3: "p"
h4: "p"
h5: "p"
h6: "p"
div: "p"
pre: "p"
blockquote: "p"
table: "p"
# We sanitized everything and convert all whitespace-inducing
# elements into <p> tags. We want to de-wrap <p> tags and replace
# with two line breaks instead.
inputText = inputText.replace(/<p[\s\S]*?>/gim, "").
replace(/<\/p>/gi, "<br/>")
# We never want more then 2 line breaks in a row.
# https://regex101.com/r/gF6bF4/4
inputText = inputText.replace(/(<br\s*\/?>\s*){3,}/g, "<br/><br/>")
# We never want to keep leading and trailing <brs>, since the user
# would have started a new paragraph themselves if they wanted space
# before what they paste.
# BAD: "<p>begins at<br>12AM</p>" => "<br><br>begins at<br>12AM<br><br>"
# Better: "<p>begins at<br>12AM</p>" => "begins at<br>12"
inputText = inputText.replace(/^(<br ?\/>)+/, '')
inputText = inputText.replace(/(<br ?\/>)+$/, '')
return inputText
####### QUOTED TEXT #########
_onToggleQuotedText: =>
@props.onChangeMode?(showQuotedText: !@props.mode?.showQuotedText)
_quotedTextClasses: => classNames
"quoted-text-control": true
module.exports = ContenteditableComponent