diff --git a/internal_packages/composer/lib/clipboard-service.coffee b/internal_packages/composer/lib/clipboard-service.coffee
new file mode 100644
index 000000000..b8c6a8288
--- /dev/null
+++ b/internal_packages/composer/lib/clipboard-service.coffee
@@ -0,0 +1,91 @@
+{Utils} = require 'nylas-exports'
+sanitizeHtml = require 'sanitize-html'
+
+class ClipboardService
+ constructor: ({@onFilePaste}={}) ->
+
+ 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'
+ 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) =>
+ @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]|[03];/g, "
").
+ replace(/\s\s/g, " ")
+ else
+ inputText = sanitizeHtml inputText.replace(/[\n\r]/g, "
"),
+ 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
tags. We want to de-wrap
tags and replace + # with two line breaks instead. + inputText = inputText.replace(/
/gim, "").
+ replace(/<\/p>/gi, " begins at begins at Foo 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
- node.data.replace(/\u00a0/g, "x").trim().length is 0
-
_setSelectionSnapshot: (selection) =>
@_previousSelection = @_selection
@_selection = selection
-
+ @setInnerState
+ selection: @_selection
+ editableFocused: true
# 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.
@@ -594,25 +602,23 @@ class ContenteditableComponent extends React.Component
# 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)
+ if @props.onScrollToBottom and DOMUtils.atEndOfContent(selection, @_editableNode())
@props.onScrollToBottom()
# Don't bother computing client rects if no scroll method has been provided
else if @props.onScrollTo
- rangeInScope = @_getRangeInScope()
+ rangeInScope = DOMUtils.getRangeInScope(@_editableNode())
return unless rangeInScope
rect = rangeInScope.getBoundingClientRect()
- if @_isEmptyBoudingRect(rect)
+ if DOMUtils.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
+ # The bounding client rect has changed
+ @setInnerState editableNode: @_editableNode()
_getSelectionRectFromDOM: (selection) ->
node = selection.anchorNode
@@ -641,7 +647,7 @@ class ContenteditableComponent extends React.Component
window.removeEventListener("mouseup", @__onMouseUp)
_onShowContextualMenu: (event) =>
- @_hideToolbar()
+ @refs["toolbarController"]?.forceClose()
event.preventDefault()
selection = document.getSelection()
@@ -724,7 +730,7 @@ class ContenteditableComponent extends React.Component
editable = @_editableNode()
return unless editable?
if editable is event.target or editable.contains(event.target)
- @_doubleDown = true
+ @setInnerState doubleDown: true
_onMouseMove: (event) =>
if not @_mouseHasMoved
@@ -734,9 +740,8 @@ class ContenteditableComponent extends React.Component
_onMouseUp: (event) =>
window.removeEventListener("mousemove", @__onMouseMove)
- if @_doubleDown
- @_doubleDown = false
- @_refreshToolbarState()
+ if @innerState.doubleDown
+ @setInnerState doubleDown: false
if @_mouseHasMoved
@_mouseHasMoved = false
@@ -744,9 +749,9 @@ class ContenteditableComponent extends React.Component
editableNode = @_editableNode()
selection = document.getSelection()
- return event unless @_selectionInScope(selection)
+ return event unless DOMUtils.selectionInScope(selection, editableNode)
- range = @_getRangeInScope()
+ range = DOMUtils.getRangeInScope(editableNode)
if range
try
for extension in DraftStore.extensions()
@@ -760,12 +765,11 @@ class ContenteditableComponent extends React.Component
editable = @_editableNode()
return unless editable?
if editable is event.target or editable.contains(event.target)
- @_dragging = true
+ @setInnerState dragging: true
_onDragEnd: (event) =>
- if @_dragging
- @_dragging = false
- @_refreshToolbarState()
+ if @innerState.dragging
+ @setInnerState dragging: false
return event
# We restore the Selection via the `setBaseAndExtent` property of the
@@ -794,13 +798,14 @@ class ContenteditableComponent extends React.Component
# selection, we'll collapse the range into a single caret
# position
_restoreSelection: ({force, collapse}={}) =>
- return if @_dragging
+ return if @innerState.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]
+ editable = @_editableNode()
+ newStartNode = DOMUtils.findSimilarNodes(editable, @_selection.startNode)[@_selection.startNodeIndex]
+ newEndNode = DOMUtils.findSimilarNodes(editable, @_selection.endNode)[@_selection.endNodeIndex]
return if not newStartNode? or not newEndNode?
@_teardownSelectionListeners()
@@ -813,149 +818,8 @@ class ContenteditableComponent extends React.Component
@_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)
+ DOMUtils.findSimilarNodes(@_editableNode(), nodeToFind).indexOf nodeToFind
# This needs to be in the contenteditable area because we need to first
# restore the selection before calling the `execCommand`
@@ -963,181 +827,37 @@ class ContenteditableComponent extends React.Component
# 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)
+ linkToModify = DOMUtils.findSimilarNodes(@_editableNode(), linkToModify)?[0]?.childNodes[0]
+
+ return unless linkToModify?
+ return if linkToModify.getAttribute?('href').trim() is url.trim()
+
+ range =
+ anchorNode: linkToModify
+ anchorOffset: 0
+ focusNode: linkToModify
+ focusOffset: linkToModify.length
+
if url.trim().length is 0
- document.execCommand("unlink", false)
- else
- document.execCommand("createLink", false, url)
- @_setupSelectionListeners()
+ @_execCommand ["unlink", false], range
+ else @_execCommand ["createLink", false, url], range
+
else
@_restoreSelection(force: true)
- if document.getSelection().isCollapsed
- # TODO
- else
+ if not document.getSelection().isCollapsed
if url.trim().length is 0
- document.execCommand("unlink", false)
- else
- document.execCommand("createLink", false, url)
+ @_execCommand ["unlink", false]
+ else @_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]|[03];/g, " tags. We want to de-wrap tags and replace
- # with two line breaks instead.
- inputText = inputText.replace(/ /gim, "").
- replace(/<\/p>/gi, " begins at begins at Foo 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 = DOMUtils.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 rootScope
+ 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
+ DOMUtils.atEndOfContent(selection, rootScope, 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 DOMUtils.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
+ node.data.replace(/\u00a0/g, "x").trim().length is 0
+
+ findSimilarNodes: (context, nodeToFind) ->
+ nodeList = []
+ if nodeToFind.isEqualNode(context)
+ nodeList.push(context)
+ return nodeList
+ treeWalker = document.createTreeWalker context
+ while treeWalker.nextNode()
+ if treeWalker.currentNode.isEqualNode nodeToFind
+ nodeList.push(treeWalker.currentNode)
+
+ return nodeList
+
escapeHTMLCharacters: (text) ->
map =
'&': '&',
@@ -86,6 +292,15 @@ DOMUtils =
nodes.unshift(node) while node = node.parentNode
return nodes
+ # Returns true if the node is the first child of the root, is the root,
+ # or is the first child of the first child of the root, etc.
+ isFirstChild: (root, node) ->
+ return false unless root and node
+ return true if root is node
+ return false unless root.childNodes[0]
+ return true if root.childNodes[0] is node
+ return DOMUtils.isFirstChild(root.childNodes[0], node)
+
# This method finds the bounding points of the word that the range
# is currently within and selects that word.
selectWordContainingRange: (range) ->
diff --git a/src/flux/stores/draft-store-extension.coffee b/src/flux/stores/draft-store-extension.coffee
index 7ccc7f953..f3f59032b 100644
--- a/src/flux/stores/draft-store-extension.coffee
+++ b/src/flux/stores/draft-store-extension.coffee
@@ -43,6 +43,29 @@ class DraftStoreExtension
@warningsForSending: (draft) ->
[]
+ ###
+ Public: declare an icon to be displayed in the composer's toolbar (where
+ bold, italic, underline, etc are).
+
+ You must declare the following properties:
+
+ - `mutator`: A function that's called when your toolbar button is
+ clicked. This mutator function will be passed as its only argument the
+ `dom`. The `dom` is the full {DOM} object of the current composer. You
+ may mutate this in place. We don't care about the mutator's return
+ value.
+
+ - `tooltip`: A one or two word description of what your icon does
+
+ - `iconUrl`: The url of your icon. It should be in the `nylas://` scheme.
+ For example: `nylas://your-package-name/assets/my-icon@2x.png`. Note, we
+ will downsample your image by 2x (for Retina screens), so make sure it's
+ twice the resolution. The icon should be black and white. We will
+ directly pass the `url` prop of a {RetinaImg}
+ ###
+ @composerToolbar: ->
+ return
+
###
Public: Override prepareNewDraft to modify a brand new draft before it is displayed
in a composer. This is one of the only places in the application where it's safe
@@ -129,8 +152,12 @@ class DraftStoreExtension
return
###
- Public: Override onInput in your DraftStoreExtension subclass to implement
- custom behavior as the user types in the composer's contenteditable body field.
+ Public: Override onInput in your DraftStoreExtension subclass to
+ implement custom behavior as the user types in the composer's
+ contenteditable body field.
+
+ As the first argument you are passed the entire DOM object of the
+ composer. You may mutate this object and edit it in place.
Example:
")
+
+ # We never want more then 2 line breaks in a row.
+ # https://regex101.com/r/gF6bF4/4
+ inputText = inputText.replace(/(
\s*){3,}/g, "
")
+
+ # We never want to keep leading and trailing
12AM
begins at
12AM
"
+ # Better: "
12AM
12"
+ inputText = inputText.replace(/^(
)+/, '')
+ inputText = inputText.replace(/(
)+$/, '')
+
+ return inputText
+
+module.exports = ClipboardService
diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx
index 2952cf4fb..e1eb02fce 100644
--- a/internal_packages/composer/lib/composer-view.cjsx
+++ b/internal_packages/composer/lib/composer-view.cjsx
@@ -25,6 +25,7 @@ ImageFileUpload = require './image-file-upload'
ExpandedParticipants = require './expanded-participants'
CollapsedParticipants = require './collapsed-participants'
+ContenteditableFilter = require './contenteditable-filter'
ContenteditableComponent = require './contenteditable-component'
Fields = require './fields'
@@ -195,20 +196,20 @@ class ComposerView extends React.Component
_renderContent: ->
"
+ else
+ replaceWith = replaceWith.replace /\s/g, " "
+ text = document.createElement("span")
+ text.innerHTML = "#{replaceWith}"
+
+ if list.querySelectorAll('li').length <= 1
+ # Delete the whole list and replace with text
+ list.parentNode.replaceChild(text, list)
+ else
+ # Delete the list item and prepend the text before the rest of the
+ # list
+ li.parentNode.removeChild(li)
+ list.parentNode.insertBefore(text, list)
+
+ child = text.childNodes[0] ? text
+ index = Math.max(replaceWith.length - 1, 0)
+ selection = document.getSelection()
+ selection.setBaseAndExtent(child, index, child, index)
+
+ @_setupSelectionListeners()
+ @_onInput()
+
_onTabDown: (event) ->
event.preventDefault()
selection = document.getSelection()
@@ -228,6 +306,16 @@ class ContenteditableComponent extends React.Component
return selection.anchorNode.textContent[selection.anchorOffset - 1] is "\t"
else return false
+ _atStartOfList: ->
+ selection = document.getSelection()
+ anchor = selection.anchorNode
+ return false if not selection.isCollapsed
+ return true if anchor?.nodeName is "LI"
+ return false if selection.anchorOffset > 0
+ li = anchor.closest("li")
+ return unless li
+ return DOMUtils.isFirstChild(li, anchor)
+
_atBeginning: ->
selection = document.getSelection()
return false if not selection.isCollapsed
@@ -261,7 +349,7 @@ class ContenteditableComponent extends React.Component
# 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: ->
+ _normalize: ->
@_cleanHTML()
@_cleanSelection()
@@ -281,6 +369,8 @@ class ContenteditableComponent extends React.Component
# nodes.
@_editableNode().normalize()
+ @_collapseAdjacentLists()
+
@_fixLeadingBRCondition()
# An issue arises from
tags immediately inside of divs. In this
@@ -308,6 +398,15 @@ class ContenteditableComponent extends React.Component
childNodes = node.childNodes
return childNodes.length >= 2 and childNodes[0].nodeName is "BR"
+ # If users ended up with two lists adjacent to each other, we
+ # collapse them into one. We leave adjacent
lists intact in case
+ # the user wanted to restart the numbering sequence
+ _collapseAdjacentLists: ->
+ els = @_editableNode().querySelectorAll('ul')
+
+ # This mutates the DOM in place.
+ DOMUtils.collapseAdjacentElements(els)
+
# 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
@@ -337,49 +436,38 @@ class ContenteditableComponent extends React.Component
_unselectableNode: (node) ->
return true if not node
- if node.nodeType is Node.TEXT_NODE and @_isBlankTextNode(node)
+ if node.nodeType is Node.TEXT_NODE and DOMUtils.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))
+ hasText = (child.nodeType is Node.TEXT_NODE and not DOMUtils.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
+ # console.log "On Blur Contenteditable"
+ @setInnerState 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()
+ @setInnerState editableFocused: false
, 50
_onFocus: (event) =>
+ @setInnerState editableFocused: true
@props.onFocus?(event)
_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)
-
+ html = @props.html
+ for filter in @props.filters
+ html = filter.beforeDisplay(html)
+ return __html: html
######### SELECTION MANAGEMENT ##########
#
@@ -431,16 +519,6 @@ class ContenteditableComponent extends React.Component
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.
#
@@ -479,109 +557,39 @@ class ContenteditableComponent extends React.Component
# 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.
+ # the `DOMUtils.getNodeIndex` method.
_saveSelectionState: =>
selection = document.getSelection()
- return if @_checkSameSelection(selection)
+ context = @_editableNode()
+ return if DOMUtils.isSameSelection(selection, @_selection, context)
return unless selection.anchorNode? and selection.focusNode?
- return unless @_selectionInScope(selection)
+ return unless DOMUtils.selectionInScope(selection, context)
@_previousSelection = @_selection
@_selection =
startNode: selection.anchorNode.cloneNode(true)
startOffset: selection.anchorOffset
- startNodeIndex: @_getNodeIndex(selection.anchorNode)
+ startNodeIndex: DOMUtils.getNodeIndex(context, selection.anchorNode)
endNode: selection.focusNode.cloneNode(true)
endOffset: selection.focusOffset
- endNodeIndex: @_getNodeIndex(selection.focusNode)
+ endNodeIndex: DOMUtils.getNodeIndex(context, selection.focusNode)
isCollapsed: selection.isCollapsed
@_ensureSelectionVisible(selection)
- @_refreshToolbarState()
+
+ @setInnerState
+ selection: @_selection
+ editableFocused: true
+
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:
- #
").
- replace(/\s\s/g, " ")
- else
- inputText = sanitizeHtml inputText.replace(/[\n\r]/g, "
"),
- 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
")
-
- # We never want more then 2 line breaks in a row.
- # https://regex101.com/r/gF6bF4/4
- inputText = inputText.replace(/(
\s*){3,}/g, "
")
-
- # We never want to keep leading and trailing
12AM
begins at
12AM
"
- # Better: "
12AM
12"
- inputText = inputText.replace(/^(
)+/, '')
- inputText = inputText.replace(/(
)+$/, '')
-
- return inputText
-
-
- ####### QUOTED TEXT #########
-
- _onToggleQuotedText: =>
- @props.onChangeMode?(showQuotedText: !@props.mode?.showQuotedText)
-
- _quotedTextClasses: => classNames
- "quoted-text-control": true
+ _execCommand: (commandArgs=[], selectionRange={}) =>
+ {anchorNode, anchorOffset, focusNode, focusOffset} = selectionRange
+ @_teardownSelectionListeners()
+ if anchorNode and focusNode
+ selection = document.getSelection()
+ selection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset)
+ document.execCommand.apply(document, commandArgs)
+ @_setupSelectionListeners()
+ @_onInput()
module.exports = ContenteditableComponent
diff --git a/internal_packages/composer/lib/contenteditable-filter.coffee b/internal_packages/composer/lib/contenteditable-filter.coffee
new file mode 100644
index 000000000..610eaa904
--- /dev/null
+++ b/internal_packages/composer/lib/contenteditable-filter.coffee
@@ -0,0 +1,11 @@
+class ContenteditableFilter
+ # Gets called immediately before insert the HTML into the DOM. This is
+ # useful for modifying what the user sees compared to the data we're
+ # storing.
+ beforeDisplay: ->
+
+ # Gets called just after the content has changed but just before we save
+ # out the new HTML. The inverse of `beforeDisplay`
+ afterDisplay: ->
+
+module.exports = ContenteditableFilter
diff --git a/internal_packages/composer/lib/expanded-participants.cjsx b/internal_packages/composer/lib/expanded-participants.cjsx
index f818004b8..b44dd70f2 100644
--- a/internal_packages/composer/lib/expanded-participants.cjsx
+++ b/internal_packages/composer/lib/expanded-participants.cjsx
@@ -90,7 +90,7 @@ class ExpandedParticipants extends React.Component
+ onClick={@props.onPopoutComposer}>
World"
+ # Convert newline to br
+ sanitizedAsPlain: "Hello
World"
+ },
+ {
+ in: "Hello\rWorld"
+ sanitizedAsHTML: "Hello
World"
+ # Convert carriage return to br
+ sanitizedAsPlain: "Hello
World"
+ },
+ {
+ in: "Hello\n\n\nWorld"
+ # Never have more than 2 br's in a row
+ sanitizedAsHTML: "Hello
World"
+ # Convert multiple newlines to same number of brs
+ sanitizedAsPlain: "Hello
World"
+ },
+ {
+ in: " Foo Bar
+
+
+ """
+ # Strip non white-list tags and encode malformed ones.
+ sanitizedAsHTML: "
"
+ # HTML encode tags for literal display
+ sanitizedAsPlain: "<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta http-equiv="Content-Style-Type" content="text/css">
<title></title>
<meta name="Generator" content="Cocoa HTML Writer">
<meta name="CocoaVersion" content="1265.21">
<style type="text/css">
li.li1 {margin: 0.0px 0.0px 0.0px 0.0px; font: 12.0px Helvetica}
ul.ul1 {list-style-type: disc}
</style>
</head>
<body>
<ul class="ul1">
<li class="li1"><b>Packet pickup: </b>I'll pick up my packet at some point on Saturday at Fort Mason. Let me know if you'd like me to get yours. I'll need a photo of your ID and your confirmation number. Also, shirt color preference, I believe. Gray or black? Can't remember...</li>
</ul>
</body>
</html>"
+ }
+ ]
+
+ it "sanitizes plain text properly", ->
+ for test in tests
+ expect(@clipboardService._sanitizeInput(test.in, "text/plain")).toBe test.sanitizedAsPlain
+
+ it "sanitizes html text properly", ->
+ for test in tests
+ expect(@clipboardService._sanitizeInput(test.in, "text/html")).toBe test.sanitizedAsHTML
diff --git a/internal_packages/composer/spec/contenteditable-component-spec.cjsx b/internal_packages/composer/spec/contenteditable-component-spec.cjsx
index 5c00d14a2..4d7417c46 100644
--- a/internal_packages/composer/spec/contenteditable-component-spec.cjsx
+++ b/internal_packages/composer/spec/contenteditable-component-spec.cjsx
@@ -76,163 +76,3 @@ describe "ContenteditableComponent", ->
contents = fs.readFileSync(file)
expect(contents.toString()).toEqual('12341352312411')
- describe "when html and plain text parts are present", ->
- beforeEach ->
- @mockEvent =
- preventDefault: jasmine.createSpy('preventDefault')
- clipboardData:
- getData: ->
- return 'This is text' if 'text/html'
- return 'This is plain text' if 'text/plain'
- return null
- items: [{
- kind: 'string'
- type: 'text/html'
- getAsString: -> 'This is text'
- },{
- kind: 'string'
- type: 'text/plain'
- getAsString: -> 'This is plain text'
- }]
-
- it "should sanitize the HTML string and call insertHTML", ->
- spyOn(document, 'execCommand')
- spyOn(@component, '_sanitizeInput').andCallThrough()
-
- runs ->
- ReactTestUtils.Simulate.paste(@editableNode, @mockEvent)
- waitsFor ->
- document.execCommand.callCount > 0
- runs ->
- expect(@component._sanitizeInput).toHaveBeenCalledWith('This is text', 'text/html')
- [command, a, html] = document.execCommand.mostRecentCall.args
- expect(command).toEqual('insertHTML')
- expect(html).toEqual('This is text')
-
- describe "when html and plain text parts are present", ->
- beforeEach ->
- @mockEvent =
- preventDefault: jasmine.createSpy('preventDefault')
- clipboardData:
- getData: ->
- return 'This is plain text' if 'text/plain'
- return null
- items: [{
- kind: 'string'
- type: 'text/plain'
- getAsString: -> 'This is plain text'
- }]
-
- it "should sanitize the plain text string and call insertHTML", ->
- spyOn(document, 'execCommand')
- spyOn(@component, '_sanitizeInput').andCallThrough()
-
- runs ->
- ReactTestUtils.Simulate.paste(@editableNode, @mockEvent)
- waitsFor ->
- document.execCommand.callCount > 0
- runs ->
- expect(@component._sanitizeInput).toHaveBeenCalledWith('This is plain text', 'text/html')
- [command, a, html] = document.execCommand.mostRecentCall.args
- expect(command).toEqual('insertHTML')
- expect(html).toEqual('This is plain text')
-
- describe "sanitization", ->
- tests = [
- {
- in: ""
- sanitizedAsHTML: ""
- sanitizedAsPlain: ""
- },
- {
- in: "Hello World"
- sanitizedAsHTML: "Hello World"
- sanitizedAsPlain: "Hello World"
- },
- {
- in: " Hello World"
- # Should collapse to 1 space when rendered
- sanitizedAsHTML: " Hello World"
- # Preserving 2 spaces
- sanitizedAsPlain: " Hello World"
- },
- {
- in: " Hello World"
- sanitizedAsHTML: " Hello World"
- # Preserving 3 spaces
- sanitizedAsPlain: " Hello World"
- },
- {
- in: " Hello World"
- sanitizedAsHTML: " Hello World"
- # Preserving 4 spaces
- sanitizedAsPlain: " Hello World"
- },
- {
- in: "Hello\nWorld"
- sanitizedAsHTML: "Hello
World"
- # Convert newline to br
- sanitizedAsPlain: "Hello
World"
- },
- {
- in: "Hello\rWorld"
- sanitizedAsHTML: "Hello
World"
- # Convert carriage return to br
- sanitizedAsPlain: "Hello
World"
- },
- {
- in: "Hello\n\n\nWorld"
- # Never have more than 2 br's in a row
- sanitizedAsHTML: "Hello
World"
- # Convert multiple newlines to same number of brs
- sanitizedAsPlain: "Hello
World"
- },
- {
- in: " Foo Bar
-
-
- """
- # Strip non white-list tags and encode malformed ones.
- sanitizedAsHTML: "
"
- # HTML encode tags for literal display
- sanitizedAsPlain: "<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta http-equiv="Content-Style-Type" content="text/css">
<title></title>
<meta name="Generator" content="Cocoa HTML Writer">
<meta name="CocoaVersion" content="1265.21">
<style type="text/css">
li.li1 {margin: 0.0px 0.0px 0.0px 0.0px; font: 12.0px Helvetica}
ul.ul1 {list-style-type: disc}
</style>
</head>
<body>
<ul class="ul1">
<li class="li1"><b>Packet pickup: </b>I'll pick up my packet at some point on Saturday at Fort Mason. Let me know if you'd like me to get yours. I'll need a photo of your ID and your confirmation number. Also, shirt color preference, I believe. Gray or black? Can't remember...</li>
</ul>
</body>
</html>"
- }
- ]
-
- it "sanitizes plain text properly", ->
- for test in tests
- expect(@component._sanitizeInput(test.in, "text/plain")).toBe test.sanitizedAsPlain
-
- it "sanitizes html text properly", ->
- for test in tests
- expect(@component._sanitizeInput(test.in, "text/html")).toBe test.sanitizedAsHTML
diff --git a/internal_packages/composer/spec/contenteditable-quoted-text-spec.cjsx b/internal_packages/composer/spec/contenteditable-quoted-text-spec.cjsx
index bb98cf1b2..68a3e024d 100644
--- a/internal_packages/composer/spec/contenteditable-quoted-text-spec.cjsx
+++ b/internal_packages/composer/spec/contenteditable-quoted-text-spec.cjsx
@@ -6,6 +6,9 @@
_ = require "underscore"
React = require "react/addons"
ReactTestUtils = React.addons.TestUtils
+
+Fields = require '../lib/fields'
+Composer = require "../lib/composer-view",
ContenteditableComponent = require "../lib/contenteditable-component",
describe "ContenteditableComponent", ->
@@ -14,6 +17,9 @@ describe "ContenteditableComponent", ->
@htmlNoQuote = 'Test HTML
'
@htmlWithQuote = 'Test HTMLQUOTE
'
+ @composer = ReactTestUtils.renderIntoDocument(QUOTE CHANGED!!!
'
expect(@$contentEditable.innerHTML).toBe @htmlWithQuote
setHTML.call(@, newText)
- ev = @onChange.mostRecentCall.args[0]
+ ev = @composer._onChangeBody.mostRecentCall.args[0]
expect(ev.target.value).toEqual(newText)
describe 'quoted text control toggle button', ->
@@ -81,10 +87,10 @@ describe "ContenteditableComponent", ->
describe 'when showQuotedText is false', ->
beforeEach ->
- @contentEditable = ReactTestUtils.renderIntoDocument(
- I'm a fake quote
"
expect(@$contentEditable.innerHTML).toBe @htmlNoQuote
setHTML.call(@, textToAdd + @htmlNoQuote)
- ev = @onChange.mostRecentCall.args[0]
+ ev = @composer._onChangeBody.mostRecentCall.args[0]
# Note that we expect the version WITH a quote while setting the
# version withOUT a quote.
expect(ev.target.value).toEqual(wrapBody(textToAdd + @htmlWithQuote))
diff --git a/src/dom-utils.coffee b/src/dom-utils.coffee
index 55bd8b720..c767a5b48 100644
--- a/src/dom-utils.coffee
+++ b/src/dom-utils.coffee
@@ -3,6 +3,212 @@ _s = require 'underscore.string'
DOMUtils =
+ # Given a bunch of elements, it will go through and find all elements
+ # that are adjacent to that one of the same type. For each set of
+ # adjacent elements, it will put all children of those elements into the
+ # first one and delete the remaining elements.
+ #
+ # WARNING: This mutates the DOM in place!
+ collapseAdjacentElements: (els=[]) ->
+ return if els.length is 0
+ els = Array::slice.call(els)
+
+ seenEls = []
+ toMerge = []
+
+ for el in els
+ continue if el in seenEls
+ adjacent = DOMUtils.collectAdjacent(el)
+ seenEls = seenEls.concat(adjacent)
+ continue if adjacent.length <= 1
+ toMerge.push(adjacent)
+
+ anchors = []
+ for mergeSet in toMerge
+ anchor = mergeSet[0]
+ remaining = mergeSet[1..-1]
+ for el in remaining
+ while (el.childNodes.length > 0)
+ anchor.appendChild(el.childNodes[0])
+ DOMUtils.removeElements(remaining)
+ anchors.push(anchor)
+
+ return anchors
+
+ # Returns an array of all immediately adjacent nodes of a particular
+ # nodeName relative to the root. Includes the root if it has the correct
+ # nodeName.
+ #
+ # nodName is optional. if left blank it'll be the nodeName of the root
+ collectAdjacent: (root, nodeName) ->
+ nodeName ?= root.nodeName
+ adjacent = []
+
+ node = root
+ while node.nextSibling?.nodeName is nodeName
+ adjacent.push(node.nextSibling)
+ node = node.nextSibling
+
+ if root.nodeName is nodeName
+ adjacent.unshift(root)
+
+ node = root
+ while node.previousSibling?.nodeName is nodeName
+ adjacent.unshift(node.previousSibling)
+ node = node.previousSibling
+
+ return adjacent
+
+ getNodeIndex: (context, nodeToFind) =>
+ DOMUtils.findSimilarNodes(context, nodeToFind).indexOf nodeToFind
+
+ # We need to break each node apart and cache since the `selection`
+ # object will mutate underneath us.
+ isSameSelection: (newSelection, oldSelection, context) =>
+ return true if not newSelection?
+ return false if not oldSelection
+ return false if not newSelection.anchorNode? or not newSelection.focusNode?
+
+ anchorIndex = DOMUtils.getNodeIndex(context, newSelection.anchorNode)
+ focusIndex = DOMUtils.getNodeIndex(context, newSelection.focusNode)
+
+ anchorEqual = newSelection.anchorNode.isEqualNode oldSelection.startNode
+ anchorIndexEqual = anchorIndex is oldSelection.startNodeIndex
+ focusEqual = newSelection.focusNode.isEqualNode oldSelection.endNode
+ focusIndexEqual = focusIndex is oldSelection.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 oldSelection.endNode
+ anchorIndexEqual = anchorIndex is oldSelection.endNodeIndex
+ focusEqual = newSelection.focusNode.isEqualNode oldSelection.startNode
+ focusIndexEqual = focusIndex is oldSelection.startndNodeIndex
+
+ anchorOffsetEqual = newSelection.anchorOffset == oldSelection.startOffset
+ focusOffsetEqual = newSelection.focusOffset == oldSelection.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 == oldSelection.focusOffset
+ focusOffsetEqual = newSelection.focusOffset == oldSelection.anchorOffset
+
+ if (anchorEqual and
+ anchorIndexEqual and
+ anchorOffsetEqual and
+ focusEqual and
+ focusIndexEqual and
+ focusOffsetEqual)
+ return true
+ else
+ return false
+
+
+ getRangeInScope: (scope) =>
+ selection = document.getSelection()
+ return null if not DOMUtils.selectionInScope(selection, scope)
+ try
+ range = selection.getRangeAt(0)
+ catch
+ console.warn "Selection is not returning a range"
+ return document.createRange()
+ range
+
+ selectionInScope: (selection, scope) ->
+ return false if not selection?
+ return false if not scope?
+ return (scope.contains(selection.anchorNode) and
+ scope.contains(selection.focusNode))
+
+ isEmptyBoudingRect: (rect) ->
+ rect.top is 0 and rect.bottom is 0 and rect.left is 0 and rect.right is 0
+
+ atEndOfContent: (selection, rootScope, containerScope) ->
+ containerScope ?= rootScope
+ 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:
+ #