mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-01-01 13:14:16 +08:00
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:
parent
447dc7283e
commit
c2ce51ae0c
12 changed files with 698 additions and 347 deletions
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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. "
|
||||
@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 "* "
|
||||
@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 "- "
|
||||
@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']
|
26
src/components/contenteditable/contenteditable-plugin.coffee
Normal file
26
src/components/contenteditable/contenteditable-plugin.coffee
Normal 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) ->
|
|
@ -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 = "<br>"
|
||||
else
|
||||
replaceWith = replaceWith.replace /\s/g, " "
|
||||
text = document.createElement("span")
|
||||
text.innerHTML = "#{replaceWith}"
|
||||
|
||||
if list.querySelectorAll('li').length <= 1
|
||||
# Delete the whole list and replace with text
|
||||
list.parentNode.replaceChild(text, list)
|
||||
else
|
||||
# Delete the list item and prepend the text before the rest of the
|
||||
# list
|
||||
li.parentNode.removeChild(li)
|
||||
list.parentNode.insertBefore(text, list)
|
||||
|
||||
child = text.childNodes[0] ? text
|
||||
index = Math.max(replaceWith.length - 1, 0)
|
||||
selection = document.getSelection()
|
||||
selection.setBaseAndExtent(child, index, child, index)
|
||||
|
||||
@_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`
|
||||
#
|
||||
|
|
100
src/components/contenteditable/list-manager.coffee
Normal file
100
src/components/contenteditable/list-manager.coffee
Normal 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>- </div>
|
||||
#
|
||||
# From a list with content
|
||||
# Outent returns to <div>sometext</div>
|
||||
# We need to turn that into <div>- 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(" ", " ") + 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
|
66
src/contenteditable-workarounds.coffee
Normal file
66
src/contenteditable-workarounds.coffee
Normal 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
|
|
@ -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 = "<br>"
|
||||
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)
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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) ->
|
||||
|
|
Loading…
Reference in a new issue