refactor(contenteditable): use DOM mutation observers

Summary: This uses DOM mutation observers instead of `onInput`

Test Plan: manual and new integration tests

Reviewers: bengotow, juan

Differential Revision: https://phab.nylas.com/D2291

feat(contenteditable): add bold, underline, etc keymaps

Moving button extensions out of toolbar

Extracted floating toolbar buttons

Convert ContenteditableExtension to new spec

Update packages to use new callback signature

Fix specs
This commit is contained in:
Evan Morikawa 2015-11-25 16:07:50 -08:00
parent b4dda021b1
commit f3d58aaede
13 changed files with 328 additions and 242 deletions

View file

@ -15,7 +15,7 @@ class SpellcheckComposerExtension extends ComposerExtension
@onInput: (editableNode) =>
@walkTree(editableNode)
@onShowContextMenu: (event, editableNode, selection, menu) =>
@onShowContextMenu: (editableNode, selection, event, menu) =>
range = DOMUtils.Mutating.getRangeAtAndSelectWord(selection, 0)
word = range.toString()
if @isMisspelled(word)

View file

@ -67,7 +67,7 @@ class ComposerView extends React.Component
enabledFields: [] # Gets updated in @_initiallyEnabledFields
showQuotedText: false
uploads: FileUploadStore.uploadsForMessage(@props.draftClientId) ? []
extensions: ExtensionRegistry.Composer.extensions()
composerExtensions: @_composerExtensions()
componentWillMount: =>
@_prepareForDraft(@props.draftClientId)
@ -99,6 +99,11 @@ class ComposerView extends React.Component
@_applyFieldFocus()
## TODO add core composer extensions to refactor callback props out of
# Contenteditable
_composerExtensions: ->
ExtensionRegistry.Composer.extensions()
_keymapHandlers: ->
'composer:send-message': => @_sendDraft()
'composer:delete-empty-draft': => @_deleteDraftIfEmpty()
@ -301,7 +306,7 @@ class ComposerView extends React.Component
onScrollTo={@props.onRequestScrollTo}
onFilePaste={@_onFilePaste}
onScrollToBottom={@_onScrollToBottom()}
extensions={@state.extensions}
extensions={@state.composerExtensions}
getComposerBoundingRect={@_getComposerBoundingRect}
initialSelectionSnapshot={@_recoveredSelection} />
@ -531,7 +536,7 @@ class ComposerView extends React.Component
return enabledFields
_onExtensionsChanged: =>
@setState extensions: ExtensionRegistry.Composer.extensions()
@setState composerExtensions: @_composerExtensions()
# When the account store changes, the From field may or may not still
# be in scope. We need to make sure to update our enabled fields.
@ -689,7 +694,7 @@ class ComposerView extends React.Component
warnings.push('without a body')
# Check third party warnings added via DraftStore extensions
for extension in @state.extensions
for extension in @state.composerExtensions
continue unless extension.warningsForSending
warnings = warnings.concat(extension.warningsForSending(draft))

View file

@ -143,7 +143,7 @@ describe "populated composer", ->
editableNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@composer, 'contentEditable'))
spyOn(@proxy.changes, "add")
editableNode.innerHTML = "Hello <strong>world</strong>"
ReactTestUtils.Simulate.input(editableNode)
@composer.refs[Fields.Body]._onDOMMutated(["mutated"])
expect(@proxy.changes.add).toHaveBeenCalled()
expect(@proxy.changes.add.calls.length).toBe 1
body = @proxy.changes.add.calls[0].args[0].body
@ -168,7 +168,7 @@ describe "populated composer", ->
it 'saves the full new body, plus quoted text', ->
@editableNode.innerHTML = "Hello <strong>world</strong>"
ReactTestUtils.Simulate.input(@editableNode)
@composer.refs[Fields.Body]._onDOMMutated(["mutated"])
expect(@proxy.changes.add).toHaveBeenCalled()
expect(@proxy.changes.add.calls.length).toBe 1
body = @proxy.changes.add.calls[0].args[0].body
@ -200,7 +200,7 @@ describe "populated composer", ->
it 'saves the full new body, plus forwarded text', ->
@editableNode.innerHTML = "Hello <strong>world</strong>#{@fwdBody}"
ReactTestUtils.Simulate.input(@editableNode)
@composer.refs[Fields.Body]._onDOMMutated(["mutated"])
expect(@proxy.changes.add).toHaveBeenCalled()
expect(@proxy.changes.add.calls.length).toBe 1
body = @proxy.changes.add.calls[0].args[0].body

View file

@ -32,7 +32,7 @@ describe "Composer Quoted Text", ->
# Must be called with the test's scope
setHTML = (newHTML) ->
@$contentEditable.innerHTML = newHTML
ReactTestUtils.Simulate.input(@$contentEditable, {target: {value: newHTML}})
@contentEditable._onDOMMutated(["mutated"])
describe "quoted-text-control toggle button", ->

View file

@ -15,6 +15,7 @@ describe "Contenteditable", ->
@component = ReactTestUtils.renderIntoDocument(
<Contenteditable html={html} onChange={@onChange}/>
)
@editableNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@component, 'contentEditable'))
describe "render", ->
@ -30,20 +31,26 @@ describe "Contenteditable", ->
@performEdit = (newHTML, component = @component) =>
@editableNode.innerHTML = newHTML
ReactTestUtils.Simulate.input(@editableNode, {target: {value: newHTML}})
it "should fire `props.onChange`", ->
@performEdit('Test <strong>New HTML</strong>')
expect(@onChange).toHaveBeenCalled()
runs =>
@performEdit('Test <strong>New HTML</strong>')
waitsFor =>
@onChange.calls.length > 0
runs =>
expect(@onChange).toHaveBeenCalled()
# One day we may make this more efficient. For now we aggressively
# re-render because of the manual cursor positioning.
it "should fire if the html is the same", ->
expect(@onChange.callCount).toBe(0)
@performEdit(@changedHtmlWithoutQuote)
expect(@onChange.callCount).toBe(1)
@performEdit(@changedHtmlWithoutQuote)
expect(@onChange.callCount).toBe(2)
runs =>
@performEdit(@changedHtmlWithoutQuote)
@performEdit(@changedHtmlWithoutQuote)
waitsFor =>
@onChange.callCount > 0
runs =>
expect(@onChange).toHaveBeenCalled()
describe "pasting", ->
beforeEach ->

View file

@ -39,14 +39,13 @@ class Contenteditable extends React.Component
# Handlers
onChange: React.PropTypes.func.isRequired
onFilePaste: React.PropTypes.func
# Passes an absolute top coordinate to scroll to.
onScrollTo: React.PropTypes.func
onScrollToBottom: React.PropTypes.func
# Extension DOM Mutating handlers. See {ContenteditableExtension}
onFilePaste: React.PropTypes.func
onInput: React.PropTypes.func
onBlur: React.PropTypes.func
onFocus: React.PropTypes.func
onClick: React.PropTypes.func
onKeyDown: React.PropTypes.func
@ -75,14 +74,12 @@ class Contenteditable extends React.Component
#
# 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(), extraArgs..., innerStateProxy]
atomicEdit: (editingFunction, extraArgs...) =>
@_teardownListeners()
args = [@_editableNode(), document.getSelection(), extraArgs...]
editingFunction.apply(null, args)
@_setupSelectionListeners()
@_setupListeners()
@_onDOMMutated()
constructor: (@props) ->
@innerState = {}
@ -97,11 +94,10 @@ class Contenteditable extends React.Component
@refs["toolbarController"]?.componentWillReceiveInnerProps(innerState)
componentDidMount: =>
@_editableNode().addEventListener('contextmenu', @_onShowContextMenu)
@_setupSelectionListeners()
@_mutationObserver = new MutationObserver(@_onDOMMutated)
@_setupListeners()
@_setupGlobalMouseListener()
@_cleanHTML()
@setInnerState editableNode: @_editableNode()
# When we have a composition event in progress, we should not update
@ -112,8 +108,7 @@ class Contenteditable extends React.Component
not Utils.isEqualReact(nextState, @state))
componentWillUnmount: =>
@_editableNode().removeEventListener('contextmenu', @_onShowContextMenu)
@_teardownSelectionListeners()
@_teardownListeners()
@_teardownGlobalMouseListener()
componentWillReceiveProps: (nextProps) =>
@ -126,6 +121,12 @@ class Contenteditable extends React.Component
@_restoreSelection()
editableNode = @_editableNode()
# On a given update the actual DOM node might be a different object on
# the heap. We need to refresh the mutation listeners.
@_teardownListeners()
@_setupListeners()
@setInnerState
links: editableNode.querySelectorAll("*[href]")
editableNode: editableNode
@ -133,9 +134,9 @@ class Contenteditable extends React.Component
_renderFloatingToolbar: ->
return unless @props.floatingToolbar
<FloatingToolbarContainer
ref="toolbarController"
onSaveUrl={@_onSaveUrl}
onDomMutator={@_onDomMutator} />
ref="toolbarController"
atomicEdit={@atomicEdit}
onSaveUrl={@_onSaveUrl} />
render: =>
<KeyCommandsRegion className="contenteditable-container"
@ -151,25 +152,26 @@ class Contenteditable extends React.Component
</KeyCommandsRegion>
_keymapHandlers: ->
'contenteditable:underline': @_execCommandWrap("underline")
'contenteditable:bold': @_execCommandWrap("bold")
'contenteditable:italic': @_execCommandWrap("italic")
'contenteditable:numbered-list': @_execCommandWrap("insertOrderedList")
'contenteditable:bulleted-list': @_execCommandWrap("insertUnorderedList")
'contenteditable:outdent': @_execCommandWrap("outdent")
'contenteditable:indent': @_execCommandWrap("indent")
atomicEditWrap = => (command) => (event) =>
@atomicEdit((-> document.execCommand(command)), event)
_execCommandWrap: (command) => (e) =>
@atomicEdit =>
document.execCommand(command)
, e
keymapHandlers = {
'contenteditable:bold': atomicEditWrap("bold")
'contenteditable:italic': atomicEditWrap("italic")
'contenteditable:indent': atomicEditWrap("indent")
'contenteditable:outdent': atomicEditWrap("outdent")
'contenteditable:underline': atomicEditWrap("underline")
'contenteditable:numbered-list': atomicEditWrap("insertOrderedList")
'contenteditable:bulleted-list': atomicEditWrap("insertUnorderedList")
}
return keymapHandlers
_eventHandlers: =>
onBlur: @_onBlur
onFocus: @_onFocus
onClick: @_onClick
onPaste: @clipboardService.onPaste
onInput: @_onInput
onKeyDown: @_onKeyDown
onCompositionEnd: @_onCompositionEnd
onCompositionStart: @_onCompositionStart
@ -186,18 +188,6 @@ class Contenteditable extends React.Component
selection.removeAllRanges()
selection.addRange(range)
# When some other component (like the `FloatingToolbar` or some
# `ComposerExtension`) wants to mutate the DOM, it declares a
# `mutator` function. That mutator expects to be passed the latest DOM
# object (the `_editableNode()`) and will do mutations to it. Once those
# mutations are done, we need to be sure to notify that changes
# happened.
_onDomMutator: (mutator) =>
@_teardownSelectionListeners()
mutator(@_editableNode())
@_setupSelectionListeners()
@_onInput()
_onClick: (event) ->
# We handle mouseDown, mouseMove, mouseUp, but we want to stop propagation
# of `click` to make it clear that we've handled the event.
@ -211,7 +201,7 @@ class Contenteditable extends React.Component
# It is also possible for a composition event to end and then
# immediately start a new composition event. This happens when two
# composition event-triggering characters are pressed twice in a row.
# When the first composition event ends, the `onInput` method fires (as
# When the first composition event ends, the `_onDOMMutated` method fires (as
# it's supposed to) and sends off an asynchronous update request when we
# `_saveNewHtml`. Before that comes back via new props, the 2nd
# composition event starts. Without the `_inCompositionEvent` flag
@ -219,46 +209,44 @@ class Contenteditable extends React.Component
# to re-render and blow away our newly started 2nd composition event.
_onCompositionStart: =>
@_inCompositionEvent = true
@_teardownSelectionListeners()
@_teardownListeners()
_onCompositionEnd: =>
@_inCompositionEvent = false
@_setupSelectionListeners()
@_onInput()
@_setupListeners()
@_onDOMMutated()
# Will execute the event handlers on each of the registerd and core extensions
# 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 extensions from being called.
# If any of the extensions calls event.preventDefault() it will prevent the
# default behavior for the Contenteditable, which basically means preventing
# the core extension handlers from being called.
# If any of the extensions calls event.stopPropagation(), it will prevent any
# other extension handlers from being called.
#
# NOTE: It's possible for there to be no `event` passed in.
_runExtensionHandlersForEvent: (method, event, args...) =>
executeCallback = (extension) =>
return if not extension[method]?
callback = extension[method].bind(extension)
@atomicEdit(callback, event, args...)
# Check if any of the extension handlers where passed as a prop and call
# that first
executeCallback(@props)
_runCallbackOnExtensions: (method, args...) =>
for extension in @props.extensions.concat(@coreExtensions)
@_runExtensionMethod(extension, method, args...)
# Will execute the event handlers on each of the registerd and core
# extensions 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 extensions from
# being called. If any of the extensions calls event.preventDefault()
# it will prevent the default behavior for the Contenteditable, which
# basically means preventing the core extension handlers from being
# called. If any of the extensions calls event.stopPropagation(), it
# will prevent any other extension handlers from being called.
_runEventCallbackOnExtensions: (method, event, args...) =>
for extension in @props.extensions
break if event?.isPropagationStopped()
executeCallback(extension)
@_runExtensionMethod(extension, method, event, args...)
return if event?.defaultPrevented or event?.isPropagationStopped()
for extension in @coreExtensions
break if event?.isPropagationStopped()
executeCallback(extension)
@_runExtensionMethod(extension, method, event, args...)
_runExtensionMethod: (extension, method, args...) =>
return if not extension[method]?
editingFunction = extension[method].bind(extension)
@atomicEdit(editingFunction, args...)
_onKeyDown: (event) =>
@_runExtensionHandlersForEvent("onKeyDown", event)
@_runEventCallbackOnExtensions("onKeyDown", event)
# This is a special case where we don't want to bubble up the event to the
# keymap manager if the extension prevented the default behavior
@ -271,19 +259,20 @@ class Contenteditable extends React.Component
return
# Every time the contents of the contenteditable DOM node change, the
# `onInput` event gets fired.
# `_onDOMMutated` 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) =>
_onDOMMutated: (mutations) =>
return if @_ignoreInputChanges
return unless mutations and mutations.length > 0
@_ignoreInputChanges = true
@_resetInnerStateOnInput()
@_runExtensionHandlersForEvent("onInput", event)
@_runCallbackOnExtensions("onContentChanged", mutations)
@_normalize()
@ -324,10 +313,10 @@ class Contenteditable extends React.Component
if DOMUtils.isSelectionInTextNode(selection)
node = selection.anchorNode
offset = selection.anchorOffset
@_teardownSelectionListeners()
@_teardownListeners()
selection.setBaseAndExtent(node, offset - 1, node, offset)
document.execCommand("delete")
@_setupSelectionListeners()
@_setupListeners()
# This component works by re-rendering on every change and restoring the
# selection. This is also how standard React controlled inputs work too.
@ -411,14 +400,14 @@ class Contenteditable extends React.Component
return if selection.anchorOffset > 0 or selection.focusOffset > 0
if selection.isCollapsed and @_unselectableNode(selection.focusNode)
@_teardownSelectionListeners()
@_teardownListeners()
treeWalker = document.createTreeWalker(selection.focusNode)
while treeWalker.nextNode()
currentNode = treeWalker.currentNode
if @_unselectableNode(currentNode)
selection.setBaseAndExtent(currentNode, 0, currentNode, 0)
break
@_setupSelectionListeners()
@_setupListeners()
return
_unselectableNode: (node) ->
@ -437,12 +426,12 @@ class Contenteditable extends React.Component
_onBlur: (event) =>
@setInnerState dragging: false
return if @_editableNode().parentElement.contains event.relatedTarget
@_runExtensionHandlersForEvent("onBlur", event)
@_runEventCallbackOnExtensions("onBlur", event)
@setInnerState editableFocused: false
_onFocus: (event) =>
@setInnerState editableFocused: true
@_runExtensionHandlersForEvent("onFocus", event)
@_runEventCallbackOnExtensions("onFocus", event)
_editableNode: =>
React.findDOMNode(@refs.contenteditable)
@ -486,13 +475,26 @@ class Contenteditable extends React.Component
# which node is most likely the matching one.
# http://www.w3.org/TR/selection-api/#selectstart-event
_setupSelectionListeners: =>
_setupListeners: =>
@_ignoreInputChanges = false
@_mutationObserver.observe(@_editableNode(), @_mutationConfig())
document.addEventListener("selectionchange", @_saveSelectionState)
@_editableNode().addEventListener('contextmenu', @_onShowContextMenu)
_teardownSelectionListeners: =>
# https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
_mutationConfig: ->
subtree: true
childList: true
attributes: true
characterData: true
attributeOldValue: true
characterDataOldValue: true
_teardownListeners: =>
document.removeEventListener("selectionchange", @_saveSelectionState)
@_mutationObserver.disconnect()
@_ignoreInputChanges = true
@_editableNode().removeEventListener('contextmenu', @_onShowContextMenu)
getCurrentSelection: => _.clone(@_selection ? {})
getPreviousSelection: => _.clone(@_previousSelection ? {})
@ -642,7 +644,7 @@ class Contenteditable extends React.Component
menu = new Menu()
@_runExtensionHandlersForEvent("onShowContextMenu", event, menu)
@_runEventCallbackOnExtensions("onShowContextMenu", event, menu)
menu.append(new MenuItem({ label: 'Cut', role: 'cut'}))
menu.append(new MenuItem({ label: 'Copy', role: 'copy'}))
menu.append(new MenuItem({ label: 'Paste', role: 'paste'}))
@ -686,7 +688,7 @@ class Contenteditable extends React.Component
selection = document.getSelection()
return event unless DOMUtils.selectionInScope(selection, editableNode)
@_runExtensionHandlersForEvent("onClick", event)
@_runEventCallbackOnExtensions("onClick", event)
return event
_onDragStart: (event) =>
@ -736,7 +738,7 @@ class Contenteditable extends React.Component
newEndNode = DOMUtils.findSimilarNodes(editable, @_selection.endNode)[@_selection.endNodeIndex]
return if not newStartNode? or not newEndNode?
@_teardownSelectionListeners()
@_teardownListeners()
selection = document.getSelection()
selection.setBaseAndExtent(newStartNode,
@_selection.startOffset,
@ -744,12 +746,15 @@ class Contenteditable extends React.Component
@_selection.endOffset)
@_ensureSelectionVisible(selection)
@_setupSelectionListeners()
@_setupListeners()
# This needs to be in the contenteditable area because we need to first
# restore the selection before calling the `execCommand`
#
# If the url is empty, that means we want to remove the url.
#
# TODO: Move this into floating-toolbar-container once we do a refactor
# pass on the Selection object.
_onSaveUrl: (url, linkToModify) =>
if linkToModify?
linkToModify = DOMUtils.findSimilarNodes(@_editableNode(), linkToModify)?[0]?.childNodes[0]
@ -779,12 +784,12 @@ class Contenteditable extends React.Component
_execCommand: (commandArgs=[], selectionRange={}) =>
{anchorNode, anchorOffset, focusNode, focusOffset} = selectionRange
@_teardownSelectionListeners()
@_teardownListeners()
if anchorNode and focusNode
selection = document.getSelection()
selection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset)
document.execCommand.apply(document, commandArgs)
@_setupSelectionListeners()
@_onInput()
@_setupListeners()
@_onDOMMutated()
module.exports = Contenteditable

View file

@ -1,7 +1,7 @@
_ = require 'underscore'
React = require 'react'
{Utils, DOMUtils} = require 'nylas-exports'
{Utils, DOMUtils, ExtensionRegistry} = require 'nylas-exports'
FloatingToolbar = require './floating-toolbar'
@ -11,15 +11,22 @@ class FloatingToolbarContainer extends React.Component
@displayName: "FloatingToolbarContainer"
@propTypes:
# We are passed in the Contenteditable's `atomicEdit` mutator
# function. This is the safe way to request updates in the
# contenteditable. It will pass the editable DOM node and the
# Selection object plus any extra args (like DOM event objects) to the
# callback
atomicEdit: React.PropTypes.func
# A function we call when we would like to request to change the
# current selection
#
# TODO: This is passed in and can't use atomicEdit in its pure form
# because it needs to reset the main selection state of the
# Contenteditable plugin. This should go away once we do a Selection
# refactor.
onSaveUrl: React.PropTypes.func
# When an extension wants to mutate the DOM, it passes `onDomMutator`
# a callback function. That callback is expecting to be passed the
# latest DOM object and may modify it in place.
onDomMutator: React.PropTypes.func
@innerPropTypes:
links: React.PropTypes.array
dragging: React.PropTypes.bool
@ -88,17 +95,51 @@ class FloatingToolbarContainer extends React.Component
onMouseEnter={@_onEnterToolbar}
onChangeMode={@_onChangeMode}
onMouseLeave={@_onLeaveToolbar}
onDomMutator={@props.onDomMutator}
linkToModify={@state.linkToModify}
buttonConfigs={@_toolbarButtonConfigs()}
onChangeFocus={@_onChangeFocus}
editAreaWidth={@state.editAreaWidth}
contentPadding={@CONTENT_PADDING}
onDoneWithLink={@_onDoneWithLink}
onClickLinkEditBtn={@_onClickLinkEditBtn} />
onDoneWithLink={@_onDoneWithLink} />
# Called when a user clicks the "link" icon on the FloatingToolbar
_onClickLinkEditBtn: =>
@setState toolbarMode: "edit-link"
# We setup the buttons that the Toolbar should have as a combination of
# core actions and user-defined plugins. The FloatingToolbar simply
# renders them.
_toolbarButtonConfigs: ->
atomicEditWrap = (command) => (event) =>
@props.atomicEdit((-> document.execCommand(command)), event)
extensionButtonConfigs = []
ExtensionRegistry.Composer.extensions().forEach (ext) ->
config = ext.composerToolbar?()
extensionButtonConfigs.push(config) if config?
return [
{
className: "btn-bold"
onClick: atomicEditWrap("bold")
tooltip: "Bold"
iconUrl: null # Defined in the css of btn-bold
}
{
className: "btn-italic"
onClick: atomicEditWrap("italic")
tooltip: "Italic"
iconUrl: null # Defined in the css of btn-italic
}
{
className: "btn-underline"
onClick: atomicEditWrap("underline")
tooltip: "Underline"
iconUrl: null # Defined in the css of btn-underline
}
{
className: "btn-link"
onClick: => @setState toolbarMode: "edit-link"
tooltip: "Edit Link"
iconUrl: null # Defined in the css of btn-link
}
].concat(extensionButtonConfigs)
# A user could be done with a link because they're setting a new one, or
# clearing one, or just canceling.

View file

@ -9,31 +9,71 @@ class FloatingToolbar extends React.Component
@displayName = "FloatingToolbar"
@propTypes:
# Absolute position in px relative to parent <Contenteditable />
top: React.PropTypes.number
# Absolute position in px relative to parent <Contenteditable />
left: React.PropTypes.number
# Either "above" or "below". Used when determining which CSS to use
pos: React.PropTypes.string
# Either "edit-link" or "buttons". Determines whether we're showing
# edit buttons or the link editor
mode: React.PropTypes.string
# The current display state of the toolbar
visible: React.PropTypes.bool
# A callback function we use to save the URL to the Contenteditable
#
# TODO: This only gets passed down because the Selection state must be
# manually maniuplated to apply the link to the appropriate text via
# the document.execcommand("createLink") command. This should get
# refactored with the Selection state.
onSaveUrl: React.PropTypes.func
# A callback so our parent can decide whether or not to hide when the
# mouse has moved over the component
onMouseEnter: React.PropTypes.func
onMouseLeave: React.PropTypes.func
# When an extension wants to mutate the DOM, it passes `onDomMutator`
# a mutator function. That mutator is expecting to be passed the
# latest DOM object and may modify it in place.
onDomMutator: React.PropTypes.func
# The current DOM link we are modifying
linkToModify: React.PropTypes.object
# Declares what buttons should appear in the toolbar. An array of
# config objects.
buttonConfigs: React.PropTypes.array
# Notifies our parent of when we focus in and out of inputs in the
# toolbar.
onChangeFocus: React.PropTypes.func
# The absolute available area we have used in calculating our
# appropriate position.
editAreaWidth: React.PropTypes.number
# The absolute available padding we have used in calculating our
# appropriate position.
contentPadding: React.PropTypes.number
# A callback used when a link has been cancled, completed, or escaped
# from. Used to notify our parent to switch modes.
onDoneWithLink: React.PropTypes.func
@defaultProps:
mode: "buttons"
onMouseEnter: ->
onMouseLeave: ->
buttonConfigs: []
constructor: (@props) ->
@state =
urlInputValue: @_initialUrl() ? ""
componentWidth: 0
extensions: ExtensionRegistry.Composer.extensions()
componentDidMount: =>
@subscriptions = new CompositeDisposable()
@usubExtensions = ExtensionRegistry.Composer.listen @_onExtensionsChanged
componentWillReceiveProps: (nextProps) =>
@setState
@ -41,7 +81,6 @@ class FloatingToolbar extends React.Component
componentWillUnmount: =>
@subscriptions?.dispose()
@usubExtensions()
componentDidUpdate: =>
if @props.mode is "edit-link" and not @props.linkToModify
@ -78,38 +117,16 @@ class FloatingToolbar extends React.Component
else return <div></div>
_renderButtons: =>
<div className="toolbar-buttons">
<button className="btn btn-bold toolbar-btn"
onClick={@_execCommand}
data-command-name="bold"></button>
<button className="btn btn-italic toolbar-btn"
onClick={@_execCommand}
data-command-name="italic"></button>
<button className="btn btn-underline toolbar-btn"
onClick={@_execCommand}
data-command-name="underline"></button>
<button className="btn btn-link toolbar-btn"
onClick={@props.onClickLinkEditBtn}
data-command-name="link"></button>
{@_toolbarExtensions(@state.extensions)}
</div>
@props.buttonConfigs.map (config, i) ->
if (config.iconUrl ? "").length > 0
icon = <RetinaImg mode={RetinaImg.Mode.ContentIsMask}
url="#{toolbarItem.iconUrl}" />
else icon = ""
_toolbarExtensions: (extensions) ->
buttons = []
for extension in extensions
toolbarItem = extension.composerToolbar?()
if toolbarItem
buttons.push(
<button className="btn btn-extension"
onClick={ => @_extensionMutateDom(toolbarItem.mutator)}
title="#{toolbarItem.tooltip}"><RetinaImg mode={RetinaImg.Mode.ContentIsMask} url="#{toolbarItem.iconUrl}" /></button>)
return buttons
_onExtensionsChanged: =>
@setState extensions: ExtensionRegistry.Composer.extensions()
_extensionMutateDom: (mutator) =>
@props.onDomMutator(mutator)
<button className="btn toolbar-btn #{config.className ? ''}"
key={"btn-#{i}"}
onClick={config.onClick}
title="#{config.tooltip}">{icon}</button>
_renderLink: =>
removeBtn = ""
@ -196,11 +213,6 @@ class FloatingToolbar extends React.Component
@props.onSaveUrl @state.urlInputValue, @props.linkToModify
@props.onDoneWithLink()
_execCommand: (event) =>
cmd = event.currentTarget.getAttribute 'data-command-name'
document.execCommand(cmd, false, null)
true
_toolbarLeft: =>
CONTENT_PADDING = @props.contentPadding ? 15
max = @props.editAreaWidth - @_width() - CONTENT_PADDING

View file

@ -2,11 +2,11 @@ _str = require 'underscore.string'
{DOMUtils, ContenteditableExtension} = require 'nylas-exports'
class ListManager extends ContenteditableExtension
@onInput: (event, editableNode, selection) ->
@onContentChanged: (editableNode, selection) ->
if @_spaceEntered and @hasListStartSignature(selection)
@createList(event, selection)
@createList(null, selection)
@onKeyDown: (event, editableNode, selection) ->
@onKeyDown: (editableNode, selection, event) ->
@_spaceEntered = event.key is " "
if DOMUtils.isInList()
if event.key is "Backspace" and DOMUtils.atStartOfList()
@ -52,7 +52,7 @@ class ListManager extends ContenteditableExtension
return
el = DOMUtils.closest(selection.anchorNode, "li")
DOMUtils.Mutating.removeEmptyNodes(el)
event.preventDefault()
event?.preventDefault()
@removeListStarter: (starterRegex, selection) ->
el = DOMUtils.closest(selection.anchorNode, "li")

View file

@ -4,21 +4,21 @@ DOMUtils = require '../../dom-utils'
ComposerExtensionAdapter = (extension) ->
if extension.onInput?.length <= 2
if extension.onInput?
origInput = extension.onInput
extension.onInput = (event, editableNode, selection) ->
origInput(editableNode, event)
extension.onContentChanged = (editableNode, selection, mutations) ->
origInput(editableNode)
extension.onInput = deprecate(
"DraftStoreExtension.onInput",
"ComposerExtension.onInput",
"ComposerExtension.onContentChanged",
extension,
extension.onInput
extension.onContentChanged
)
if extension.onTabDown?
origKeyDown = extension.onKeyDown
extension.onKeyDown = (event, editableNode, selection) ->
extension.onKeyDown = (editableNode, selection, event) ->
if event.key is "Tab"
range = DOMUtils.getRangeInScope(editableNode)
extension.onTabDown(editableNode, range, event)
@ -34,7 +34,7 @@ ComposerExtensionAdapter = (extension) ->
if extension.onMouseUp?
origOnClick = extension.onClick
extension.onClick = (event, editableNode, selection) ->
extension.onClick = (editableNode, selection, event) ->
range = DOMUtils.getRangeInScope(editableNode)
extension.onMouseUp(editableNode, range, event)
origOnClick?(event, editableNode, selection)

View file

@ -1,47 +1,51 @@
ContenteditableExtension = require('./contenteditable-extension')
###
Public: To create ComposerExtensions that enhance the composer experience, you
should create objects that implement the interface defined at {ComposerExtension}.
Public: To create ComposerExtensions that enhance the composer experience,
you should create objects that implement the interface defined at
{ComposerExtension}.
{ComposerExtension} extends {ContenteditableExtension}, so you can also
implement the methods defined there to further enhance the composer
experience.
To register your extension with the ExtensionRegistry, call {ExtensionRegistry::Composer::register}.
When your package is being unloaded, you *must* call the corresponding
To register your extension with the ExtensionRegistry, call
{ExtensionRegistry::Composer::register}. When your package is being
unloaded, you *must* call the corresponding
{ExtensionRegistry::Composer::unregister} to unhook your extension.
```coffee
activate: ->
ExtensionRegistry.Composer.register(MyExtension)
```
coffee activate: -> ExtensionRegistry.Composer.register(MyExtension)
...
deactivate: ->
ExtensionRegistry.Composer.unregister(MyExtension)
deactivate: -> ExtensionRegistry.Composer.unregister(MyExtension)
```
Your ComposerExtension should be stateless. The user may have multiple drafts
open at any time, and the methods of your ComposerExtension may be called for different
drafts at any time. You should not expect that the session you receive in
{::finalizeSessionBeforeSending} is for the same draft you previously received in
{::warningsForSending}, etc.
**Your ComposerExtension should be stateless**. The user may have multiple
drafts open at any time, and the methods of your ComposerExtension may be
called for different drafts at any time. You should not expect that the
session you receive in {::finalizeSessionBeforeSending} is for the same
draft you previously received in {::warningsForSending}, etc.
The ComposerExtension API does not currently expose any asynchronous or {Promise}-based APIs.
This will likely change in the future. If you have a use-case for a ComposerExtension that
is not possible with the current API, please let us know.
The ComposerExtension API does not currently expose any asynchronous or
{Promise}-based APIs. This will likely change in the future. If you have
a use-case for a ComposerExtension that is not possible with the current
API, please let us know.
Section: Extensions
###
class ComposerExtension
class ComposerExtension extends ContenteditableExtension
###
Public: Inspect the draft, and return any warnings that need to be displayed before
the draft is sent. Warnings should be string phrases, such as "without an attachment"
that fit into a message of the form: "Send #{phase1} and #{phase2}?"
Public: Inspect the draft, and return any warnings that need to be
displayed before the draft is sent. Warnings should be string phrases,
such as "without an attachment" that fit into a message of the form:
"Send #{phase1} and #{phase2}?"
- `draft`: A fully populated {Message} object that is about to be sent.
Returns a list of warning strings, or an empty array if no warnings need to be displayed.
Returns a list of warning strings, or an empty array if no warnings need
to be displayed.
###
@warningsForSending: (draft) ->
[]
@ -53,42 +57,45 @@ class ComposerExtension
You must return an object that contains the following properties:
- `mutator`: A function that's called when your toolbar button is
clicked. This mutator function will be passed as its only argument the
`dom`. The `dom` is the full {DOM} object of the current composer. You
may mutate this in place. We don't care about the mutator's return
value.
clicked. The mutator will be passed: `(contenteditableDOM, selection,
event)`. It will be executed in a wrapped transaction block where it is
safe to mutate the DOM and the selection object.
- `className`: The button will already have the `btn` and `toolbar-btn`
classes.
- `tooltip`: A one or two word description of what your icon does
- `iconUrl`: The url of your icon. It should be in the `nylas://` scheme.
For example: `nylas://your-package-name/assets/my-icon@2x.png`. Note, we
will downsample your image by 2x (for Retina screens), so make sure it's
twice the resolution. The icon should be black and white. We will
directly pass the `url` prop of a {RetinaImg}
- `iconUrl`: The url of your icon. It should be in the `nylas://`
scheme. For example: `nylas://your-package-name/assets/my-icon@2x.png`.
Note, we will downsample your image by 2x (for Retina screens), so make
sure it's twice the resolution. The icon should be black and white. We
will directly pass the `url` prop of a {RetinaImg}
###
@composerToolbar: ->
return
###
Public: Override prepareNewDraft to modify a brand new draft before it is displayed
in a composer. This is one of the only places in the application where it's safe
to modify the draft object you're given directly to add participants to the draft,
add a signature, etc.
Public: Override prepareNewDraft to modify a brand new draft before it
is displayed in a composer. This is one of the only places in the
application where it's safe to modify the draft object you're given
directly to add participants to the draft, add a signature, etc.
By default, new drafts are considered `pristine`. If the user leaves the composer
without making any changes, the draft is discarded. If your extension populates
the draft in a way that makes it "populated" in a valuable way, you should set
`draft.pristine = false` so the draft saves, even if no further changes are made.
By default, new drafts are considered `pristine`. If the user leaves the
composer without making any changes, the draft is discarded. If your
extension populates the draft in a way that makes it "populated" in a
valuable way, you should set `draft.pristine = false` so the draft
saves, even if no further changes are made.
###
@prepareNewDraft: (draft) ->
return
###
Public: Override finalizeSessionBeforeSending in your ComposerExtension subclass to
transform the {DraftStoreProxy} editing session just before the draft is sent. This method
gives you an opportunity to make any final substitutions or changes after any
{::warningsForSending} have been displayed.
Public: Override finalizeSessionBeforeSending in your ComposerExtension
subclass to transform the {DraftStoreProxy} editing session just before
the draft is sent. This method gives you an opportunity to make any
final substitutions or changes after any {::warningsForSending} have
been displayed.
- `session`: A {DraftStoreProxy} for the draft.

View file

@ -21,36 +21,45 @@ render() {
}
```
If you specifically want to enhance the Composer experience you should register
a {ComposerExtension}
If you specifically want to enhance the Composer experience you should
register a {ComposerExtension}
Section: Extensions
###
class ContenteditableExtension
###
Public: Override onInput in your Contenteditable subclass to implement custom
behavior as the user types in the contenteditable's body field. You may mutate
the contenteditable in place, we do not expect any return value from this method.
Public: Override onContentChanged in your Contenteditable subclass to
implement custom behavior as the user types in the contenteditable's
body field. This method fires any time any DOM changes anywhere in the
Contenteditable component. It is wrapper over a native DOM
{MutationObserver}.
The onInput event can be triggered by a variety of events, some of which could
have been already been looked at by a callback. Almost any DOM mutation will
fire this event. Sometimes those mutations are the cause of other callbacks.
Callback params:
- editableNode: DOM node that represents the current
contenteditable. This object can be mutated in place to modify the
Contenteditable's content
- selection: {Selection} object that represents the current selection
on the contenteditable
- mutations: An array of DOM Mutations as returned by the
{MutationObserver}. Note that these may not always be populated
- event: DOM event fired on the contenteditable
- editableNode: DOM node that represents the current contenteditable.This object
can be mutated in place to modify the Contenteditable's content
- selection: [Selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection)
object that represents the current selection on the contenteditable
You may mutate the contenteditable in place, we do not expect any return
value from this method.
The onContentChanged event can be triggered by a variety of events, some
of which could have been already been looked at by a callback. Any DOM
mutation will fire this event. Sometimes those mutations are the cause
of other callbacks.
Example:
The Nylas `templates` package uses this method to see if the user has populated a
`<code>` tag placed in the body and change it's CSS class to reflect that it is no
longer empty.
The Nylas `templates` package uses this method to see if the user has
populated a `<code>` tag placed in the body and change it's CSS class to
reflect that it is no longer empty.
```coffee
onInput: (event, editableNode, selection) ->
onContentChanged: (editableNode, selection, mutations) ->
isWithinNode = (node) ->
test = selection.baseNode
while test isnt editableNode
@ -64,46 +73,46 @@ class ContenteditableExtension
codeTag.classList.remove('empty')
```
###
@onInput: (event, editableNode, selection) ->
@onContentChanged: (editableNode, selection, mutations) ->
###
Public: Override onBlur to mutate the contenteditable DOM node whenever the
onBlur event is fired on it. You may mutate the contenteditable in place, we
not expect any return value from this method.
- event: DOM event fired on the contenteditable
- editableNode: DOM node that represents the current contenteditable.This object
can be mutated in place to modify the Contenteditable's content
- selection: [Selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection)
object that represents the current selection on the contenteditable
- event: DOM event fired on the contenteditable
###
@onBlur: (event, editableNode, selection) ->
@onBlur: (editableNode, selection, event) ->
###
Public: Override onFocus to mutate the contenteditable DOM node whenever the
onFocus event is fired on it. You may mutate the contenteditable in place, we
not expect any return value from this method.
- event: DOM event fired on the contenteditable
- editableNode: DOM node that represents the current contenteditable.This object
can be mutated in place to modify the Contenteditable's content
- selection: [Selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection)
object that represents the current selection on the contenteditable
- event: DOM event fired on the contenteditable
###
@onFocus: (event, editableNode, selection) ->
@onFocus: (editableNode, selection, event) ->
###
Public: Override onClick to mutate the contenteditable DOM node whenever the
onClick event is fired on it. You may mutate the contenteditable in place, we
not expect any return value from this method.
- event: DOM event fired on the contenteditable
- editableNode: DOM node that represents the current contenteditable.This object
can be mutated in place to modify the Contenteditable's content
- selection: [Selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection)
object that represents the current selection on the contenteditable
- event: DOM event fired on the contenteditable
###
@onClick: (event, editableNode, selection) ->
@onClick: (editableNode, selection, event) ->
###
Public: Override onKeyDown to mutate the contenteditable DOM node whenever the
@ -119,20 +128,19 @@ class ContenteditableExtension
Important: You should prevent the default key down behavior with great care.
- event: DOM event fired on the contenteditable
- editableNode: DOM node that represents the current contenteditable.This object
can be mutated in place to modify the Contenteditable's content
- selection: [Selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection)
object that represents the current selection on the contenteditable
- event: DOM event fired on the contenteditable
###
@onKeyDown: (event, editableNode, selection) ->
@onKeyDown: (editableNode, selection, event) ->
###
Public: Override onInput to mutate the contenteditable DOM node whenever the
onInput event is fired on it.You may mutate the contenteditable in place, we
not expect any return value from this method.
- event: DOM event fired on the contenteditable
- editableNode: DOM node that represents the current contenteditable.This object
can be mutated in place to modify the Contenteditable's content
- selection: [Selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection)
@ -140,7 +148,8 @@ class ContenteditableExtension
- menu: [Menu](https://github.com/atom/electron/blob/master/docs/api/menu.md)
object you can mutate in order to add new [MenuItems](https://github.com/atom/electron/blob/master/docs/api/menu-item.md)
to the context menu that will be displayed when you right click the contenteditable.
- event: DOM event fired on the contenteditable
###
@onShowContextMenu: (event, editableNode, selection, menu) ->
@onShowContextMenu: (editableNode, selection, event, menu) ->
module.exports = ContenteditableExtension

View file

@ -239,7 +239,7 @@ class NylasEnvConstructor extends Model
@lastUncaughtError = Array::slice.call(arguments)
[message, url, line, column, originalError] = @lastUncaughtError
{line, column} = mapSourcePosition({source: url, line, column})
# {line, column} = mapSourcePosition({source: url, line, column})
eventObject = {message, url, line, column, originalError}