mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-01-01 13:14:16 +08:00
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:
parent
b4dda021b1
commit
f3d58aaede
13 changed files with 328 additions and 242 deletions
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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", ->
|
||||
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
||||
|
|
Loading…
Reference in a new issue