fix(composer): Fix several composer issues and refactor Contenteditable

Summary:
  - Fixes T5819 issues
  - Adds ContenteditbalePlugin mechanism to allow extension of Contenteditable
    functionality, and completely removes lifecycleCallbacks from Contenteditable
  - Refactors list functionality outside of Contenteditable and into a plugin
  - Updates ComposerView to apply DraftStoreExtensions through a ContentEditablePlugin
  - Moves spell checking logic outside of Contenteditable into the spellcheck package

Fixes T5824 (atom.assert)
Fixes T5951 (shift-tabbing) bullets

Test Plan: - Unit tests and manual

Reviewers: evan, bengotow

Reviewed By: bengotow

Maniphest Tasks: T5951, T5824, T5819

Differential Revision: https://phab.nylas.com/D2261
This commit is contained in:
Juan Tejada 2015-11-18 12:09:07 -08:00
parent 447dc7283e
commit c2ce51ae0c
12 changed files with 698 additions and 347 deletions

View file

@ -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)

View file

@ -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

View file

@ -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()}
<div className="compose-body" ref="composeBody" onClick={@_onClickComposeBody}>
<div className="compose-body"
ref="composeBody"
onMouseUp={@_onMouseUpComposerBody}
onMouseDown={@_onMouseDownComposerBody}>
{@_renderBody()}
{@_renderFooterRegions()}
</div>
@ -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

View file

@ -0,0 +1,105 @@
xdescribe "ListManager", ->
beforeEach ->
@ce = new ContenteditableTestHarness
it "Creates ordered lists", ->
@ce.type ['1', '.', ' ']
@ce.expectHTML "<ol><li></li></ol>"
@ce.expectSelection (dom) ->
dom.querySelectorAll("li")[0]
it "Undoes ordered list creation with backspace", ->
@ce.type ['1', '.', ' ', 'backspace']
@ce.expectHTML "1.&nbsp;"
@ce.expectSelection (dom) ->
node: dom.childNodes[0]
offset: 3
it "Creates unordered lists with star", ->
@ce.type ['*', ' ']
@ce.expectHTML "<ul><li></li></ul>"
@ce.expectSelection (dom) ->
dom.querySelectorAll("li")[0]
it "Undoes unordered list creation with backspace", ->
@ce.type ['*', ' ', 'backspace']
@ce.expectHTML "*&nbsp;"
@ce.expectSelection (dom) ->
node: dom.childNodes[0]
offset: 2
it "Creates unordered lists with dash", ->
@ce.type ['-', ' ']
@ce.expectHTML "<ul><li></li></ul>"
@ce.expectSelection (dom) ->
dom.querySelectorAll("li")[0]
it "Undoes unordered list creation with backspace", ->
@ce.type ['-', ' ', 'backspace']
@ce.expectHTML "-&nbsp;"
@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 "<ul><li>a</li><li>b</li></ul>"
@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']

View file

@ -0,0 +1,26 @@
###
ContenteditablePlugin is an abstract base class. Implementations of this
are used to make additional changes to a <Contenteditable /> component
beyond a user's input intents.
While some ContenteditablePlugins are included with the core
<Contenteditable /> 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) ->

View file

@ -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, "&nbsp;"
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)
@_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`
#

View file

@ -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 = "<br>"
else
textNode = DOMUtils.findFirstTextNode(el)
textNode.textContent = textNode.textContent.replace(starterRegex, "")
# From a newly-created list
# Outdent returns to a <div><br/></div> structure
# I need to turn into <div>-&nbsp;</div>
#
# From a list with content
# Outent returns to <div>sometext</div>
# We need to turn that into <div>-&nbsp;sometext</div>
@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(" ", "&nbsp;") + 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

View file

@ -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 <li> tag of both <ul> and <ol> lists when
# there is exactly one <li> tag.
#
# We must manually perform the outdent when we detect we are at at the
# first item in a list.
#
# Given
# ```html
# <ul>
# <li>a</li>
# </ul>
# ```
@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

View file

@ -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, "&nbsp;"
text = document.createElement("div")
text.innerHTML = "<br>"
else
replaceWith = replaceWith.replace /\s/g, "&nbsp;"
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 &nbsp;
@ -218,16 +390,6 @@ DOMUtils =
"'": '&#039;'
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)

View file

@ -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'

View file

@ -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

View file

@ -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) ->