2015-07-22 02:34:47 +08:00
|
|
|
_ = require 'underscore'
|
2015-09-11 01:53:27 +08:00
|
|
|
_s = require 'underscore.string'
|
2015-07-22 02:34:47 +08:00
|
|
|
|
|
|
|
DOMUtils =
|
2015-11-19 04:09:07 +08:00
|
|
|
Mutating:
|
|
|
|
replaceFirstListItem: (li, replaceWith) ->
|
|
|
|
list = DOMUtils.closest(li, "ul, ol")
|
|
|
|
|
|
|
|
if replaceWith.length is 0
|
|
|
|
replaceWith = replaceWith.replace /\s/g, " "
|
|
|
|
text = document.createElement("div")
|
|
|
|
text.innerHTML = "<br>"
|
|
|
|
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)
|
|
|
|
|
|
|
|
removeEmptyNodes: (node) ->
|
|
|
|
Array::slice.call(node.childNodes).forEach (child) ->
|
|
|
|
if child.textContent is ''
|
|
|
|
node.removeChild(child)
|
|
|
|
else
|
|
|
|
DOMUtils.Mutating.removeEmptyNodes(child)
|
|
|
|
|
|
|
|
# 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.
|
|
|
|
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.Mutating.removeElements(remaining)
|
|
|
|
anchors.push(anchor)
|
|
|
|
|
|
|
|
return anchors
|
|
|
|
|
|
|
|
removeElements: (elements=[]) ->
|
|
|
|
for el in elements
|
|
|
|
try
|
|
|
|
if el.parentNode then el.parentNode.removeChild(el)
|
|
|
|
catch
|
|
|
|
# This can happen if we've already removed ourselves from the
|
|
|
|
# node or it no longer exists
|
|
|
|
continue
|
|
|
|
return elements
|
|
|
|
|
|
|
|
applyTextInRange: (range, selection, newText) ->
|
|
|
|
range.deleteContents()
|
|
|
|
node = document.createTextNode(newText)
|
|
|
|
range.insertNode(node)
|
|
|
|
range.selectNode(node)
|
|
|
|
selection.removeAllRanges()
|
|
|
|
selection.addRange(range)
|
|
|
|
|
|
|
|
getRangeAtAndSelectWord: (selection, index) ->
|
|
|
|
range = selection.getRangeAt(index)
|
|
|
|
|
|
|
|
# On Windows, right-clicking a word does not select it at the OS-level.
|
|
|
|
if range.collapsed
|
|
|
|
DOMUtils.Mutating.selectWordContainingRange(range)
|
|
|
|
range = selection.getRangeAt(index)
|
|
|
|
return range
|
|
|
|
|
|
|
|
# This method finds the bounding points of the word that the range
|
|
|
|
# is currently within and selects that word.
|
|
|
|
selectWordContainingRange: (range) ->
|
|
|
|
selection = document.getSelection()
|
|
|
|
node = selection.focusNode
|
|
|
|
text = node.textContent
|
|
|
|
wordStart = _s.reverse(text.substring(0, selection.focusOffset)).search(/\s/)
|
|
|
|
if wordStart is -1
|
|
|
|
wordStart = 0
|
|
|
|
else
|
|
|
|
wordStart = selection.focusOffset - wordStart
|
|
|
|
wordEnd = text.substring(selection.focusOffset).search(/\s/)
|
|
|
|
if wordEnd is -1
|
|
|
|
wordEnd = text.length
|
|
|
|
else
|
|
|
|
wordEnd += selection.focusOffset
|
|
|
|
|
|
|
|
selection.removeAllRanges()
|
|
|
|
range = new Range()
|
|
|
|
range.setStart(node, wordStart)
|
|
|
|
range.setEnd(node, wordEnd)
|
|
|
|
selection.addRange(range)
|
|
|
|
|
|
|
|
moveSelectionToIndexInAnchorNode: (selection, index) ->
|
|
|
|
return unless selection.isCollapsed
|
|
|
|
node = selection.anchorNode
|
|
|
|
selection.setBaseAndExtent(node, index, node, index)
|
|
|
|
|
|
|
|
moveSelectionToEnd: (selection) ->
|
|
|
|
return unless selection.isCollapsed
|
|
|
|
node = DOMUtils.findLastTextNode(selection.anchorNode)
|
|
|
|
index = node.length
|
|
|
|
selection.setBaseAndExtent(node, index, node, index)
|
|
|
|
|
|
|
|
getSelectionRectFromDOM: (selection) ->
|
|
|
|
selection ?= document.getSelection()
|
|
|
|
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
|
2015-08-04 04:06:28 +08:00
|
|
|
|
2015-11-19 04:09:07 +08:00
|
|
|
isSelectionInTextNode: (selection) ->
|
|
|
|
selection ?= document.getSelection()
|
|
|
|
return false unless selection
|
|
|
|
return selection.isCollapsed and selection.anchorNode.nodeType is Node.TEXT_NODE and selection.anchorOffset > 0
|
|
|
|
|
|
|
|
isAtTabChar: (selection) ->
|
|
|
|
selection ?= document.getSelection()
|
|
|
|
if DOMUtils.isSelectionInTextNode(selection)
|
|
|
|
return selection.anchorNode.textContent[selection.anchorOffset - 1] is "\t"
|
|
|
|
else return false
|
|
|
|
|
|
|
|
isAtBeginningOfDocument: (dom, selection) ->
|
|
|
|
selection ?= document.getSelection()
|
|
|
|
return false if not selection.isCollapsed
|
|
|
|
return false if selection.anchorOffset > 0
|
|
|
|
return true if dom.childNodes.length is 0
|
|
|
|
return true if selection.anchorNode is dom
|
|
|
|
firstChild = dom.childNodes[0]
|
|
|
|
return selection.anchorNode is firstChild
|
|
|
|
|
|
|
|
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 = DOMUtils.closest(anchor, "li")
|
|
|
|
return unless li
|
|
|
|
return DOMUtils.isFirstChild(li, anchor)
|
|
|
|
|
2015-12-11 05:51:44 +08:00
|
|
|
# Selectors for input types
|
|
|
|
inputTypes: -> "input, textarea, *[contenteditable]"
|
|
|
|
|
2015-11-19 04:09:07 +08:00
|
|
|
# https://developer.mozilla.org/en-US/docs/Web/API/Element/closest
|
|
|
|
# Only Elements (not Text nodes) have the `closest` method
|
|
|
|
closest: (node, selector) ->
|
2015-11-20 07:29:49 +08:00
|
|
|
if node instanceof HTMLElement
|
|
|
|
return node.closest(selector)
|
|
|
|
else if node?.parentNode
|
|
|
|
return DOMUtils.closest(node.parentNode, selector)
|
|
|
|
else return null
|
2015-11-19 04:09:07 +08:00
|
|
|
|
|
|
|
closestAtCursor: (selector) ->
|
|
|
|
selection = document.getSelection()
|
|
|
|
return unless selection?.isCollapsed
|
|
|
|
return DOMUtils.closest(selection.anchorNode, selector)
|
|
|
|
|
2016-02-04 07:05:14 +08:00
|
|
|
closestElement: (node) ->
|
|
|
|
if node instanceof HTMLElement
|
|
|
|
return node
|
|
|
|
else if node?.parentNode
|
|
|
|
return DOMUtils.closestElement(node.parentNode)
|
|
|
|
else return null
|
|
|
|
|
2015-11-19 04:09:07 +08:00
|
|
|
isInList: ->
|
|
|
|
li = DOMUtils.closestAtCursor("li")
|
|
|
|
list = DOMUtils.closestAtCursor("ul, ol")
|
|
|
|
return li and list
|
2015-09-23 07:02:44 +08:00
|
|
|
|
|
|
|
# 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) =>
|
2016-01-12 09:31:03 +08:00
|
|
|
DOMUtils.indexOfNodeInSimilarNodes(context, nodeToFind)
|
2015-09-23 07:02:44 +08:00
|
|
|
|
|
|
|
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))
|
|
|
|
|
feat(editor-region): Add support to register components as editors
Summary:
- The main purpose of this is to be able to properly register the editor for the markdown plugin (and any other plugins to come)
- Refactors ComposerView and Contenteditable ->
- Replaces Contenteditable with an InjectedComponent for a new region role:
"Composer:Editor"
- Creates a new component called ComposerEditor, which is the one that is
being registered by default as "Composer:Editor"
- I used this class to try to standardize the props that should be
passed to any would be editor Component:
- Renamed a bunch of the props which (I think) had a bit of
confusing names
- Added a bunch of docs for these in the source file, although
I feel like those docs should live elsewhere, like in the
ComponentRegion docs.
- In the process, I ended up pulling some stuff out of ComposerView and
some stuff out of the Contenteditable, namely:
- The scrolling logic to ensure that the composer is visible while
typing was moved outside of the Contenteditable -- this feels more
like the ComposerEditor's responsibility, especially since the
Contenteditable is meant to be used in other contexts as well.
- The ComposerExtensions state; it feels less awkward for me if this
is inside the ComposerEditor because 1) ComposerView does less
things, 2) these are actually just being passed to the
Contenteditable, 3) I feel like other plugins shouldn't need to
mess around with ComposerExtensions, so we shouldn't pass them to the
editor. If you register an editor different from our default one,
any other ComposerExtension callbacks will be disabled, which
I feel is expected behavior.
- I think there is still some more refactoring to be done, and I left some TODOS
here and there, but I think this diff is already big enough and its a minimal
set of changes to get the markdown editor working in a not so duck
tapish way.
- New props for InjectedComponent:
- `requiredMethods`: allows you to define a collection of methods that
should be implemented by any Component that registers for your
desired region.
- It will throw an error if these are not implemented
- It will automatically pass calls made on the InjectedComponent to these methods
down to the instance of the actual registered component
- Would love some comments on this approach and impl
- `fallback`: allows you to define a default component to use if none were
registered through the ComponentRegistry
- Misc:
- Added a new test case for the QuotedHTMLTransformer
- Tests:
- They were minimally updated so that they don't break, but a big TODO
is to properly refactor them. I plan to do that in an upcoming
diff.
Test Plan: - Unit tests
Reviewers: bengotow, evan
Reviewed By: evan
Differential Revision: https://phab.nylas.com/D2372
2015-12-19 03:03:58 +08:00
|
|
|
isEmptyBoundingRect: (rect) ->
|
2015-09-23 07:02:44 +08:00
|
|
|
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:
|
|
|
|
# <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 = 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
|
|
|
|
|
2016-03-04 09:30:17 +08:00
|
|
|
lastDescendent: (node) ->
|
|
|
|
return null unless node
|
|
|
|
if node.childNodes.length > 0
|
|
|
|
return DOMUtils.lastNode(node.childNodes[node.childNodes.length - 1])
|
|
|
|
else return null
|
|
|
|
|
2015-11-19 04:09:07 +08:00
|
|
|
findLastTextNode: (node) ->
|
|
|
|
return null unless node
|
|
|
|
return node if node.nodeType is Node.TEXT_NODE
|
|
|
|
for childNode in node.childNodes by -1
|
|
|
|
if childNode.nodeType is Node.TEXT_NODE
|
|
|
|
return childNode
|
|
|
|
else if childNode.nodeType is Node.ELEMENT_NODE
|
|
|
|
return DOMUtils.findLastTextNode(childNode)
|
|
|
|
else continue
|
|
|
|
return null
|
|
|
|
|
2016-01-21 06:35:20 +08:00
|
|
|
# Only looks down node trees with one child for a text node.
|
|
|
|
# Returns null if there's no single text node
|
|
|
|
findOnlyChildTextNode: (node) ->
|
|
|
|
return null unless node
|
|
|
|
return node if node.nodeType is Node.TEXT_NODE
|
|
|
|
return null if node.childNodes.length > 1
|
|
|
|
return DOMUtils.findOnlyChildTextNode(node.childNodes[0])
|
|
|
|
|
2015-11-19 04:09:07 +08:00
|
|
|
findFirstTextNode: (node) ->
|
|
|
|
return null unless node
|
|
|
|
return node if node.nodeType is Node.TEXT_NODE
|
|
|
|
for childNode in node.childNodes
|
|
|
|
if childNode.nodeType is Node.TEXT_NODE
|
|
|
|
return childNode
|
|
|
|
else if childNode.nodeType is Node.ELEMENT_NODE
|
|
|
|
return DOMUtils.findFirstTextNode(childNode)
|
|
|
|
else continue
|
|
|
|
return null
|
|
|
|
|
2015-09-23 07:02:44 +08:00
|
|
|
isBlankTextNode: (node) ->
|
|
|
|
return if not node?.data
|
|
|
|
# \u00a0 is
|
|
|
|
node.data.replace(/\u00a0/g, "x").trim().length is 0
|
|
|
|
|
2016-01-12 09:31:03 +08:00
|
|
|
indexOfNodeInSimilarNodes: (context, nodeToFind) ->
|
2015-09-23 07:02:44 +08:00
|
|
|
if nodeToFind.isEqualNode(context)
|
2016-01-12 09:31:03 +08:00
|
|
|
return 0
|
|
|
|
|
|
|
|
treeWalker = document.createTreeWalker context
|
|
|
|
idx = 0
|
|
|
|
while treeWalker.nextNode()
|
|
|
|
if treeWalker.currentNode.isEqualNode nodeToFind
|
|
|
|
if treeWalker.currentNode.isSameNode nodeToFind
|
|
|
|
return idx
|
|
|
|
idx += 1
|
|
|
|
|
|
|
|
return -1
|
|
|
|
|
|
|
|
# This is an optimization of findSimilarNodes which avoids tons of extra work
|
|
|
|
# scanning a large DOM if all we're going to do is get item at index [0]. It
|
|
|
|
# returns once it has found the similar node at the index desired.
|
|
|
|
findSimilarNodeAtIndex: (context, nodeToFind, desiredIdx) ->
|
|
|
|
if desiredIdx is 0 and nodeToFind.isEqualNode(context)
|
|
|
|
return context
|
|
|
|
|
2015-09-23 07:02:44 +08:00
|
|
|
treeWalker = document.createTreeWalker context
|
2016-01-12 09:31:03 +08:00
|
|
|
idx = 0
|
2015-09-23 07:02:44 +08:00
|
|
|
while treeWalker.nextNode()
|
|
|
|
if treeWalker.currentNode.isEqualNode nodeToFind
|
2016-01-12 09:31:03 +08:00
|
|
|
return treeWalker.currentNode if desiredIdx is idx
|
|
|
|
idx += 1
|
2015-09-23 07:02:44 +08:00
|
|
|
|
2016-01-12 09:31:03 +08:00
|
|
|
return null
|
2015-09-23 07:02:44 +08:00
|
|
|
|
2016-01-12 03:46:20 +08:00
|
|
|
findCharacter: (context, character) ->
|
|
|
|
node = null
|
|
|
|
index = null
|
|
|
|
treeWalker = document.createTreeWalker(context, NodeFilter.SHOW_TEXT)
|
|
|
|
while currentNode = treeWalker.nextNode()
|
|
|
|
i = currentNode.data.indexOf(character)
|
|
|
|
if i >= 0
|
|
|
|
node = currentNode
|
|
|
|
index = i
|
|
|
|
break
|
|
|
|
return {node, index}
|
|
|
|
|
2015-08-04 04:06:28 +08:00
|
|
|
escapeHTMLCharacters: (text) ->
|
|
|
|
map =
|
|
|
|
'&': '&',
|
|
|
|
'<': '<',
|
|
|
|
'>': '>',
|
|
|
|
'"': '"',
|
|
|
|
"'": '''
|
|
|
|
text.replace /[&<>"']/g, (m) -> map[m]
|
|
|
|
|
2015-07-22 02:34:47 +08:00
|
|
|
# Checks to see if a particular node is visible and any of its parents
|
|
|
|
# are visible.
|
|
|
|
#
|
|
|
|
# WARNING. This is a fairly expensive operation and should be used
|
|
|
|
# sparingly.
|
|
|
|
nodeIsVisible: (node) ->
|
2016-03-03 06:46:27 +08:00
|
|
|
while node and node.nodeType is Node.ELEMENT_NODE
|
2015-07-22 02:34:47 +08:00
|
|
|
style = window.getComputedStyle(node)
|
|
|
|
node = node.parentNode
|
|
|
|
continue unless style?
|
|
|
|
# NOTE: opacity must be soft ==
|
|
|
|
if style.opacity is 0 or style.opacity is "0" or style.visibility is "hidden" or style.display is "none"
|
|
|
|
return false
|
|
|
|
return true
|
|
|
|
|
2016-03-03 06:46:27 +08:00
|
|
|
# This checks for the `offsetParent` to be null. This will work for
|
|
|
|
# hidden elements, but not if they are in a `position:fixed` container.
|
|
|
|
#
|
|
|
|
# It is less thorough then Utils.nodeIsVisible, but is ~16x faster!!
|
|
|
|
# http://jsperf.com/check-hidden
|
|
|
|
# http://stackoverflow.com/a/21696585/793472
|
|
|
|
nodeIsLikelyVisible: (node) -> node.offsetParent isnt null
|
|
|
|
|
2015-07-22 02:34:47 +08:00
|
|
|
# Finds all of the non blank node in a {Document} object or HTML string.
|
|
|
|
#
|
|
|
|
# - `elementOrHTML` a dom element or an HTML string. If passed a
|
|
|
|
# string, it will use `DOMParser` to convert it into a DOM object.
|
|
|
|
#
|
|
|
|
# "Non blank" is defined as any node whose `textContent` returns a
|
|
|
|
# whitespace string.
|
|
|
|
#
|
|
|
|
# It will also reject nodes we see are invisible due to basic CSS
|
|
|
|
# properties.
|
|
|
|
#
|
|
|
|
# Returns an array of DOM Nodes
|
|
|
|
nodesWithContent: (elementOrHTML) ->
|
|
|
|
nodes = []
|
|
|
|
if _.isString(elementOrHTML)
|
|
|
|
domParser = new DOMParser()
|
|
|
|
doc = domParser.parseFromString(elementOrHTML, "text/html")
|
|
|
|
allNodes = doc.body.childNodes
|
|
|
|
else if elementOrHTML?.childNodes
|
|
|
|
allNodes = elementOrHTML.childNodes
|
|
|
|
else return nodes
|
|
|
|
|
|
|
|
# We need to check `childNodes` instead of `children` to look for
|
|
|
|
# plain Text nodes.
|
|
|
|
for node in allNodes by -1
|
|
|
|
if node.nodeName is "IMG"
|
|
|
|
nodes.unshift node
|
|
|
|
|
|
|
|
# It's important to use `textContent` and NOT `innerText`.
|
|
|
|
# `innerText` causes a full reflow on every call because it
|
|
|
|
# calcaultes CSS styles to determine if the text is truly visible or
|
|
|
|
# not. This utility method must NOT cause a reflow. We instead will
|
|
|
|
# check for basic cases ourselves.
|
|
|
|
if (node.textContent ? "").trim().length is 0
|
|
|
|
continue
|
|
|
|
|
|
|
|
if node.style?.opacity is 0 or node.style?.opacity is "0" or node.style?.visibility is "hidden" or node.style?.display is "none"
|
|
|
|
continue
|
|
|
|
|
|
|
|
nodes.unshift node
|
|
|
|
|
|
|
|
# No nodes with content found!
|
|
|
|
return nodes
|
|
|
|
|
|
|
|
parents: (node) ->
|
|
|
|
nodes = []
|
|
|
|
nodes.unshift(node) while node = node.parentNode
|
|
|
|
return nodes
|
|
|
|
|
2015-09-23 07:02:44 +08:00
|
|
|
# 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)
|
|
|
|
|
2016-03-03 06:46:27 +08:00
|
|
|
commonAncestor: (nodes=[], parentFilter) ->
|
|
|
|
return null if nodes.length is 0
|
|
|
|
|
2015-07-22 02:34:47 +08:00
|
|
|
nodes = Array::slice.call(nodes)
|
|
|
|
|
|
|
|
minDepth = Number.MAX_VALUE
|
|
|
|
# Sometimes we can potentially have tons of REALLY deeply nested
|
|
|
|
# nodes. Since we're looking for a common ancestor we can really speed
|
|
|
|
# this up by keeping track of the min depth reached. We know that we
|
|
|
|
# won't need to check past that.
|
2016-03-03 06:46:27 +08:00
|
|
|
getParents = (node) ->
|
|
|
|
parentNodes = [node]
|
2015-07-22 02:34:47 +08:00
|
|
|
depth = 0
|
|
|
|
while node = node.parentNode
|
2016-03-03 06:46:27 +08:00
|
|
|
if parentFilter
|
|
|
|
parentNodes.unshift(node) if parentFilter(node)
|
|
|
|
else
|
|
|
|
parentNodes.unshift(node)
|
2015-07-22 02:34:47 +08:00
|
|
|
depth += 1
|
|
|
|
if depth > minDepth then break
|
|
|
|
minDepth = Math.min(minDepth, depth)
|
2016-03-03 06:46:27 +08:00
|
|
|
return parentNodes
|
2015-07-22 02:34:47 +08:00
|
|
|
|
|
|
|
# _.intersection will preserve the ordering of the parent node arrays.
|
|
|
|
# parents are ordered top to bottom, so the last node is the most
|
|
|
|
# specific common ancenstor
|
2016-03-03 06:46:27 +08:00
|
|
|
_.last(_.intersection.apply(null, nodes.map(getParents)))
|
2015-07-22 02:34:47 +08:00
|
|
|
|
|
|
|
scrollAdjustmentToMakeNodeVisibleInContainer: (node, container) ->
|
|
|
|
return unless node
|
|
|
|
nodeRect = node.getBoundingClientRect()
|
|
|
|
containerRect = container.getBoundingClientRect()
|
2015-07-31 09:29:38 +08:00
|
|
|
return @scrollAdjustmentToMakeRectVisibleInRect(nodeRect, containerRect)
|
2015-07-22 02:34:47 +08:00
|
|
|
|
2015-07-31 09:29:38 +08:00
|
|
|
scrollAdjustmentToMakeRectVisibleInRect: (nodeRect, containerRect) ->
|
2015-07-22 02:34:47 +08:00
|
|
|
distanceBelowBottom = (nodeRect.top + nodeRect.height) - (containerRect.top + containerRect.height)
|
2015-07-31 09:29:38 +08:00
|
|
|
if distanceBelowBottom >= 0
|
2015-07-22 02:34:47 +08:00
|
|
|
return distanceBelowBottom
|
|
|
|
|
|
|
|
distanceAboveTop = containerRect.top - nodeRect.top
|
2015-07-31 09:29:38 +08:00
|
|
|
if distanceAboveTop >= 0
|
2015-07-22 02:34:47 +08:00
|
|
|
return -distanceAboveTop
|
|
|
|
|
|
|
|
return 0
|
|
|
|
|
refactor(templates): major additions and refactoring for the Templates plugin.
Summary:
Adds several new features to the templates plugin, fixes some existing bugs, and
refactors existing code.
New Plugin Features/Fixes:
- Changes the templates editor in preferences to allow variables to be entered with
`{{brackets}}`. Handles many contenteditable complexities to implement.
- Better interaction for renaming and deleting of templates in the editor.
- Changes tabbing behavior when using templates. Tabbing between variables now wraps
around, and typing tab from outside a variable region highlights the closest region.
- Prevents "Enter" key in the composer when inside a variable region, and strips all
formatting/tags from within the region - this prevents major contenteditable issues
that can result in inline CSS in the style of our variable regions, which will not be
removed when sending.
- Shows a warning when choosing a template if it will replace existing text in a draft.
- Prevents invalid characters in template names (due to filenames, esp. on Windows),
and shows an error message. Strips these characters from draft titles when making a
template.
- Fixes a bug where TemplateStore's initialization code was being called multiple times.
New N1 code:
- Several new methods in `DOMUtils` useful for working with contenteditable.
- Implement some missing methods in `Editor`
Refactor:
- Major refactor/rewrite of template composer extension to use new DOMUtils methods
and simplify the logic (while adding new functionality).
Remaining issues:
- `preferences-tempaltes.cjsx` and `template-editor.coffee` should be rewritten in ES6
for consistency
- Need tests for new DOMUtils functions and for new Templates plugin code.
Test Plan: manual, need to update specs
Reviewers: evan, bengotow
Reviewed By: evan, bengotow
Subscribers: juan
Differential Revision: https://phab.nylas.com/D2382
2015-12-30 07:11:04 +08:00
|
|
|
# Produces a list of indexed text contained within a given node. Returns a
|
|
|
|
# list of objects of the form:
|
|
|
|
# {start, end, node, text}
|
|
|
|
#
|
|
|
|
# The text being indexed is intended to approximate the rendered content visible
|
|
|
|
# to the user. This includes the nodeValue of any text nodes, and "\n" for any
|
|
|
|
# DIV or BR elements.
|
|
|
|
getIndexedTextContent: (node) ->
|
|
|
|
items = []
|
|
|
|
treeWalker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT)
|
|
|
|
position = 0
|
|
|
|
while treeWalker.nextNode()
|
|
|
|
node = treeWalker.currentNode
|
|
|
|
if node.tagName is "BR" or node.nodeType is Node.TEXT_NODE or node.tagName is "DIV"
|
|
|
|
text = if node.nodeType is Node.TEXT_NODE then node.nodeValue else "\n"
|
|
|
|
item =
|
|
|
|
start: position
|
|
|
|
end: position + text.length
|
|
|
|
node: node
|
|
|
|
text: text
|
|
|
|
items.push(item)
|
|
|
|
position += text.length
|
|
|
|
return items
|
|
|
|
|
|
|
|
# Returns true if the inner range is fully contained within the outer range
|
|
|
|
rangeInRange: (inner, outer) ->
|
|
|
|
return outer.isPointInRange(inner.startContainer, inner.startOffset) and outer.isPointInRange(inner.endContainer, inner.endOffset)
|
|
|
|
|
|
|
|
# Returns true if the given ranges overlap
|
|
|
|
rangeOverlapsRange: (range1, range2) ->
|
|
|
|
return range2.isPointInRange(range1.startContainer, range1.startOffset) or range1.isPointInRange(range2.startContainer, range2.startOffset)
|
|
|
|
|
|
|
|
# Returns true if the first range starts or ends within the second range.
|
|
|
|
# Unlike rangeOverlapsRange, returns false if range2 is fully within range1.
|
|
|
|
rangeStartsOrEndsInRange: (range1, range2) ->
|
|
|
|
return range2.isPointInRange(range1.startContainer, range1.startOffset) or range2.isPointInRange(range1.endContainer, range1.endOffset)
|
|
|
|
|
|
|
|
# Accepts a Range or a Node, and returns true if the current selection starts
|
|
|
|
# or ends within it. Useful for knowing if a DOM modification will break the
|
|
|
|
# current selection.
|
|
|
|
selectionStartsOrEndsIn: (rangeOrNode) ->
|
|
|
|
selection = document.getSelection()
|
2016-01-13 04:44:22 +08:00
|
|
|
return false unless (selection and selection.rangeCount>0)
|
refactor(templates): major additions and refactoring for the Templates plugin.
Summary:
Adds several new features to the templates plugin, fixes some existing bugs, and
refactors existing code.
New Plugin Features/Fixes:
- Changes the templates editor in preferences to allow variables to be entered with
`{{brackets}}`. Handles many contenteditable complexities to implement.
- Better interaction for renaming and deleting of templates in the editor.
- Changes tabbing behavior when using templates. Tabbing between variables now wraps
around, and typing tab from outside a variable region highlights the closest region.
- Prevents "Enter" key in the composer when inside a variable region, and strips all
formatting/tags from within the region - this prevents major contenteditable issues
that can result in inline CSS in the style of our variable regions, which will not be
removed when sending.
- Shows a warning when choosing a template if it will replace existing text in a draft.
- Prevents invalid characters in template names (due to filenames, esp. on Windows),
and shows an error message. Strips these characters from draft titles when making a
template.
- Fixes a bug where TemplateStore's initialization code was being called multiple times.
New N1 code:
- Several new methods in `DOMUtils` useful for working with contenteditable.
- Implement some missing methods in `Editor`
Refactor:
- Major refactor/rewrite of template composer extension to use new DOMUtils methods
and simplify the logic (while adding new functionality).
Remaining issues:
- `preferences-tempaltes.cjsx` and `template-editor.coffee` should be rewritten in ES6
for consistency
- Need tests for new DOMUtils functions and for new Templates plugin code.
Test Plan: manual, need to update specs
Reviewers: evan, bengotow
Reviewed By: evan, bengotow
Subscribers: juan
Differential Revision: https://phab.nylas.com/D2382
2015-12-30 07:11:04 +08:00
|
|
|
if rangeOrNode instanceof Range
|
|
|
|
return @rangeStartsOrEndsInRange(selection.getRangeAt(0), rangeOrNode)
|
|
|
|
else if rangeOrNode instanceof Node
|
|
|
|
range = new Range()
|
|
|
|
range.selectNode(rangeOrNode)
|
|
|
|
return @rangeStartsOrEndsInRange(selection.getRangeAt(0), range)
|
|
|
|
else
|
|
|
|
return false
|
|
|
|
|
|
|
|
# Accepts a Range or a Node, and returns true if the current selection is fully
|
|
|
|
# contained within it.
|
|
|
|
selectionIsWithin: (rangeOrNode) ->
|
|
|
|
selection = document.getSelection()
|
2016-01-13 04:44:22 +08:00
|
|
|
return false unless (selection and selection.rangeCount>0)
|
refactor(templates): major additions and refactoring for the Templates plugin.
Summary:
Adds several new features to the templates plugin, fixes some existing bugs, and
refactors existing code.
New Plugin Features/Fixes:
- Changes the templates editor in preferences to allow variables to be entered with
`{{brackets}}`. Handles many contenteditable complexities to implement.
- Better interaction for renaming and deleting of templates in the editor.
- Changes tabbing behavior when using templates. Tabbing between variables now wraps
around, and typing tab from outside a variable region highlights the closest region.
- Prevents "Enter" key in the composer when inside a variable region, and strips all
formatting/tags from within the region - this prevents major contenteditable issues
that can result in inline CSS in the style of our variable regions, which will not be
removed when sending.
- Shows a warning when choosing a template if it will replace existing text in a draft.
- Prevents invalid characters in template names (due to filenames, esp. on Windows),
and shows an error message. Strips these characters from draft titles when making a
template.
- Fixes a bug where TemplateStore's initialization code was being called multiple times.
New N1 code:
- Several new methods in `DOMUtils` useful for working with contenteditable.
- Implement some missing methods in `Editor`
Refactor:
- Major refactor/rewrite of template composer extension to use new DOMUtils methods
and simplify the logic (while adding new functionality).
Remaining issues:
- `preferences-tempaltes.cjsx` and `template-editor.coffee` should be rewritten in ES6
for consistency
- Need tests for new DOMUtils functions and for new Templates plugin code.
Test Plan: manual, need to update specs
Reviewers: evan, bengotow
Reviewed By: evan, bengotow
Subscribers: juan
Differential Revision: https://phab.nylas.com/D2382
2015-12-30 07:11:04 +08:00
|
|
|
if rangeOrNode instanceof Range
|
|
|
|
return @rangeInRange(selection.getRangeAt(0), rangeOrNode)
|
|
|
|
else if rangeOrNode instanceof Node
|
|
|
|
range = new Range()
|
|
|
|
range.selectNode(rangeOrNode)
|
|
|
|
return @rangeInRange(selection.getRangeAt(0), range)
|
|
|
|
else
|
|
|
|
return false
|
|
|
|
|
|
|
|
# Finds all matches to a regex within a node's text content (including line
|
|
|
|
# breaks from DIVs and BRs, as \n), and returns a list of corresponding Range
|
|
|
|
# objects.
|
|
|
|
regExpSelectorAll: (node, regex) ->
|
|
|
|
|
|
|
|
# Generate a text representation of the node's content
|
|
|
|
nodeTextList = @getIndexedTextContent(node)
|
|
|
|
text = nodeTextList.map( ({text}) -> text ).join("")
|
|
|
|
|
|
|
|
# Build a list of range objects by looping over regex matches in the
|
|
|
|
# text content string, and then finding the node those match indexes
|
|
|
|
# point to.
|
|
|
|
ranges = []
|
|
|
|
listPosition = 0
|
|
|
|
while (result = regex.exec(text)) isnt null
|
|
|
|
from = result.index
|
|
|
|
to = regex.lastIndex
|
|
|
|
item = nodeTextList[listPosition]
|
|
|
|
range = document.createRange()
|
|
|
|
|
|
|
|
while from >= item.end
|
|
|
|
item = nodeTextList[++listPosition]
|
|
|
|
start = if item.node.nodeType is Node.TEXT_NODE then from - item.start else 0
|
|
|
|
range.setStart(item.node,start)
|
|
|
|
|
|
|
|
while to > item.end
|
|
|
|
item = nodeTextList[++listPosition]
|
|
|
|
end = if item.node.nodeType is Node.TEXT_NODE then to - item.start else 0
|
|
|
|
range.setEnd(item.node, end)
|
|
|
|
|
|
|
|
ranges.push(range)
|
|
|
|
|
|
|
|
return ranges
|
|
|
|
|
|
|
|
# Returns true if the given range is the sole content of a node with the given
|
|
|
|
# nodeName. If the range's parent has a different nodeName or contains any other
|
|
|
|
# content, returns false.
|
|
|
|
isWrapped: (range, nodeName) ->
|
|
|
|
return false unless range and nodeName
|
|
|
|
startNode = range.startContainer
|
|
|
|
endNode = range.endContainer
|
|
|
|
return false unless startNode.parentNode is endNode.parentNode # must have same parent
|
|
|
|
return false if startNode.previousSibling or endNode.nextSibling # selection must span all sibling nodes
|
|
|
|
return false if range.startOffset > 0 or range.endOffset < endNode.textContent.length # selection must span all text
|
|
|
|
return startNode.parentNode.nodeName is nodeName
|
|
|
|
|
|
|
|
# Modifies the DOM to wrap the given range with a new node, of name nodeName.
|
|
|
|
#
|
|
|
|
# If the range starts or ends in the middle of an node, that node will be split.
|
|
|
|
# This will likely break selections that contain any of the affected nodes.
|
|
|
|
wrap: (range, nodeName) ->
|
|
|
|
newNode = document.createElement(nodeName)
|
|
|
|
try
|
|
|
|
range.surroundContents(newNode)
|
|
|
|
catch
|
|
|
|
newNode.appendChild(range.extractContents())
|
|
|
|
range.insertNode(newNode)
|
|
|
|
return newNode
|
|
|
|
|
|
|
|
# Modifies the DOM to "unwrap" a given node, replacing that node with its contents.
|
|
|
|
# This may break selections containing the affected nodes.
|
2016-01-21 06:35:20 +08:00
|
|
|
# We don't use `document.createFragment` because the returned `fragment`
|
|
|
|
# would be empty and useless after its children get replaced.
|
refactor(templates): major additions and refactoring for the Templates plugin.
Summary:
Adds several new features to the templates plugin, fixes some existing bugs, and
refactors existing code.
New Plugin Features/Fixes:
- Changes the templates editor in preferences to allow variables to be entered with
`{{brackets}}`. Handles many contenteditable complexities to implement.
- Better interaction for renaming and deleting of templates in the editor.
- Changes tabbing behavior when using templates. Tabbing between variables now wraps
around, and typing tab from outside a variable region highlights the closest region.
- Prevents "Enter" key in the composer when inside a variable region, and strips all
formatting/tags from within the region - this prevents major contenteditable issues
that can result in inline CSS in the style of our variable regions, which will not be
removed when sending.
- Shows a warning when choosing a template if it will replace existing text in a draft.
- Prevents invalid characters in template names (due to filenames, esp. on Windows),
and shows an error message. Strips these characters from draft titles when making a
template.
- Fixes a bug where TemplateStore's initialization code was being called multiple times.
New N1 code:
- Several new methods in `DOMUtils` useful for working with contenteditable.
- Implement some missing methods in `Editor`
Refactor:
- Major refactor/rewrite of template composer extension to use new DOMUtils methods
and simplify the logic (while adding new functionality).
Remaining issues:
- `preferences-tempaltes.cjsx` and `template-editor.coffee` should be rewritten in ES6
for consistency
- Need tests for new DOMUtils functions and for new Templates plugin code.
Test Plan: manual, need to update specs
Reviewers: evan, bengotow
Reviewed By: evan, bengotow
Subscribers: juan
Differential Revision: https://phab.nylas.com/D2382
2015-12-30 07:11:04 +08:00
|
|
|
unwrapNode: (node) ->
|
2016-01-21 06:35:20 +08:00
|
|
|
return node if node.childNodes.length is 0
|
|
|
|
replacedNodes = []
|
|
|
|
parent = node.parentNode
|
|
|
|
return node if not parent?
|
|
|
|
|
|
|
|
lastChild = _.last(node.childNodes)
|
|
|
|
replacedNodes.unshift(lastChild)
|
|
|
|
parent.replaceChild(lastChild, node)
|
|
|
|
|
|
|
|
while child = _.last(node.childNodes)
|
|
|
|
replacedNodes.unshift(child)
|
|
|
|
parent.insertBefore(child, lastChild)
|
|
|
|
lastChild = child
|
|
|
|
|
|
|
|
return replacedNodes
|
refactor(templates): major additions and refactoring for the Templates plugin.
Summary:
Adds several new features to the templates plugin, fixes some existing bugs, and
refactors existing code.
New Plugin Features/Fixes:
- Changes the templates editor in preferences to allow variables to be entered with
`{{brackets}}`. Handles many contenteditable complexities to implement.
- Better interaction for renaming and deleting of templates in the editor.
- Changes tabbing behavior when using templates. Tabbing between variables now wraps
around, and typing tab from outside a variable region highlights the closest region.
- Prevents "Enter" key in the composer when inside a variable region, and strips all
formatting/tags from within the region - this prevents major contenteditable issues
that can result in inline CSS in the style of our variable regions, which will not be
removed when sending.
- Shows a warning when choosing a template if it will replace existing text in a draft.
- Prevents invalid characters in template names (due to filenames, esp. on Windows),
and shows an error message. Strips these characters from draft titles when making a
template.
- Fixes a bug where TemplateStore's initialization code was being called multiple times.
New N1 code:
- Several new methods in `DOMUtils` useful for working with contenteditable.
- Implement some missing methods in `Editor`
Refactor:
- Major refactor/rewrite of template composer extension to use new DOMUtils methods
and simplify the logic (while adding new functionality).
Remaining issues:
- `preferences-tempaltes.cjsx` and `template-editor.coffee` should be rewritten in ES6
for consistency
- Need tests for new DOMUtils functions and for new Templates plugin code.
Test Plan: manual, need to update specs
Reviewers: evan, bengotow
Reviewed By: evan, bengotow
Subscribers: juan
Differential Revision: https://phab.nylas.com/D2382
2015-12-30 07:11:04 +08:00
|
|
|
|
2015-12-31 03:53:46 +08:00
|
|
|
isDescendantOf: (node, matcher = -> false) ->
|
|
|
|
parent = node?.parentElement
|
|
|
|
while parent
|
|
|
|
return true if matcher(parent)
|
|
|
|
parent = parent.parentElement
|
|
|
|
false
|
|
|
|
|
2016-01-12 03:46:20 +08:00
|
|
|
looksLikeBlockElement: (node) ->
|
|
|
|
return node.nodeName in ["BR", "P", "BLOCKQUOTE", "DIV", "TABLE"]
|
|
|
|
|
|
|
|
# When detecting if we're at the start of a "visible" line, we need to look
|
|
|
|
# for text nodes that have visible content in them.
|
|
|
|
looksLikeNonEmptyNode: (node) ->
|
|
|
|
textNode = DOMUtils.findFirstTextNode(node)
|
|
|
|
if textNode
|
|
|
|
if /^[\n ]*$/.test(textNode.data)
|
|
|
|
return false
|
|
|
|
else return true
|
|
|
|
else
|
|
|
|
return false
|
|
|
|
|
2016-02-04 07:05:14 +08:00
|
|
|
previousTextNode: (node) ->
|
|
|
|
curNode = node
|
|
|
|
while curNode.parentNode
|
|
|
|
if curNode.previousSibling
|
|
|
|
return this.findLastTextNode(curNode.previousSibling)
|
|
|
|
curNode = curNode.parentNode
|
|
|
|
return null
|
|
|
|
|
2015-07-22 02:34:47 +08:00
|
|
|
module.exports = DOMUtils
|