diff --git a/internal_packages/composer-spellcheck/lib/draft-extension.coffee b/internal_packages/composer-spellcheck/lib/draft-extension.coffee
index d74378973..ba7a10899 100644
--- a/internal_packages/composer-spellcheck/lib/draft-extension.coffee
+++ b/internal_packages/composer-spellcheck/lib/draft-extension.coffee
@@ -1,19 +1,47 @@
-{DraftStoreExtension, AccountStore} = require 'nylas-exports'
+{DraftStoreExtension, AccountStore, DOMUtils} = require 'nylas-exports'
_ = require 'underscore'
+spellchecker = require('spellchecker')
+remote = require('remote')
+MenuItem = remote.require('menu-item')
SpellcheckCache = {}
class SpellcheckDraftStoreExtension extends DraftStoreExtension
@isMisspelled: (word) ->
- @spellchecker ?= require('spellchecker')
- SpellcheckCache[word] ?= @spellchecker.isMisspelled(word)
+ SpellcheckCache[word] ?= spellchecker.isMisspelled(word)
SpellcheckCache[word]
- @onComponentDidUpdate: (editableNode) ->
+ @onInput: (editableNode) ->
@walkTree(editableNode)
- @onLearnSpelling: (editableNode, word) ->
+ @onShowContextMenu: (event, editableNode, selection, innerStateProxy, menu) =>
+ range = DOMUtils.Mutating.getRangeAtAndSelectWord(selection, 0)
+ word = range.toString()
+ if @isMisspelled(word)
+ corrections = spellchecker.getCorrectionsForMisspelling(word)
+ if corrections.length > 0
+ corrections.forEach (correction) =>
+ menu.append(new MenuItem({
+ label: correction,
+ click: @applyCorrection.bind(@, editableNode, range, selection, correction)
+ }))
+ else
+ menu.append(new MenuItem({ label: 'No Guesses Found', enabled: false}))
+
+ menu.append(new MenuItem({ type: 'separator' }))
+ menu.append(new MenuItem({
+ label: 'Learn Spelling',
+ click: @learnSpelling.bind(@, editableNode, word)
+ }))
+ menu.append(new MenuItem({ type: 'separator' }))
+
+ @applyCorrection: (editableNode, range, selection, correction) =>
+ DOMUtils.Mutating.applyTextInRange(range, selection, correction)
+ @walkTree(editableNode)
+
+ @learnSpelling: (editableNode, word) =>
+ spellchecker.add(word)
delete SpellcheckCache[word]
@walkTree(editableNode)
diff --git a/internal_packages/composer/lib/composer-extensions-plugin.coffee b/internal_packages/composer/lib/composer-extensions-plugin.coffee
new file mode 100644
index 000000000..468800538
--- /dev/null
+++ b/internal_packages/composer/lib/composer-extensions-plugin.coffee
@@ -0,0 +1,27 @@
+{DraftStore, DOMUtils, ContenteditablePlugin} = require 'nylas-exports'
+
+class ComposerExtensionsPlugin extends ContenteditablePlugin
+ @onInput: (event, editableNode, selection, innerStateProxy) ->
+ for extension in DraftStore.extensions()
+ extension.onInput?(editableNode, event)
+
+ @onKeyDown: (event, editableNode, selection, innerStateProxy) ->
+ if event.key is "Tab"
+ range = DOMUtils.getRangeInScope(editableNode)
+ for extension in DraftStore.extensions()
+ extension.onTabDown?(editableNode, range, event)
+
+ @onShowContextMenu: (args...) ->
+ for extension in DraftStore.extensions()
+ extension.onShowContextMenu?(args...)
+
+ @onClick: (event, editableNode, selection, innerStateProxy) ->
+ range = DOMUtils.getRangeInScope(editableNode)
+ return unless range
+ try
+ for extension in DraftStore.extensions()
+ extension.onMouseUp?(editableNode, range, event)
+ catch e
+ console.error('DraftStore extension raised an error: '+e.toString())
+
+module.exports = ComposerExtensionsPlugin
diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx
index 31e3c1a54..84f01b5bb 100644
--- a/internal_packages/composer/lib/composer-view.cjsx
+++ b/internal_packages/composer/lib/composer-view.cjsx
@@ -29,6 +29,8 @@ CollapsedParticipants = require './collapsed-participants'
Fields = require './fields'
+ComposerExtensionsPlugin = require './composer-extensions-plugin'
+
# The ComposerView is a unique React component because it (currently) is a
# singleton. Normally, the React way to do things would be to re-render the
# Composer with new props.
@@ -78,7 +80,7 @@ class ComposerView extends React.Component
@_usubs = []
@_usubs.push FileUploadStore.listen @_onFileUploadStoreChange
@_usubs.push AccountStore.listen @_onAccountStoreChanged
- @_applyFocusedField()
+ @_applyFieldFocus()
componentWillUnmount: =>
@_unmounted = true # rarf
@@ -94,7 +96,7 @@ class ComposerView extends React.Component
# re-rendering.
@_recoveredSelection = null if @_recoveredSelection?
- @_applyFocusedField()
+ @_applyFieldFocus()
_keymapHandlers: ->
'composer:send-message': => @_sendDraft()
@@ -109,14 +111,18 @@ class ComposerView extends React.Component
"composer:undo": @undo
"composer:redo": @redo
- _applyFocusedField: ->
- if @state.focusedField
+ _applyFieldFocus: ->
+ if @state.focusedField and @_lastFocusedField isnt @state.focusedField
+ @_lastFocusedField = @state.focusedField
return unless @refs[@state.focusedField]
if @refs[@state.focusedField].focus
@refs[@state.focusedField].focus()
else
React.findDOMNode(@refs[@state.focusedField]).focus()
+ if @state.focusedField is Fields.Body
+ @refs[Fields.Body].selectEnd()
+
componentWillReceiveProps: (newProps) =>
@_ignoreNextTrigger = false
if newProps.draftClientId isnt @props.draftClientId
@@ -223,7 +229,10 @@ class ComposerView extends React.Component
{@_renderSubject()}
-
+
{@_renderBody()}
{@_renderFooterRegions()}
@@ -291,39 +300,10 @@ class ComposerView extends React.Component
onScrollTo={@props.onRequestScrollTo}
onFilePaste={@_onFilePaste}
onScrollToBottom={@_onScrollToBottom()}
- lifecycleCallbacks={@_contenteditableLifecycleCallbacks()}
+ plugins={[ComposerExtensionsPlugin]}
getComposerBoundingRect={@_getComposerBoundingRect}
initialSelectionSnapshot={@_recoveredSelection} />
- _contenteditableLifecycleCallbacks: ->
- componentDidUpdate: (editableNode) =>
- for extension in DraftStore.extensions()
- extension.onComponentDidUpdate?(editableNode)
-
- onInput: (editableNode, event) =>
- for extension in DraftStore.extensions()
- extension.onInput?(editableNode, event)
-
- onTabDown: (editableNode, event, range) =>
- for extension in DraftStore.extensions()
- extension.onTabDown?(editableNode, range, event)
-
- onSubstitutionPerformed: (editableNode) =>
- for extension in DraftStore.extensions()
- extension.onSubstitutionPerformed?(editableNode)
-
- onLearnSpelling: (editableNode, text) =>
- for extension in DraftStore.extensions()
- extension.onLearnSpelling?(editableNode, text)
-
- onMouseUp: (editableNode, event, range) =>
- return unless range
- try
- for extension in DraftStore.extensions()
- extension.onMouseUp?(editableNode, range, event)
- catch e
- console.error('DraftStore extension raised an error: '+e.toString())
-
# The contenteditable decides when to request a scroll based on the
# position of the cursor and its relative distance to this composer
# component. We provide it our boundingClientRect so it can calculate
@@ -472,8 +452,23 @@ class ComposerView extends React.Component
# This lets us click outside of the `contenteditable`'s `contentBody`
# and simulate what happens when you click beneath the text *in* the
# contentEditable.
- _onClickComposeBody: (event) =>
- @refs[Fields.Body].selectEnd()
+ #
+ # Unfortunately, we need to manually keep track of the "click" in
+ # separate mouseDown, mouseUp events because we need to ensure that the
+ # start and end target are both not in the contenteditable. This ensures
+ # that this behavior doesn't interfear with a click and drag selection.
+ _onMouseDownComposerBody: (event) =>
+ if React.findDOMNode(@refs[Fields.Body]).contains(event.target)
+ @_mouseDownTarget = null
+ else @_mouseDownTarget = event.target
+
+ _onMouseUpComposerBody: (event) =>
+ if event.target is @_mouseDownTarget
+ @refs[Fields.Body].selectEnd()
+ @_mouseDownTarget = null
+
+ _onMouseMoveComposeBody: (event) =>
+ if @_mouseComposeBody is "down" then @_mouseComposeBody = "move"
_onDraftChanged: =>
return if @_ignoreNextTrigger
diff --git a/spec/components/contenteditable/automatic-list-manager-spec.coffee b/spec/components/contenteditable/automatic-list-manager-spec.coffee
new file mode 100644
index 000000000..13dc1310f
--- /dev/null
+++ b/spec/components/contenteditable/automatic-list-manager-spec.coffee
@@ -0,0 +1,105 @@
+
+xdescribe "ListManager", ->
+ beforeEach ->
+ @ce = new ContenteditableTestHarness
+
+ it "Creates ordered lists", ->
+ @ce.type ['1', '.', ' ']
+ @ce.expectHTML "
"
+ @ce.expectSelection (dom) ->
+ dom.querySelectorAll("li")[0]
+
+ it "Undoes ordered list creation with backspace", ->
+ @ce.type ['1', '.', ' ', 'backspace']
+ @ce.expectHTML "1. "
+ @ce.expectSelection (dom) ->
+ node: dom.childNodes[0]
+ offset: 3
+
+ it "Creates unordered lists with star", ->
+ @ce.type ['*', ' ']
+ @ce.expectHTML "
"
+ @ce.expectSelection (dom) ->
+ dom.querySelectorAll("li")[0]
+
+ it "Undoes unordered list creation with backspace", ->
+ @ce.type ['*', ' ', 'backspace']
+ @ce.expectHTML "* "
+ @ce.expectSelection (dom) ->
+ node: dom.childNodes[0]
+ offset: 2
+
+ it "Creates unordered lists with dash", ->
+ @ce.type ['-', ' ']
+ @ce.expectHTML "
"
+ @ce.expectSelection (dom) ->
+ dom.querySelectorAll("li")[0]
+
+ it "Undoes unordered list creation with backspace", ->
+ @ce.type ['-', ' ', 'backspace']
+ @ce.expectHTML "- "
+ @ce.expectSelection (dom) ->
+ node: dom.childNodes[0]
+ offset: 2
+
+ it "create a single item then delete it with backspace", ->
+ @ce.type ['-', ' ', 'a', 'left', 'backspace']
+ @ce.expectHTML "a"
+ @ce.expectSelection (dom) ->
+ node: dom.childNodes[0]
+ offset: 0
+
+ it "create a single item then delete it with tab", ->
+ @ce.type ['-', ' ', 'a', 'shift-tab']
+ @ce.expectHTML "a"
+ @ce.expectSelection (dom) -> dom.childNodes[0]
+ node: dom.childNodes[0]
+ offset: 1
+
+ describe "when creating two items in a list", ->
+ beforeEach ->
+ @twoItemKeys = ['-', ' ', 'a', 'enter', 'b']
+
+ it "creates two items with enter at end", ->
+ @ce.type @twoItemKeys
+ @ce.expectHTML "
"
+ @ce.expectSelection (dom) ->
+ node: dom.querySelectorAll('li')[1].childNodes[0]
+ offset: 1
+
+ it "backspace from the start of the 1st item outdents", ->
+ @ce.type @twoItemKeys.concat ['left', 'up', 'backspace']
+
+ it "backspace from the start of the 2nd item outdents", ->
+ @ce.type @twoItemKeys.concat ['left', 'backspace']
+
+ it "shift-tab from the start of the 1st item outdents", ->
+ @ce.type @twoItemKeys.concat ['left', 'up', 'shift-tab']
+
+ it "shift-tab from the start of the 2nd item outdents", ->
+ @ce.type @twoItemKeys.concat ['left', 'shift-tab']
+
+ it "shift-tab from the end of the 1st item outdents", ->
+ @ce.type @twoItemKeys.concat ['up', 'shift-tab']
+
+ it "shift-tab from the end of the 2nd item outdents", ->
+ @ce.type @twoItemKeys.concat ['shift-tab']
+
+ it "backspace from the end of the 1st item doesn't outdent", ->
+ @ce.type @twoItemKeys.concat ['up', 'backspace']
+
+ it "backspace from the end of the 2nd item doesn't outdent", ->
+ @ce.type @twoItemKeys.concat ['backspace']
+
+ describe "multi-depth bullets", ->
+ it "creates multi level bullet when tabbed in", ->
+ @ce.type ['-', ' ', 'a', 'tab']
+
+ it "creates multi level bullet when tabbed in", ->
+ @ce.type ['-', ' ', 'tab', 'a']
+
+ it "returns to single level bullet on backspace", ->
+ @ce.type ['-', ' ', 'a', 'tab', 'left', 'backspace']
+
+ it "returns to single level bullet on shift-tab", ->
+ @ce.type ['-', ' ', 'a', 'tab', 'shift-tab']
diff --git a/src/components/contenteditable/contenteditable-plugin.coffee b/src/components/contenteditable/contenteditable-plugin.coffee
new file mode 100644
index 000000000..bc3cb85e3
--- /dev/null
+++ b/src/components/contenteditable/contenteditable-plugin.coffee
@@ -0,0 +1,26 @@
+###
+ContenteditablePlugin is an abstract base class. Implementations of this
+are used to make additional changes to a
component
+beyond a user's input intents.
+
+While some ContenteditablePlugins are included with the core
+
component, others may be added via the `plugins`
+prop.
+###
+class ContenteditablePlugin
+
+ # The onInput event can be triggered by a variety of events, some of
+ # which could have been already been looked at by a callback.
+ # Pretty much any DOM mutation will fire this.
+ # Sometimes those mutations are the cause of callbacks.
+ @onInput: (event, editableNode, selection, innerStateProxy) ->
+
+ @onBlur: (event, editableNode, selection, innerStateProxy) ->
+
+ @onFocus: (event, editableNode, selection, innerStateProxy) ->
+
+ @onClick: (event, editableNode, selection, innerStateProxy) ->
+
+ @onKeyDown: (event, editableNode, selection, innerStateProxy) ->
+
+ @onShowContextMenu: (event, editableNode, selection, innerStateProxy, menu) ->
diff --git a/src/components/contenteditable/contenteditable.cjsx b/src/components/contenteditable/contenteditable.cjsx
index d7dc047ec..eb5d40c56 100644
--- a/src/components/contenteditable/contenteditable.cjsx
+++ b/src/components/contenteditable/contenteditable.cjsx
@@ -5,6 +5,8 @@ React = require 'react'
ClipboardService = require './clipboard-service'
FloatingToolbarContainer = require './floating-toolbar-container'
+ListManager = require './list-manager'
+
###
Public: A modern, well-behaved, React-compatible contenteditable
@@ -40,25 +42,37 @@ class Contenteditable extends React.Component
onScrollTo: React.PropTypes.func
onScrollToBottom: React.PropTypes.func
- # A series of callbacks that can get executed at various points along
- # the contenteditable.
- lifecycleCallbacks: React.PropTypes.object
+ # A list of objects that extend {ContenteditablePlugin}
+ plugins: React.PropTypes.array
spellcheck: React.PropTypes.bool
floatingToolbar: React.PropTypes.bool
@defaultProps:
+ plugins: []
spellcheck: true
floatingToolbar: true
- lifecycleCallbacks:
- componentDidUpdate: (editableNode) ->
- onInput: (editableNode, event) ->
- onTabDown: (editableNode, event, range) ->
- onLearnSpelling: (editableNode, text) ->
- onSubstitutionPerformed: (editableNode) ->
- onMouseUp: (editableNode, event, range) ->
+ corePlugins: [ListManager]
+
+ # We allow extensions to read, and mutate the:
+ #
+ # 1. DOM of the contenteditable
+ # 2. The Selection
+ # 3. The innerState of the component
+ # 4. The context menu (onShowContextMenu)
+ #
+ # We treat mutations as a single atomic change (even if multiple actual
+ # mutations happened).
+ atomicEdit: (editingFunction, event, extraArgs...) ->
+ @_teardownSelectionListeners()
+ innerStateProxy =
+ get: => return @innerState
+ set: (newInnerState) => @setInnerState(newInnerState)
+ args = [event, @_editableNode(), document.getSelection(), innerStateProxy, extraArgs...]
+ editingFunction.apply(null, args)
+ @_setupSelectionListeners()
constructor: (@props) ->
@innerState = {}
@@ -73,7 +87,7 @@ class Contenteditable extends React.Component
@refs["toolbarController"]?.componentWillReceiveInnerProps(innerState)
componentDidMount: =>
- @_editableNode().addEventListener('contextmenu', @_onShowContextualMenu)
+ @_editableNode().addEventListener('contextmenu', @_onShowContextMenu)
@_setupSelectionListeners()
@_setupGlobalMouseListener()
@_cleanHTML()
@@ -88,7 +102,7 @@ class Contenteditable extends React.Component
not Utils.isEqualReact(nextState, @state))
componentWillUnmount: =>
- @_editableNode().removeEventListener('contextmenu', @_onShowContextualMenu)
+ @_editableNode().removeEventListener('contextmenu', @_onShowContextMenu)
@_teardownSelectionListeners()
@_teardownGlobalMouseListener()
@@ -99,13 +113,9 @@ class Contenteditable extends React.Component
componentDidUpdate: =>
@_cleanHTML()
-
@_restoreSelection()
editableNode = @_editableNode()
-
- @props.lifecycleCallbacks.componentDidUpdate(editableNode)
-
@setInnerState
links: editableNode.querySelectorAll("*[href]")
editableNode: editableNode
@@ -191,164 +201,88 @@ class Contenteditable extends React.Component
@_setupSelectionListeners()
@_onInput()
+ # Will execute the event handlers on each of the registerd and core plugins
+ # In this context, event.preventDefault and event.stopPropagation don't refer
+ # to stopping default DOM behavior or prevent event bubbling through the DOM,
+ # but rather prevent our own Contenteditable default behavior, and preventing
+ # other plugins from being called.
+ # If any of the plugins calls event.preventDefault() it will prevent the
+ # default behavior for the Contenteditable, which basically means preventing
+ # the core plugin handlers from being called.
+ # If any of the plugins calls event.stopPropagation(), it will prevent any
+ # other plugin handlers from being called.
+ _runPluginHandlersForEvent: (method, event, args...) =>
+ executeCallback = (plugin) =>
+ return if not plugin[method]?
+ callback = callback.bind(plugin)
+ @atomicEdit(callback, event, args...)
+
+ for plugin in @props.plugins
+ break if event.isPropagationStopped()
+ executeCallback(plugin)
+
+ return if event.defaultPrevented or event.isPropagationStopped()
+ for plugin in @corePlugins
+ break if event.isPropagationStopped()
+ executeCallback(plugin)
+
_onKeyDown: (event) =>
+ @_runPluginHandlersForEvent("onKeyDown", event)
+
+ # This is a special case where we don't want to bubble up the event to the
+ # keymap manager if the plugin prevented the default behavior
+ if event.defaultPrevented
+ event.stopPropagation()
+ return
+
if event.key is "Tab"
- @_onTabDown(event)
- if event.key is "Backspace"
- @_onBackspaceDown(event)
+ @_onTabDownDefaultBehavior(event)
+
U = 85
if event.which is U and (event.metaKey or event.ctrlKey)
event.preventDefault()
document.execCommand("underline")
return
+ # Every time the contents of the contenteditable DOM node change, the
+ # `onInput` event gets fired.
+ #
+ # If we are in the middle of an `atomic` change transaction, we ignore
+ # those changes.
+ #
+ # At all other times we take the change, apply various filters to the
+ # new content, then notify our parent that the content has been updated.
_onInput: (event) =>
return if @_ignoreInputChanges
@_ignoreInputChanges = true
-
@_resetInnerStateOnInput()
- @_runCoreFilters()
-
- @props.lifecycleCallbacks.onInput(@_editableNode(), event)
+ @_runPluginHandlersForEvent("onInput", event)
@_normalize()
@_saveSelectionState()
- @_saveNewHtml()
+ @_notifyParentOfChange()
@_ignoreInputChanges = false
return
_resetInnerStateOnInput: ->
- @_justCreatedList = false
+ @_autoCreatedListFromText = false
@setInnerState dragging: false if @innerState.dragging
@setInnerState doubleDown: false if @innerState.doubleDown
- _runCoreFilters: ->
- @_createLists()
-
- _saveNewHtml: ->
+ _notifyParentOfChange: ->
@props.onChange(target: {value: @_editableNode().innerHTML})
- # Determines if the user wants to add an ordered or unordered list.
- _createLists: ->
- # The `execCommand` will update the DOM and move the cursor. Since
- # this is happening in the middle of an `_onInput` callback, we want
- # the whole operation to look "atomic". As such we'll do any necessary
- # DOM cleanup and fire the `exec` command with the listeners off, then
- # re-enable at the end.
- if @_resetListToText
- @_resetListToText = false
- return
-
- updateDOM = (command) =>
- @_teardownSelectionListeners()
- document.execCommand(command)
- selection = document.getSelection()
- selection.anchorNode.parentElement.innerHTML = ""
- @_setupSelectionListeners()
-
- text = @_textContentAtCursor()
- if (/^\d\.\s$/).test text
- @_justCreatedList = text
- updateDOM("insertOrderedList")
- else if (/^[*-]\s$/).test text
- @_justCreatedList = text
- updateDOM("insertUnorderedList")
-
- _onBackspaceDown: (event) ->
- if document.getSelection()?.isCollapsed
- if @_atStartOfList()
- li = @_closestAtCursor("li")
- list = @_closestAtCursor("ul, ol")
- return unless li and list
- event.preventDefault()
- if list.querySelectorAll('li')?[0] is li # We're in first li
- if @_justCreatedList
- @_resetListToText = true
- @_replaceFirstListItem(li, @_justCreatedList)
- else
- @_replaceFirstListItem(li, "")
- else
- document.execCommand("outdent")
-
- # The native document.execCommand('outdent')
- _outdent: ->
-
- _closestAtCursor: (selector) ->
- selection = document.getSelection()
- return unless selection?.isCollapsed
- return @_closest(selection.anchorNode, selector)
-
- # https://developer.mozilla.org/en-US/docs/Web/API/Element/closest
- # Only Elements (not Text nodes) have the `closest` method
- _closest: (node, selector) ->
- el = if node instanceof HTMLElement then node else node.parentElement
- return el.closest(selector)
-
- _replaceFirstListItem: (li, replaceWith) ->
- @_teardownSelectionListeners()
- list = @_closest(li, "ul, ol")
-
- if replaceWith.length is 0
- replaceWith = replaceWith.replace /\s/g, " "
- text = document.createElement("div")
- text.innerHTML = "
"
- 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) ->
- editableNode = @_editableNode()
- range = DOMUtils.getRangeInScope(editableNode)
-
- @props.lifecycleCallbacks.onTabDown(editableNode, event, range)
-
- return if event.defaultPrevented
- @_onTabDownDefaultBehavior(event)
-
_onTabDownDefaultBehavior: (event) ->
- event.preventDefault()
-
selection = document.getSelection()
if selection?.isCollapsed
- # Only Elements (not Text nodes) have the `closest` method
- li = @_closestAtCursor("li")
- if li
- if event.shiftKey
- list = @_closestAtCursor("ul, ol")
- # BUG: As of 9/25/15 if you outdent the first item in a list, it
- # doesn't work :(
- if list.querySelectorAll('li')?[0] is li # We're in first li
- @_replaceFirstListItem(li, li.innerHTML)
- else
- document.execCommand("outdent")
- else
- document.execCommand("indent")
- else if event.shiftKey
- if @_atTabChar()
- @_removeLastCharacter()
- else if @_atBeginning()
+ if event.shiftKey
+ if DOMUtils.isAtTabChar(selection)
+ @_removeLastCharacter(selection)
+ else if DOMUtils.isAtBeginningOfDocument(@_editableNode(), selection)
return # Don't stop propagation
else
document.execCommand("insertText", false, "\t")
@@ -357,41 +291,11 @@ class Contenteditable extends React.Component
document.execCommand("insertText", false, "")
else
document.execCommand("insertText", false, "\t")
+ event.preventDefault()
event.stopPropagation()
- _selectionInText: (selection) ->
- return false unless selection
- return selection.isCollapsed and selection.anchorNode.nodeType is Node.TEXT_NODE and selection.anchorOffset > 0
-
- _atTabChar: ->
- selection = document.getSelection()
- if @_selectionInText(selection)
- return selection.anchorNode.textContent[selection.anchorOffset - 1] is "\t"
- else return false
-
- _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 = @_closest(anchor, "li")
- return unless li
- return DOMUtils.isFirstChild(li, anchor)
-
- _atBeginning: ->
- selection = document.getSelection()
- return false if not selection.isCollapsed
- return false if selection.anchorOffset > 0
- el = @_editableNode()
- return true if el.childNodes.length is 0
- return true if selection.anchorNode is el
- firstChild = el.childNodes[0]
- return selection.anchorNode is firstChild
-
- _removeLastCharacter: ->
- selection = document.getSelection()
- if @_selectionInText(selection)
+ _removeLastCharacter: (selection) ->
+ if DOMUtils.isSelectionInTextNode(selection)
node = selection.anchorNode
offset = selection.anchorOffset
@_teardownSelectionListeners()
@@ -399,12 +303,6 @@ class Contenteditable extends React.Component
document.execCommand("delete")
@_setupSelectionListeners()
- _textContentAtCursor: ->
- selection = document.getSelection()
- if selection.isCollapsed
- return selection.anchorNode?.textContent
- else return null
-
# This component works by re-rendering on every change and restoring the
# selection. This is also how standard React controlled inputs work too.
#
@@ -468,7 +366,7 @@ class Contenteditable extends React.Component
els = @_editableNode().querySelectorAll('ul')
# This mutates the DOM in place.
- DOMUtils.collapseAdjacentElements(els)
+ DOMUtils.Mutating.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
@@ -665,7 +563,7 @@ class Contenteditable extends React.Component
rect = rangeInScope.getBoundingClientRect()
if DOMUtils.isEmptyBoudingRect(rect)
- rect = @_getSelectionRectFromDOM(selection)
+ rect = DOMUtils.getSelectionRectFromDOM(selection)
if rect
@props.onScrollTo({rect})
@@ -692,17 +590,6 @@ class Contenteditable extends React.Component
selfRect = @_editableNode().getBoundingClientRect()
return Math.abs(parentRect.bottom - selfRect.bottom) <= 250
- _getSelectionRectFromDOM: (selection) ->
- node = selection.anchorNode
- if node.nodeType is Node.TEXT_NODE
- r = document.createRange()
- r.selectNodeContents(node)
- return r.getBoundingClientRect()
- else if node.nodeType is Node.ELEMENT_NODE
- return node.getBoundingClientRect()
- else
- return null
-
# We use global listeners to determine whether or not dragging is
# happening. This is because dragging may stop outside the scope of
# this element. Note that the `dragstart` and `dragend` events don't
@@ -718,19 +605,12 @@ class Contenteditable extends React.Component
window.removeEventListener("mousedown", @__onMouseDown)
window.removeEventListener("mouseup", @__onMouseUp)
- _onShowContextualMenu: (event) =>
+ _onShowContextMenu: (event) =>
@refs["toolbarController"]?.forceClose()
event.preventDefault()
selection = document.getSelection()
- range = selection.getRangeAt(0)
-
- # On Windows, right-clicking a word does not select it at the OS-level.
- # We need to implement this behavior locally for the rest of the logic here.
- if range.collapsed
- DOMUtils.selectWordContainingRange(range)
- range = selection.getRangeAt(0)
-
+ range = DOMUtils.Mutating.getRangeAtAndSelectWord(selection, 0)
text = range.toString()
remote = require('remote')
@@ -738,48 +618,22 @@ class Contenteditable extends React.Component
Menu = remote.require('menu')
MenuItem = remote.require('menu-item')
- apply = (newtext) =>
- range.deleteContents()
- node = document.createTextNode(newtext)
- range.insertNode(node)
- range.selectNode(node)
- selection.removeAllRanges()
- selection.addRange(range)
- @props.lifecycleCallbacks.onSubstitutionPerformed(@_editableNode())
-
cut = =>
clipboard.writeText(text)
- apply('')
+ DOMUtils.Mutating.applyTextInRange(range, selection, '')
copy = =>
clipboard.writeText(text)
paste = =>
- apply(clipboard.readText())
+ DOMUtils.Mutating.applyTextInRange(range, selection, clipboard.readText())
menu = new Menu()
- ## TODO, move into spellcheck package
- if @props.spellcheck
- spellchecker = require('spellchecker')
- learnSpelling = =>
- spellchecker.add(text)
- @props.lifecycleCallbacks.onLearnSpelling(@_editableNode(), text)
- if spellchecker.isMisspelled(text)
- corrections = spellchecker.getCorrectionsForMisspelling(text)
- if corrections.length > 0
- corrections.forEach (correction) ->
- menu.append(new MenuItem({ label: correction, click:( -> apply(correction))}))
- else
- menu.append(new MenuItem({ label: 'No Guesses Found', enabled: false}))
-
- menu.append(new MenuItem({ type: 'separator' }))
- menu.append(new MenuItem({ label: 'Learn Spelling', click: learnSpelling}))
- menu.append(new MenuItem({ type: 'separator' }))
-
- menu.append(new MenuItem({ label: 'Cut', click:cut}))
- menu.append(new MenuItem({ label: 'Copy', click:copy}))
- menu.append(new MenuItem({ label: 'Paste', click:paste}))
+ @_runPluginHandlersForEvent("onShowContextMenu", event, menu)
+ menu.append(new MenuItem({ label: 'Cut', click: cut}))
+ menu.append(new MenuItem({ label: 'Copy', click: copy}))
+ menu.append(new MenuItem({ label: 'Paste', click: paste}))
menu.popup(remote.getCurrentWindow())
_onMouseDown: (event) =>
@@ -820,10 +674,7 @@ class Contenteditable extends React.Component
selection = document.getSelection()
return event unless DOMUtils.selectionInScope(selection, editableNode)
- range = DOMUtils.getRangeInScope(editableNode)
-
- @props.lifecycleCallbacks.onMouseUp(editableNode, event, range)
-
+ @_runPluginHandlersForEvent("onClick", event)
return event
_onDragStart: (event) =>
@@ -883,9 +734,6 @@ class Contenteditable extends React.Component
@_ensureSelectionVisible(selection)
@_setupSelectionListeners()
- _getNodeIndex: (nodeToFind) =>
- 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`
#
diff --git a/src/components/contenteditable/list-manager.coffee b/src/components/contenteditable/list-manager.coffee
new file mode 100644
index 000000000..c37133fcf
--- /dev/null
+++ b/src/components/contenteditable/list-manager.coffee
@@ -0,0 +1,100 @@
+_str = require 'underscore.string'
+{DOMUtils} = require 'nylas-exports'
+ContenteditablePlugin = require './contenteditable-plugin'
+
+class ListManager extends ContenteditablePlugin
+ @onInput: (event, editableNode, selection) ->
+ if @_spaceEntered and @hasListStartSignature(selection)
+ @createList(event, selection)
+
+ @onKeyDown: (event, editableNode, selection) ->
+ @_spaceEntered = event.key is " "
+ if DOMUtils.isInList()
+ if event.key is "Backspace" and DOMUtils.atStartOfList()
+ event.preventDefault()
+ @outdentListItem(selection)
+ else if event.key is "Tab" and selection.isCollapsed
+ event.preventDefault()
+ if event.shiftKey
+ @outdentListItem(selection)
+ else
+ document.execCommand("indent")
+ else
+ # Do nothing, let the event through.
+ @originalInput = null
+ else
+ @originalInput = null
+
+ return event
+
+ @bulletRegex: -> /^[*-]\s/
+
+ @numberRegex: -> /^\d\.\s/
+
+ @hasListStartSignature: (selection) ->
+ return false unless selection?.anchorNode
+ return false if not selection.isCollapsed
+
+ text = selection.anchorNode.textContent
+ return @numberRegex().test(text) or @bulletRegex().test(text)
+
+ @createList: (event, selection) ->
+ text = selection.anchorNode?.textContent
+
+ if @numberRegex().test(text)
+ @originalInput = text[0...3]
+ document.execCommand("insertOrderedList")
+ @removeListStarter(@numberRegex(), selection)
+ else if @bulletRegex().test(text)
+ @originalInput = text[0...2]
+ document.execCommand("insertUnorderedList")
+ @removeListStarter(@bulletRegex(), selection)
+ else
+ return
+ el = DOMUtils.closest(selection.anchorNode, "li")
+ DOMUtils.Mutating.removeEmptyNodes(el)
+ event.preventDefault()
+
+ @removeListStarter: (starterRegex, selection) ->
+ el = DOMUtils.closest(selection.anchorNode, "li")
+ textContent = el.textContent.replace(starterRegex, "")
+ if textContent.trim().length is 0
+ el.innerHTML = "
"
+ else
+ textNode = DOMUtils.findFirstTextNode(el)
+ textNode.textContent = textNode.textContent.replace(starterRegex, "")
+
+ # From a newly-created list
+ # Outdent returns to a
structure
+ # I need to turn into
-
+ #
+ # From a list with content
+ # Outent returns to
sometext
+ # We need to turn that into
- sometext
+ @restoreOriginalInput: (selection) ->
+ node = selection.anchorNode
+ return unless node
+ if node.nodeType is Node.TEXT_NODE
+ node.textContent = @originalInput + node.textContent
+ else if node.nodeType is Node.ELEMENT_NODE
+ textNode = DOMUtils.findFirstTextNode(node)
+ if not textNode
+ node.innerHTML = @originalInput.replace(" ", " ") + node.innerHTML
+ else
+ textNode.textContent = @originalInput + textNode.textContent
+
+ if @numberRegex().test(@originalInput)
+ DOMUtils.Mutating.moveSelectionToIndexInAnchorNode(selection, 3) # digit plus dot
+ if @bulletRegex().test(@originalInput)
+ DOMUtils.Mutating.moveSelectionToIndexInAnchorNode(selection, 2) # dash or star
+
+ @originalInput = null
+
+ @outdentListItem: (selection) ->
+ if @originalInput
+ document.execCommand("outdent")
+ @restoreOriginalInput(selection)
+ else
+ document.execCommand("outdent")
+
+module.exports = ListManager
diff --git a/src/contenteditable-workarounds.coffee b/src/contenteditable-workarounds.coffee
new file mode 100644
index 000000000..bb913a66d
--- /dev/null
+++ b/src/contenteditable-workarounds.coffee
@@ -0,0 +1,66 @@
+DOMUtils = require './dom-utils'
+
+###
+The Nylas N1 Contenteditable component relies on Chrome's (via Electron) implementation of DOM Contenteditable.
+
+Contenteditable is problematic when multiple browser support is required. Since we only support one browser (Electron), its behavior is consistent.
+
+Unfortunately there are still a handful of issues in its implementation.
+
+For more reading on Contenteditable and its issues see:
+
+- https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_Editable
+- https://medium.com/medium-eng/why-contenteditable-is-terrible-122d8a40e480
+- https://blog.whatwg.org/the-road-to-html-5-contenteditable
+- https://github.com/basecamp/trix
+###
+class Workarounds
+
+ @patch: ->
+ return
+ @origExecCommand ?= document.execCommand
+ @patchOutdent()
+
+ # As of Electron 0.29.2 `document.execCommand('outdent')` does not
+ # properly work on the
tag of both and lists when
+ # there is exactly one - tag.
+ #
+ # We must manually perform the outdent when we detect we are at at the
+ # first item in a list.
+ #
+ # Given
+ # ```html
+ #
+ # ```
+ @patchOutdent: ->
+ document.execCommand = (command, args...) =>
+ if command is "outdent"
+ @customOutdent()
+ else
+ @origExecCommand.apply(document, [command].concat(args))
+
+ @customOutdent: ->
+ parentList = DOMUtils.closestAtCursor("ul, ol")
+ if parentList
+ listItems = parentList.querySelectorAll('li')
+ if listItems.length is 1
+ originalText = listItems[0].innerHTML
+ DOMUtils.Mutating.replaceFirstListItem(listItems[0], originalText)
+ else
+ @origExecCommand.call(document, "outdent")
+ else
+ @origExecCommand.call(document, "outdent")
+
+ # outdentFirstListItem: (replaceWithContent) ->
+ #
+ # # Detects if the cursor is in the first list item.
+ # detectOutdentFirstListItem: ->
+ # li = DOMUtils.closestAtCursor("li")
+ # return false if not li
+ # list = DOMUtils.closestAtCursor("ul, ol")
+ # return list.querySelectorAll('li')?[0] is li
+
+Workarounds.patch()
+module.exports = Workarounds
diff --git a/src/dom-utils.coffee b/src/dom-utils.coffee
index c767a5b48..fafe01580 100644
--- a/src/dom-utils.coffee
+++ b/src/dom-utils.coffee
@@ -2,38 +2,188 @@ _ = require 'underscore'
_s = require 'underscore.string'
DOMUtils =
+ Mutating:
+ replaceFirstListItem: (li, replaceWith) ->
+ list = DOMUtils.closest(li, "ul, ol")
- # 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)
+ if replaceWith.length is 0
+ replaceWith = replaceWith.replace /\s/g, " "
+ text = document.createElement("div")
+ text.innerHTML = "
"
+ else
+ replaceWith = replaceWith.replace /\s/g, " "
+ text = document.createElement("span")
+ text.innerHTML = "#{replaceWith}"
- seenEls = []
- toMerge = []
+ 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)
- 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)
+ child = text.childNodes[0] ? text
+ index = Math.max(replaceWith.length - 1, 0)
+ selection = document.getSelection()
+ selection.setBaseAndExtent(child, index, child, index)
- 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)
+ removeEmptyNodes: (node) ->
+ Array::slice.call(node.childNodes).forEach (child) ->
+ if child.textContent is ''
+ node.removeChild(child)
+ else
+ DOMUtils.Mutating.removeEmptyNodes(child)
- return anchors
+ # 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)
+
+ # https://developer.mozilla.org/en-US/docs/Web/API/Element/closest
+ # Only Elements (not Text nodes) have the `closest` method
+ closest: (node, selector) ->
+ el = if node instanceof HTMLElement then node else node.parentElement
+ return el.closest(selector)
+
+ closestAtCursor: (selector) ->
+ selection = document.getSelection()
+ return unless selection?.isCollapsed
+ return DOMUtils.closest(selection.anchorNode, selector)
+
+ 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
@@ -192,6 +342,28 @@ DOMUtils =
else continue
return lastNode
+ 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
+
+ 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
@@ -218,16 +390,6 @@ DOMUtils =
"'": '''
text.replace /[&<>"']/g, (m) -> map[m]
- 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
-
# Checks to see if a particular node is visible and any of its parents
# are visible.
#
@@ -301,29 +463,6 @@ DOMUtils =
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) ->
- 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)
-
commonAncestor: (nodes=[]) ->
nodes = Array::slice.call(nodes)
diff --git a/src/global/nylas-exports.coffee b/src/global/nylas-exports.coffee
index 96ba7b8d8..e53255c9f 100644
--- a/src/global/nylas-exports.coffee
+++ b/src/global/nylas-exports.coffee
@@ -144,6 +144,9 @@ class NylasExports
@load "BufferedProcess", 'buffered-process'
@get "APMWrapper", -> require('../apm-wrapper')
+ # Contenteditable
+ @load "ContenteditablePlugin", 'components/contenteditable/contenteditable-plugin'
+
# Testing
@get "NylasTestUtils", -> require '../../spec/nylas-test-utils'
diff --git a/src/nylas-env.coffee b/src/nylas-env.coffee
index 41b644443..4bcc32e94 100644
--- a/src/nylas-env.coffee
+++ b/src/nylas-env.coffee
@@ -30,6 +30,9 @@ module.exports =
class NylasEnvConstructor extends Model
@version: 1 # Increment this when the serialization format changes
+ assert: (bool, msg) ->
+ throw new Error("Assertion error: #{msg}") if not bool
+
# Load or create the application environment
# Returns an NylasEnv instance, fully initialized
@loadOrCreate: ->
@@ -144,6 +147,8 @@ class NylasEnvConstructor extends Model
unless @inDevMode() or @inSpecMode()
require('grim').deprecate = ->
+ @enhanceEventObject()
+
@setupErrorLogger()
@unsubscribe()
@@ -919,3 +924,12 @@ class NylasEnvConstructor extends Model
remote.require('app').quit()
else
@close()
+
+ enhanceEventObject: ->
+ overriddenStop = Event::stopPropagation
+ Event::stopPropagation = ->
+ @propagationStopped = true
+ overriddenStop.apply(@, arguments)
+ Event::isPropagationStopped = ->
+ @propagationStopped
+
diff --git a/src/services/quoted-html-parser.coffee b/src/services/quoted-html-parser.coffee
index 70e79c8dd..eb5354fab 100644
--- a/src/services/quoted-html-parser.coffee
+++ b/src/services/quoted-html-parser.coffee
@@ -44,7 +44,7 @@ class QuotedHTMLParser
if options.keepIfWholeBodyIsQuote and @_wholeBodyIsQuote(doc, quoteElements)
return doc.children[0].innerHTML
else
- DOMUtils.removeElements(quoteElements, options)
+ DOMUtils.Mutating.removeElements(quoteElements, options)
childNodes = doc.body.childNodes
extraTailBrTags = []
@@ -56,7 +56,7 @@ class QuotedHTMLParser
else
break
- DOMUtils.removeElements(extraTailBrTags)
+ DOMUtils.Mutating.removeElements(extraTailBrTags)
return doc.children[0].innerHTML
appendQuotedHTML: (htmlWithoutQuotes, originalHTML) ->