Mailspring/internal_packages/composer/lib/contenteditable-component.cjsx
Evan Morikawa e8bffef723 feat(composer): improve tab useage in composer
A `*` as well as a `-` will start a list.

If you tab anywhere in plain text, it will insert a tab character.

If your shift-tab anywhere in plain text, and the character just before
the cursor is a "tab" character, it will delete that last tab character.

Fix a bug whereby shift-tab and tab will indent/outdent from anywhere
inside of a list.

Fixes T3409
2015-08-31 16:55:01 -07:00

1099 lines
39 KiB
CoffeeScript

_ = require 'underscore'
React = require 'react'
classNames = require 'classnames'
sanitizeHtml = require 'sanitize-html'
{Utils, QuotedHTMLParser, DraftStore} = require 'nylas-exports'
FloatingToolbar = require './floating-toolbar'
linkUUID = 0
genLinkId = -> linkUUID += 1; return linkUUID
class ContenteditableComponent extends React.Component
@displayName = "Contenteditable"
@propTypes =
html: React.PropTypes.string
style: React.PropTypes.object
tabIndex: React.PropTypes.string
onChange: React.PropTypes.func.isRequired
mode: React.PropTypes.object
onFilePaste: React.PropTypes.func
onChangeMode: React.PropTypes.func
initialSelectionSnapshot: React.PropTypes.object
# 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
tabIndex={@props.tabIndex}
style={@props.style ? {}}
onBlur={@_onBlur}
onClick={@_onClick}
onPaste={@_onPaste}
onInput={@_onInput}
onKeyDown={@_onKeyDown}
dangerouslySetInnerHTML={@_dangerouslySetInnerHTML()}></div>
<a className={@_quotedTextClasses()} onClick={@_onToggleQuotedText}></a>
</div>
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
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
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
_editableNode: =>
React.findDOMNode(@refs.contenteditable)
_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?
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)
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)
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))}))
menu.append(new MenuItem({ type: 'separator' }))
menu.append(new MenuItem({ label: 'Learn Spelling', click:( -> spellchecker.add(text))}))
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
"no-quoted-text": not QuotedHTMLParser.hasQuotedHTML(@props.html)
"show-quoted-text": @props.mode?.showQuotedText
module.exports = ContenteditableComponent