Mailspring/src/dom-utils.coffee

709 lines
24 KiB
CoffeeScript
Raw Normal View History

_ = require 'underscore'
_s = require 'underscore.string'
DOMUtils =
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, "&nbsp;"
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
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)
# Selectors for input types
inputTypes: -> "input, textarea, *[contenteditable]"
# https://developer.mozilla.org/en-US/docs/Web/API/Element/closest
# Only Elements (not Text nodes) have the `closest` method
closest: (node, selector) ->
feat(tests): add integration tests comment Adding test harness Using key strokes in main window test Tests work now Clean up argument variables Rename list manager and get rid of old spec-helper methods Extract out time overrides from spec-helper Spectron test for contenteditable fix spec exit codes and boot mode fix(spec): cleanup N1.sh and make specs fail with exit code 1 Revert tests and get it working in window Move to spec_integration and add window load tester Specs pass. Console logs still in Remove console logs Extract N1 Launcher ready method Make integrated unit test runner feat(tests): adding integration tests Summary: The /spectron folder got moved to /spec_integration There are now unit tests (the old ones) run via the renamed `script/grunt run-unit-tests` There are now integration tests run via the command `script/grunt run-integration-tests`. There are two types of integration tests: 1. Tests that operate on the whole app via Selenium/Chromedriver. These tests have access to Spectron APIs but do NOT have access to any JS object running inside the application. See the `app-boot-spec.es6` for an example of these tests. This is tricky because we want to test the Main window, but Spectron may latch onto any other of our loading windows. Code in `integration-helper` give us an API that finds and loads the main window so we can test it 2. Tests that run in the unit test suite that need Spectron to perform integration-like behavior. These are the contentedtiable specs. The Spectron server is accessed from the app and can be used to trigger actions on the running app, from the app. These tests use the windowed-test runner so Spectron can identify whether the tests have completed, passed, or failed. Unfortunately Spectron can't access the logs , nor the exit code of the test script thereby forcing us to parse the HTML DOM. (Note this is still a WIP) I also revamped the `N1.sh` file when getting the launch arguments to work properly. It's much cleaner. We didn't need most of the data. Test Plan: new tests Reviewers: juan, bengotow Differential Revision: https://phab.nylas.com/D2289 Fix composer specs Tests can properly detect when Spectron is in the environment Report plain text output in specs fixing contenteditable specs Testing slow keymaps on contenteditable specs Move to DOm mutation Spell as `subtree` not `subTree`
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
closestAtCursor: (selector) ->
selection = document.getSelection()
return unless selection?.isCollapsed
return DOMUtils.closest(selection.anchorNode, selector)
closestElement: (node) ->
if node instanceof HTMLElement
return node
else if node?.parentNode
return DOMUtils.closestElement(node.parentNode)
else return null
isInList: ->
li = DOMUtils.closestAtCursor("li")
list = DOMUtils.closestAtCursor("ul, ol")
return li and list
# 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.indexOfNodeInSimilarNodes(context, nodeToFind)
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) ->
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
lastDescendent: (node) ->
return null unless node
if node.childNodes.length > 0
return DOMUtils.lastNode(node.childNodes[node.childNodes.length - 1])
else return null
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
refactor(toolbar): allow toolbar extensions in contenteditable Summary: This is a refactor of the toolbar in the contenteditable. Goals of this are: 1. Allow developers to add new buttons to the toolbar 2. Allow developers to add other component types to the floating toolbar (like the LinkEditor) 3. Make the toolbar declaratively defined instead of imperatively set 4. Separate out logical units of the toolbar into individual sections 5. Clean up `innerState` of the Contenteditable The Floating Toolbar used to be an imperative mess. Doing simple functionality additions required re-understanding a very complex set of logic to hide and show the toolbar and delecately manage focus states. There also was no real capacity for any developer to extend the toolbar. It also used to be completely outside of our `atomicEdit` system and was a legacy of having raw access to contenteditable controls (since it all used to be directly inside of the contenteditable) Finally it was difficult to declaratively define things because the `innerState` of the Contenteditable was inconsistently used and its lifecycle not properly thought through. This fixed several lifecycle bugs with that. Along the way several of the DOMUtils methods were also subtly not functional and fixed. The Toolbar is now broken apart into separate logical units. There are now `ContentedtiableExtension`s that declare what should be displayed in the toolbar at any given moment. They define a method called `toolbarComponentData`. This is a pure function of the state of the `Contenteditable`. If selection and content conditions look correct, then that method will return a component to render. This is how we declaratively define whether a toolbar should be visible or not instead of manually setting `hide` & `show` bits. There is also a `toolbarButtons` method that declaratively defines buttons that can go in the new `<ToolbarButtons>` component. The `ToolbarButtonManager` takes care of extracting these and binding the correct editorAPI context. Now the `<LinkEditor>` is a separate component from the `<ToolbarButtons>` instead of being smashed together. The `LinkManager` takes care of declaring when the `LinkEditor` should be displayed and has properly bound methods to update the `contenteditable` through the standard `atomicEdit` interface. If users have additional contenteditable popup plugins (like displaying extra info on a name or some content in the composer), they can now implement the `toolbarComponentData` api and declaratively define that information based on the state of the contenteditable. Test Plan: TODO Reviewers: bengotow, juan Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D2442
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])
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
isBlankTextNode: (node) ->
return if not node?.data
# \u00a0 is &nbsp;
node.data.replace(/\u00a0/g, "x").trim().length is 0
indexOfNodeInSimilarNodes: (context, nodeToFind) ->
if nodeToFind.isEqualNode(context)
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
treeWalker = document.createTreeWalker context
idx = 0
while treeWalker.nextNode()
if treeWalker.currentNode.isEqualNode nodeToFind
return treeWalker.currentNode if desiredIdx is idx
idx += 1
return null
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}
escapeHTMLCharacters: (text) ->
map =
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
text.replace /[&<>"']/g, (m) -> map[m]
# 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) ->
while node and node.nodeType is Node.ELEMENT_NODE
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
# 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
# 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
# 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)
commonAncestor: (nodes=[], parentFilter) ->
return null if nodes.length is 0
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.
getParents = (node) ->
parentNodes = [node]
depth = 0
while node = node.parentNode
if parentFilter
parentNodes.unshift(node) if parentFilter(node)
else
parentNodes.unshift(node)
depth += 1
if depth > minDepth then break
minDepth = Math.min(minDepth, depth)
return parentNodes
# _.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
_.last(_.intersection.apply(null, nodes.map(getParents)))
scrollAdjustmentToMakeNodeVisibleInContainer: (node, container) ->
return unless node
nodeRect = node.getBoundingClientRect()
containerRect = container.getBoundingClientRect()
return @scrollAdjustmentToMakeRectVisibleInRect(nodeRect, containerRect)
scrollAdjustmentToMakeRectVisibleInRect: (nodeRect, containerRect) ->
distanceBelowBottom = (nodeRect.top + nodeRect.height) - (containerRect.top + containerRect.height)
if distanceBelowBottom >= 0
return distanceBelowBottom
distanceAboveTop = containerRect.top - nodeRect.top
if distanceAboveTop >= 0
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()
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()
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.
refactor(toolbar): allow toolbar extensions in contenteditable Summary: This is a refactor of the toolbar in the contenteditable. Goals of this are: 1. Allow developers to add new buttons to the toolbar 2. Allow developers to add other component types to the floating toolbar (like the LinkEditor) 3. Make the toolbar declaratively defined instead of imperatively set 4. Separate out logical units of the toolbar into individual sections 5. Clean up `innerState` of the Contenteditable The Floating Toolbar used to be an imperative mess. Doing simple functionality additions required re-understanding a very complex set of logic to hide and show the toolbar and delecately manage focus states. There also was no real capacity for any developer to extend the toolbar. It also used to be completely outside of our `atomicEdit` system and was a legacy of having raw access to contenteditable controls (since it all used to be directly inside of the contenteditable) Finally it was difficult to declaratively define things because the `innerState` of the Contenteditable was inconsistently used and its lifecycle not properly thought through. This fixed several lifecycle bugs with that. Along the way several of the DOMUtils methods were also subtly not functional and fixed. The Toolbar is now broken apart into separate logical units. There are now `ContentedtiableExtension`s that declare what should be displayed in the toolbar at any given moment. They define a method called `toolbarComponentData`. This is a pure function of the state of the `Contenteditable`. If selection and content conditions look correct, then that method will return a component to render. This is how we declaratively define whether a toolbar should be visible or not instead of manually setting `hide` & `show` bits. There is also a `toolbarButtons` method that declaratively defines buttons that can go in the new `<ToolbarButtons>` component. The `ToolbarButtonManager` takes care of extracting these and binding the correct editorAPI context. Now the `<LinkEditor>` is a separate component from the `<ToolbarButtons>` instead of being smashed together. The `LinkManager` takes care of declaring when the `LinkEditor` should be displayed and has properly bound methods to update the `contenteditable` through the standard `atomicEdit` interface. If users have additional contenteditable popup plugins (like displaying extra info on a name or some content in the composer), they can now implement the `toolbarComponentData` api and declaratively define that information based on the state of the contenteditable. Test Plan: TODO Reviewers: bengotow, juan Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D2442
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) ->
refactor(toolbar): allow toolbar extensions in contenteditable Summary: This is a refactor of the toolbar in the contenteditable. Goals of this are: 1. Allow developers to add new buttons to the toolbar 2. Allow developers to add other component types to the floating toolbar (like the LinkEditor) 3. Make the toolbar declaratively defined instead of imperatively set 4. Separate out logical units of the toolbar into individual sections 5. Clean up `innerState` of the Contenteditable The Floating Toolbar used to be an imperative mess. Doing simple functionality additions required re-understanding a very complex set of logic to hide and show the toolbar and delecately manage focus states. There also was no real capacity for any developer to extend the toolbar. It also used to be completely outside of our `atomicEdit` system and was a legacy of having raw access to contenteditable controls (since it all used to be directly inside of the contenteditable) Finally it was difficult to declaratively define things because the `innerState` of the Contenteditable was inconsistently used and its lifecycle not properly thought through. This fixed several lifecycle bugs with that. Along the way several of the DOMUtils methods were also subtly not functional and fixed. The Toolbar is now broken apart into separate logical units. There are now `ContentedtiableExtension`s that declare what should be displayed in the toolbar at any given moment. They define a method called `toolbarComponentData`. This is a pure function of the state of the `Contenteditable`. If selection and content conditions look correct, then that method will return a component to render. This is how we declaratively define whether a toolbar should be visible or not instead of manually setting `hide` & `show` bits. There is also a `toolbarButtons` method that declaratively defines buttons that can go in the new `<ToolbarButtons>` component. The `ToolbarButtonManager` takes care of extracting these and binding the correct editorAPI context. Now the `<LinkEditor>` is a separate component from the `<ToolbarButtons>` instead of being smashed together. The `LinkManager` takes care of declaring when the `LinkEditor` should be displayed and has properly bound methods to update the `contenteditable` through the standard `atomicEdit` interface. If users have additional contenteditable popup plugins (like displaying extra info on a name or some content in the composer), they can now implement the `toolbarComponentData` api and declaratively define that information based on the state of the contenteditable. Test Plan: TODO Reviewers: bengotow, juan Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D2442
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
isDescendantOf: (node, matcher = -> false) ->
parent = node?.parentElement
while parent
return true if matcher(parent)
parent = parent.parentElement
false
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
previousTextNode: (node) ->
curNode = node
while curNode.parentNode
if curNode.previousSibling
return this.findLastTextNode(curNode.previousSibling)
curNode = curNode.parentNode
return null
module.exports = DOMUtils