mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-09-10 22:54:45 +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
e1aa260597
commit
02deba38c4
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'
|
_ = require 'underscore'
|
||||||
|
spellchecker = require('spellchecker')
|
||||||
|
remote = require('remote')
|
||||||
|
MenuItem = remote.require('menu-item')
|
||||||
|
|
||||||
SpellcheckCache = {}
|
SpellcheckCache = {}
|
||||||
|
|
||||||
class SpellcheckDraftStoreExtension extends DraftStoreExtension
|
class SpellcheckDraftStoreExtension extends DraftStoreExtension
|
||||||
|
|
||||||
@isMisspelled: (word) ->
|
@isMisspelled: (word) ->
|
||||||
@spellchecker ?= require('spellchecker')
|
SpellcheckCache[word] ?= spellchecker.isMisspelled(word)
|
||||||
SpellcheckCache[word] ?= @spellchecker.isMisspelled(word)
|
|
||||||
SpellcheckCache[word]
|
SpellcheckCache[word]
|
||||||
|
|
||||||
@onComponentDidUpdate: (editableNode) ->
|
@onInput: (editableNode) ->
|
||||||
@walkTree(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]
|
delete SpellcheckCache[word]
|
||||||
@walkTree(editableNode)
|
@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'
|
Fields = require './fields'
|
||||||
|
|
||||||
|
ComposerExtensionsPlugin = require './composer-extensions-plugin'
|
||||||
|
|
||||||
# The ComposerView is a unique React component because it (currently) is a
|
# 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
|
# singleton. Normally, the React way to do things would be to re-render the
|
||||||
# Composer with new props.
|
# Composer with new props.
|
||||||
|
@ -78,7 +80,7 @@ class ComposerView extends React.Component
|
||||||
@_usubs = []
|
@_usubs = []
|
||||||
@_usubs.push FileUploadStore.listen @_onFileUploadStoreChange
|
@_usubs.push FileUploadStore.listen @_onFileUploadStoreChange
|
||||||
@_usubs.push AccountStore.listen @_onAccountStoreChanged
|
@_usubs.push AccountStore.listen @_onAccountStoreChanged
|
||||||
@_applyFocusedField()
|
@_applyFieldFocus()
|
||||||
|
|
||||||
componentWillUnmount: =>
|
componentWillUnmount: =>
|
||||||
@_unmounted = true # rarf
|
@_unmounted = true # rarf
|
||||||
|
@ -94,7 +96,7 @@ class ComposerView extends React.Component
|
||||||
# re-rendering.
|
# re-rendering.
|
||||||
@_recoveredSelection = null if @_recoveredSelection?
|
@_recoveredSelection = null if @_recoveredSelection?
|
||||||
|
|
||||||
@_applyFocusedField()
|
@_applyFieldFocus()
|
||||||
|
|
||||||
_keymapHandlers: ->
|
_keymapHandlers: ->
|
||||||
'composer:send-message': => @_sendDraft()
|
'composer:send-message': => @_sendDraft()
|
||||||
|
@ -109,14 +111,18 @@ class ComposerView extends React.Component
|
||||||
"composer:undo": @undo
|
"composer:undo": @undo
|
||||||
"composer:redo": @redo
|
"composer:redo": @redo
|
||||||
|
|
||||||
_applyFocusedField: ->
|
_applyFieldFocus: ->
|
||||||
if @state.focusedField
|
if @state.focusedField and @_lastFocusedField isnt @state.focusedField
|
||||||
|
@_lastFocusedField = @state.focusedField
|
||||||
return unless @refs[@state.focusedField]
|
return unless @refs[@state.focusedField]
|
||||||
if @refs[@state.focusedField].focus
|
if @refs[@state.focusedField].focus
|
||||||
@refs[@state.focusedField].focus()
|
@refs[@state.focusedField].focus()
|
||||||
else
|
else
|
||||||
React.findDOMNode(@refs[@state.focusedField]).focus()
|
React.findDOMNode(@refs[@state.focusedField]).focus()
|
||||||
|
|
||||||
|
if @state.focusedField is Fields.Body
|
||||||
|
@refs[Fields.Body].selectEnd()
|
||||||
|
|
||||||
componentWillReceiveProps: (newProps) =>
|
componentWillReceiveProps: (newProps) =>
|
||||||
@_ignoreNextTrigger = false
|
@_ignoreNextTrigger = false
|
||||||
if newProps.draftClientId isnt @props.draftClientId
|
if newProps.draftClientId isnt @props.draftClientId
|
||||||
|
@ -223,7 +229,10 @@ class ComposerView extends React.Component
|
||||||
|
|
||||||
{@_renderSubject()}
|
{@_renderSubject()}
|
||||||
|
|
||||||
<div className="compose-body" ref="composeBody" onClick={@_onClickComposeBody}>
|
<div className="compose-body"
|
||||||
|
ref="composeBody"
|
||||||
|
onMouseUp={@_onMouseUpComposerBody}
|
||||||
|
onMouseDown={@_onMouseDownComposerBody}>
|
||||||
{@_renderBody()}
|
{@_renderBody()}
|
||||||
{@_renderFooterRegions()}
|
{@_renderFooterRegions()}
|
||||||
</div>
|
</div>
|
||||||
|
@ -291,39 +300,10 @@ class ComposerView extends React.Component
|
||||||
onScrollTo={@props.onRequestScrollTo}
|
onScrollTo={@props.onRequestScrollTo}
|
||||||
onFilePaste={@_onFilePaste}
|
onFilePaste={@_onFilePaste}
|
||||||
onScrollToBottom={@_onScrollToBottom()}
|
onScrollToBottom={@_onScrollToBottom()}
|
||||||
lifecycleCallbacks={@_contenteditableLifecycleCallbacks()}
|
plugins={[ComposerExtensionsPlugin]}
|
||||||
getComposerBoundingRect={@_getComposerBoundingRect}
|
getComposerBoundingRect={@_getComposerBoundingRect}
|
||||||
initialSelectionSnapshot={@_recoveredSelection} />
|
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
|
# The contenteditable decides when to request a scroll based on the
|
||||||
# position of the cursor and its relative distance to this composer
|
# position of the cursor and its relative distance to this composer
|
||||||
# component. We provide it our boundingClientRect so it can calculate
|
# 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`
|
# This lets us click outside of the `contenteditable`'s `contentBody`
|
||||||
# and simulate what happens when you click beneath the text *in* the
|
# and simulate what happens when you click beneath the text *in* the
|
||||||
# contentEditable.
|
# contentEditable.
|
||||||
_onClickComposeBody: (event) =>
|
#
|
||||||
|
# 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()
|
@refs[Fields.Body].selectEnd()
|
||||||
|
@_mouseDownTarget = null
|
||||||
|
|
||||||
|
_onMouseMoveComposeBody: (event) =>
|
||||||
|
if @_mouseComposeBody is "down" then @_mouseComposeBody = "move"
|
||||||
|
|
||||||
_onDraftChanged: =>
|
_onDraftChanged: =>
|
||||||
return if @_ignoreNextTrigger
|
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'
|
ClipboardService = require './clipboard-service'
|
||||||
FloatingToolbarContainer = require './floating-toolbar-container'
|
FloatingToolbarContainer = require './floating-toolbar-container'
|
||||||
|
|
||||||
|
ListManager = require './list-manager'
|
||||||
|
|
||||||
###
|
###
|
||||||
Public: A modern, well-behaved, React-compatible contenteditable
|
Public: A modern, well-behaved, React-compatible contenteditable
|
||||||
|
|
||||||
|
@ -40,25 +42,37 @@ class Contenteditable extends React.Component
|
||||||
onScrollTo: React.PropTypes.func
|
onScrollTo: React.PropTypes.func
|
||||||
onScrollToBottom: React.PropTypes.func
|
onScrollToBottom: React.PropTypes.func
|
||||||
|
|
||||||
# A series of callbacks that can get executed at various points along
|
# A list of objects that extend {ContenteditablePlugin}
|
||||||
# the contenteditable.
|
plugins: React.PropTypes.array
|
||||||
lifecycleCallbacks: React.PropTypes.object
|
|
||||||
|
|
||||||
spellcheck: React.PropTypes.bool
|
spellcheck: React.PropTypes.bool
|
||||||
|
|
||||||
floatingToolbar: React.PropTypes.bool
|
floatingToolbar: React.PropTypes.bool
|
||||||
|
|
||||||
@defaultProps:
|
@defaultProps:
|
||||||
|
plugins: []
|
||||||
spellcheck: true
|
spellcheck: true
|
||||||
floatingToolbar: 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) ->
|
constructor: (@props) ->
|
||||||
@innerState = {}
|
@innerState = {}
|
||||||
|
@ -73,7 +87,7 @@ class Contenteditable extends React.Component
|
||||||
@refs["toolbarController"]?.componentWillReceiveInnerProps(innerState)
|
@refs["toolbarController"]?.componentWillReceiveInnerProps(innerState)
|
||||||
|
|
||||||
componentDidMount: =>
|
componentDidMount: =>
|
||||||
@_editableNode().addEventListener('contextmenu', @_onShowContextualMenu)
|
@_editableNode().addEventListener('contextmenu', @_onShowContextMenu)
|
||||||
@_setupSelectionListeners()
|
@_setupSelectionListeners()
|
||||||
@_setupGlobalMouseListener()
|
@_setupGlobalMouseListener()
|
||||||
@_cleanHTML()
|
@_cleanHTML()
|
||||||
|
@ -88,7 +102,7 @@ class Contenteditable extends React.Component
|
||||||
not Utils.isEqualReact(nextState, @state))
|
not Utils.isEqualReact(nextState, @state))
|
||||||
|
|
||||||
componentWillUnmount: =>
|
componentWillUnmount: =>
|
||||||
@_editableNode().removeEventListener('contextmenu', @_onShowContextualMenu)
|
@_editableNode().removeEventListener('contextmenu', @_onShowContextMenu)
|
||||||
@_teardownSelectionListeners()
|
@_teardownSelectionListeners()
|
||||||
@_teardownGlobalMouseListener()
|
@_teardownGlobalMouseListener()
|
||||||
|
|
||||||
|
@ -99,13 +113,9 @@ class Contenteditable extends React.Component
|
||||||
|
|
||||||
componentDidUpdate: =>
|
componentDidUpdate: =>
|
||||||
@_cleanHTML()
|
@_cleanHTML()
|
||||||
|
|
||||||
@_restoreSelection()
|
@_restoreSelection()
|
||||||
|
|
||||||
editableNode = @_editableNode()
|
editableNode = @_editableNode()
|
||||||
|
|
||||||
@props.lifecycleCallbacks.componentDidUpdate(editableNode)
|
|
||||||
|
|
||||||
@setInnerState
|
@setInnerState
|
||||||
links: editableNode.querySelectorAll("*[href]")
|
links: editableNode.querySelectorAll("*[href]")
|
||||||
editableNode: editableNode
|
editableNode: editableNode
|
||||||
|
@ -191,164 +201,88 @@ class Contenteditable extends React.Component
|
||||||
@_setupSelectionListeners()
|
@_setupSelectionListeners()
|
||||||
@_onInput()
|
@_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) =>
|
_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"
|
if event.key is "Tab"
|
||||||
@_onTabDown(event)
|
@_onTabDownDefaultBehavior(event)
|
||||||
if event.key is "Backspace"
|
|
||||||
@_onBackspaceDown(event)
|
|
||||||
U = 85
|
U = 85
|
||||||
if event.which is U and (event.metaKey or event.ctrlKey)
|
if event.which is U and (event.metaKey or event.ctrlKey)
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
document.execCommand("underline")
|
document.execCommand("underline")
|
||||||
return
|
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) =>
|
_onInput: (event) =>
|
||||||
return if @_ignoreInputChanges
|
return if @_ignoreInputChanges
|
||||||
@_ignoreInputChanges = true
|
@_ignoreInputChanges = true
|
||||||
|
|
||||||
@_resetInnerStateOnInput()
|
@_resetInnerStateOnInput()
|
||||||
|
|
||||||
@_runCoreFilters()
|
@_runPluginHandlersForEvent("onInput", event)
|
||||||
|
|
||||||
@props.lifecycleCallbacks.onInput(@_editableNode(), event)
|
|
||||||
|
|
||||||
@_normalize()
|
@_normalize()
|
||||||
|
|
||||||
@_saveSelectionState()
|
@_saveSelectionState()
|
||||||
|
|
||||||
@_saveNewHtml()
|
@_notifyParentOfChange()
|
||||||
|
|
||||||
@_ignoreInputChanges = false
|
@_ignoreInputChanges = false
|
||||||
return
|
return
|
||||||
|
|
||||||
_resetInnerStateOnInput: ->
|
_resetInnerStateOnInput: ->
|
||||||
@_justCreatedList = false
|
@_autoCreatedListFromText = false
|
||||||
@setInnerState dragging: false if @innerState.dragging
|
@setInnerState dragging: false if @innerState.dragging
|
||||||
@setInnerState doubleDown: false if @innerState.doubleDown
|
@setInnerState doubleDown: false if @innerState.doubleDown
|
||||||
|
|
||||||
_runCoreFilters: ->
|
_notifyParentOfChange: ->
|
||||||
@_createLists()
|
|
||||||
|
|
||||||
_saveNewHtml: ->
|
|
||||||
@props.onChange(target: {value: @_editableNode().innerHTML})
|
@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) ->
|
_onTabDownDefaultBehavior: (event) ->
|
||||||
event.preventDefault()
|
|
||||||
|
|
||||||
selection = document.getSelection()
|
selection = document.getSelection()
|
||||||
if selection?.isCollapsed
|
if selection?.isCollapsed
|
||||||
# Only Elements (not Text nodes) have the `closest` method
|
|
||||||
li = @_closestAtCursor("li")
|
|
||||||
if li
|
|
||||||
if event.shiftKey
|
if event.shiftKey
|
||||||
list = @_closestAtCursor("ul, ol")
|
if DOMUtils.isAtTabChar(selection)
|
||||||
# BUG: As of 9/25/15 if you outdent the first item in a list, it
|
@_removeLastCharacter(selection)
|
||||||
# doesn't work :(
|
else if DOMUtils.isAtBeginningOfDocument(@_editableNode(), selection)
|
||||||
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()
|
|
||||||
return # Don't stop propagation
|
return # Don't stop propagation
|
||||||
else
|
else
|
||||||
document.execCommand("insertText", false, "\t")
|
document.execCommand("insertText", false, "\t")
|
||||||
|
@ -357,41 +291,11 @@ class Contenteditable extends React.Component
|
||||||
document.execCommand("insertText", false, "")
|
document.execCommand("insertText", false, "")
|
||||||
else
|
else
|
||||||
document.execCommand("insertText", false, "\t")
|
document.execCommand("insertText", false, "\t")
|
||||||
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
_selectionInText: (selection) ->
|
_removeLastCharacter: (selection) ->
|
||||||
return false unless selection
|
if DOMUtils.isSelectionInTextNode(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)
|
|
||||||
node = selection.anchorNode
|
node = selection.anchorNode
|
||||||
offset = selection.anchorOffset
|
offset = selection.anchorOffset
|
||||||
@_teardownSelectionListeners()
|
@_teardownSelectionListeners()
|
||||||
|
@ -399,12 +303,6 @@ class Contenteditable extends React.Component
|
||||||
document.execCommand("delete")
|
document.execCommand("delete")
|
||||||
@_setupSelectionListeners()
|
@_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
|
# This component works by re-rendering on every change and restoring the
|
||||||
# selection. This is also how standard React controlled inputs work too.
|
# selection. This is also how standard React controlled inputs work too.
|
||||||
#
|
#
|
||||||
|
@ -468,7 +366,7 @@ class Contenteditable extends React.Component
|
||||||
els = @_editableNode().querySelectorAll('ul')
|
els = @_editableNode().querySelectorAll('ul')
|
||||||
|
|
||||||
# This mutates the DOM in place.
|
# 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
|
# After an input, the selection can sometimes get itself into a state
|
||||||
# that either can't be restored properly, or will cause undersirable
|
# that either can't be restored properly, or will cause undersirable
|
||||||
|
@ -665,7 +563,7 @@ class Contenteditable extends React.Component
|
||||||
|
|
||||||
rect = rangeInScope.getBoundingClientRect()
|
rect = rangeInScope.getBoundingClientRect()
|
||||||
if DOMUtils.isEmptyBoudingRect(rect)
|
if DOMUtils.isEmptyBoudingRect(rect)
|
||||||
rect = @_getSelectionRectFromDOM(selection)
|
rect = DOMUtils.getSelectionRectFromDOM(selection)
|
||||||
|
|
||||||
if rect
|
if rect
|
||||||
@props.onScrollTo({rect})
|
@props.onScrollTo({rect})
|
||||||
|
@ -692,17 +590,6 @@ class Contenteditable extends React.Component
|
||||||
selfRect = @_editableNode().getBoundingClientRect()
|
selfRect = @_editableNode().getBoundingClientRect()
|
||||||
return Math.abs(parentRect.bottom - selfRect.bottom) <= 250
|
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
|
# We use global listeners to determine whether or not dragging is
|
||||||
# happening. This is because dragging may stop outside the scope of
|
# happening. This is because dragging may stop outside the scope of
|
||||||
# this element. Note that the `dragstart` and `dragend` events don't
|
# 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("mousedown", @__onMouseDown)
|
||||||
window.removeEventListener("mouseup", @__onMouseUp)
|
window.removeEventListener("mouseup", @__onMouseUp)
|
||||||
|
|
||||||
_onShowContextualMenu: (event) =>
|
_onShowContextMenu: (event) =>
|
||||||
@refs["toolbarController"]?.forceClose()
|
@refs["toolbarController"]?.forceClose()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
selection = document.getSelection()
|
selection = document.getSelection()
|
||||||
range = selection.getRangeAt(0)
|
range = DOMUtils.Mutating.getRangeAtAndSelectWord(selection, 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)
|
|
||||||
|
|
||||||
text = range.toString()
|
text = range.toString()
|
||||||
|
|
||||||
remote = require('remote')
|
remote = require('remote')
|
||||||
|
@ -738,45 +618,19 @@ class Contenteditable extends React.Component
|
||||||
Menu = remote.require('menu')
|
Menu = remote.require('menu')
|
||||||
MenuItem = remote.require('menu-item')
|
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 = =>
|
cut = =>
|
||||||
clipboard.writeText(text)
|
clipboard.writeText(text)
|
||||||
apply('')
|
DOMUtils.Mutating.applyTextInRange(range, selection, '')
|
||||||
|
|
||||||
copy = =>
|
copy = =>
|
||||||
clipboard.writeText(text)
|
clipboard.writeText(text)
|
||||||
|
|
||||||
paste = =>
|
paste = =>
|
||||||
apply(clipboard.readText())
|
DOMUtils.Mutating.applyTextInRange(range, selection, clipboard.readText())
|
||||||
|
|
||||||
menu = new Menu()
|
menu = new Menu()
|
||||||
|
|
||||||
## TODO, move into spellcheck package
|
@_runPluginHandlersForEvent("onShowContextMenu", event, menu)
|
||||||
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: 'Cut', click: cut}))
|
||||||
menu.append(new MenuItem({ label: 'Copy', click: copy}))
|
menu.append(new MenuItem({ label: 'Copy', click: copy}))
|
||||||
menu.append(new MenuItem({ label: 'Paste', click: paste}))
|
menu.append(new MenuItem({ label: 'Paste', click: paste}))
|
||||||
|
@ -820,10 +674,7 @@ class Contenteditable extends React.Component
|
||||||
selection = document.getSelection()
|
selection = document.getSelection()
|
||||||
return event unless DOMUtils.selectionInScope(selection, editableNode)
|
return event unless DOMUtils.selectionInScope(selection, editableNode)
|
||||||
|
|
||||||
range = DOMUtils.getRangeInScope(editableNode)
|
@_runPluginHandlersForEvent("onClick", event)
|
||||||
|
|
||||||
@props.lifecycleCallbacks.onMouseUp(editableNode, event, range)
|
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
_onDragStart: (event) =>
|
_onDragStart: (event) =>
|
||||||
|
@ -883,9 +734,6 @@ class Contenteditable extends React.Component
|
||||||
@_ensureSelectionVisible(selection)
|
@_ensureSelectionVisible(selection)
|
||||||
@_setupSelectionListeners()
|
@_setupSelectionListeners()
|
||||||
|
|
||||||
_getNodeIndex: (nodeToFind) =>
|
|
||||||
DOMUtils.findSimilarNodes(@_editableNode(), nodeToFind).indexOf nodeToFind
|
|
||||||
|
|
||||||
# This needs to be in the contenteditable area because we need to first
|
# This needs to be in the contenteditable area because we need to first
|
||||||
# restore the selection before calling the `execCommand`
|
# 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,13 +2,44 @@ _ = require 'underscore'
|
||||||
_s = require 'underscore.string'
|
_s = require 'underscore.string'
|
||||||
|
|
||||||
DOMUtils =
|
DOMUtils =
|
||||||
|
Mutating:
|
||||||
|
replaceFirstListItem: (li, replaceWith) ->
|
||||||
|
list = DOMUtils.closest(li, "ul, ol")
|
||||||
|
|
||||||
|
if replaceWith.length is 0
|
||||||
|
replaceWith = replaceWith.replace /\s/g, " "
|
||||||
|
text = document.createElement("div")
|
||||||
|
text.innerHTML = "<br>"
|
||||||
|
else
|
||||||
|
replaceWith = replaceWith.replace /\s/g, " "
|
||||||
|
text = document.createElement("span")
|
||||||
|
text.innerHTML = "#{replaceWith}"
|
||||||
|
|
||||||
|
if list.querySelectorAll('li').length <= 1
|
||||||
|
# Delete the whole list and replace with text
|
||||||
|
list.parentNode.replaceChild(text, list)
|
||||||
|
else
|
||||||
|
# Delete the list item and prepend the text before the rest of the
|
||||||
|
# list
|
||||||
|
li.parentNode.removeChild(li)
|
||||||
|
list.parentNode.insertBefore(text, list)
|
||||||
|
|
||||||
|
child = text.childNodes[0] ? text
|
||||||
|
index = Math.max(replaceWith.length - 1, 0)
|
||||||
|
selection = document.getSelection()
|
||||||
|
selection.setBaseAndExtent(child, index, child, index)
|
||||||
|
|
||||||
|
removeEmptyNodes: (node) ->
|
||||||
|
Array::slice.call(node.childNodes).forEach (child) ->
|
||||||
|
if child.textContent is ''
|
||||||
|
node.removeChild(child)
|
||||||
|
else
|
||||||
|
DOMUtils.Mutating.removeEmptyNodes(child)
|
||||||
|
|
||||||
# Given a bunch of elements, it will go through and find all elements
|
# 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
|
# 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
|
# adjacent elements, it will put all children of those elements into
|
||||||
# first one and delete the remaining elements.
|
# the first one and delete the remaining elements.
|
||||||
#
|
|
||||||
# WARNING: This mutates the DOM in place!
|
|
||||||
collapseAdjacentElements: (els=[]) ->
|
collapseAdjacentElements: (els=[]) ->
|
||||||
return if els.length is 0
|
return if els.length is 0
|
||||||
els = Array::slice.call(els)
|
els = Array::slice.call(els)
|
||||||
|
@ -30,11 +61,130 @@ DOMUtils =
|
||||||
for el in remaining
|
for el in remaining
|
||||||
while (el.childNodes.length > 0)
|
while (el.childNodes.length > 0)
|
||||||
anchor.appendChild(el.childNodes[0])
|
anchor.appendChild(el.childNodes[0])
|
||||||
DOMUtils.removeElements(remaining)
|
DOMUtils.Mutating.removeElements(remaining)
|
||||||
anchors.push(anchor)
|
anchors.push(anchor)
|
||||||
|
|
||||||
return anchors
|
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
|
# Returns an array of all immediately adjacent nodes of a particular
|
||||||
# nodeName relative to the root. Includes the root if it has the correct
|
# nodeName relative to the root. Includes the root if it has the correct
|
||||||
# nodeName.
|
# nodeName.
|
||||||
|
@ -192,6 +342,28 @@ DOMUtils =
|
||||||
else continue
|
else continue
|
||||||
return lastNode
|
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) ->
|
isBlankTextNode: (node) ->
|
||||||
return if not node?.data
|
return if not node?.data
|
||||||
# \u00a0 is
|
# \u00a0 is
|
||||||
|
@ -218,16 +390,6 @@ DOMUtils =
|
||||||
"'": '''
|
"'": '''
|
||||||
text.replace /[&<>"']/g, (m) -> map[m]
|
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
|
# Checks to see if a particular node is visible and any of its parents
|
||||||
# are visible.
|
# are visible.
|
||||||
#
|
#
|
||||||
|
@ -301,29 +463,6 @@ DOMUtils =
|
||||||
return true if root.childNodes[0] is node
|
return true if root.childNodes[0] is node
|
||||||
return DOMUtils.isFirstChild(root.childNodes[0], 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=[]) ->
|
commonAncestor: (nodes=[]) ->
|
||||||
nodes = Array::slice.call(nodes)
|
nodes = Array::slice.call(nodes)
|
||||||
|
|
||||||
|
|
|
@ -144,6 +144,9 @@ class NylasExports
|
||||||
@load "BufferedProcess", 'buffered-process'
|
@load "BufferedProcess", 'buffered-process'
|
||||||
@get "APMWrapper", -> require('../apm-wrapper')
|
@get "APMWrapper", -> require('../apm-wrapper')
|
||||||
|
|
||||||
|
# Contenteditable
|
||||||
|
@load "ContenteditablePlugin", 'components/contenteditable/contenteditable-plugin'
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
@get "NylasTestUtils", -> require '../../spec/nylas-test-utils'
|
@get "NylasTestUtils", -> require '../../spec/nylas-test-utils'
|
||||||
|
|
||||||
|
|
|
@ -30,6 +30,9 @@ module.exports =
|
||||||
class NylasEnvConstructor extends Model
|
class NylasEnvConstructor extends Model
|
||||||
@version: 1 # Increment this when the serialization format changes
|
@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
|
# Load or create the application environment
|
||||||
# Returns an NylasEnv instance, fully initialized
|
# Returns an NylasEnv instance, fully initialized
|
||||||
@loadOrCreate: ->
|
@loadOrCreate: ->
|
||||||
|
@ -144,6 +147,8 @@ class NylasEnvConstructor extends Model
|
||||||
unless @inDevMode() or @inSpecMode()
|
unless @inDevMode() or @inSpecMode()
|
||||||
require('grim').deprecate = ->
|
require('grim').deprecate = ->
|
||||||
|
|
||||||
|
@enhanceEventObject()
|
||||||
|
|
||||||
@setupErrorLogger()
|
@setupErrorLogger()
|
||||||
|
|
||||||
@unsubscribe()
|
@unsubscribe()
|
||||||
|
@ -919,3 +924,12 @@ class NylasEnvConstructor extends Model
|
||||||
remote.require('app').quit()
|
remote.require('app').quit()
|
||||||
else
|
else
|
||||||
@close()
|
@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)
|
if options.keepIfWholeBodyIsQuote and @_wholeBodyIsQuote(doc, quoteElements)
|
||||||
return doc.children[0].innerHTML
|
return doc.children[0].innerHTML
|
||||||
else
|
else
|
||||||
DOMUtils.removeElements(quoteElements, options)
|
DOMUtils.Mutating.removeElements(quoteElements, options)
|
||||||
childNodes = doc.body.childNodes
|
childNodes = doc.body.childNodes
|
||||||
|
|
||||||
extraTailBrTags = []
|
extraTailBrTags = []
|
||||||
|
@ -56,7 +56,7 @@ class QuotedHTMLParser
|
||||||
else
|
else
|
||||||
break
|
break
|
||||||
|
|
||||||
DOMUtils.removeElements(extraTailBrTags)
|
DOMUtils.Mutating.removeElements(extraTailBrTags)
|
||||||
return doc.children[0].innerHTML
|
return doc.children[0].innerHTML
|
||||||
|
|
||||||
appendQuotedHTML: (htmlWithoutQuotes, originalHTML) ->
|
appendQuotedHTML: (htmlWithoutQuotes, originalHTML) ->
|
||||||
|
|
Loading…
Add table
Reference in a new issue