From f3d58aaede97c91e6735de94bffc5a80673219d8 Mon Sep 17 00:00:00 2001 From: Evan Morikawa Date: Wed, 25 Nov 2015 16:07:50 -0800 Subject: [PATCH] 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 --- .../lib/spellcheck-composer-extension.coffee | 2 +- .../composer/lib/composer-view.cjsx | 13 +- .../composer/spec/composer-view-spec.cjsx | 6 +- .../composer/spec/quoted-text-spec.cjsx | 2 +- .../contenteditable-component-spec.cjsx | 21 +- .../contenteditable/contenteditable.cjsx | 179 +++++++++--------- .../floating-toolbar-container.cjsx | 65 +++++-- .../contenteditable/floating-toolbar.cjsx | 98 +++++----- .../contenteditable/list-manager.coffee | 8 +- .../composer-extension-adapter.coffee | 14 +- src/flux/extensions/composer-extension.coffee | 95 +++++----- .../contenteditable-extension.coffee | 65 ++++--- src/nylas-env.coffee | 2 +- 13 files changed, 328 insertions(+), 242 deletions(-) diff --git a/internal_packages/composer-spellcheck/lib/spellcheck-composer-extension.coffee b/internal_packages/composer-spellcheck/lib/spellcheck-composer-extension.coffee index f7b9600cc..39d2fed06 100644 --- a/internal_packages/composer-spellcheck/lib/spellcheck-composer-extension.coffee +++ b/internal_packages/composer-spellcheck/lib/spellcheck-composer-extension.coffee @@ -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) diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx index 7e025a262..5d2859c23 100644 --- a/internal_packages/composer/lib/composer-view.cjsx +++ b/internal_packages/composer/lib/composer-view.cjsx @@ -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)) diff --git a/internal_packages/composer/spec/composer-view-spec.cjsx b/internal_packages/composer/spec/composer-view-spec.cjsx index 89086ec02..fd232343a 100644 --- a/internal_packages/composer/spec/composer-view-spec.cjsx +++ b/internal_packages/composer/spec/composer-view-spec.cjsx @@ -143,7 +143,7 @@ describe "populated composer", -> editableNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@composer, 'contentEditable')) spyOn(@proxy.changes, "add") editableNode.innerHTML = "Hello world" - 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 world" - 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 world#{@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 diff --git a/internal_packages/composer/spec/quoted-text-spec.cjsx b/internal_packages/composer/spec/quoted-text-spec.cjsx index 276b6e921..768f022ad 100644 --- a/internal_packages/composer/spec/quoted-text-spec.cjsx +++ b/internal_packages/composer/spec/quoted-text-spec.cjsx @@ -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", -> diff --git a/spec/components/contenteditable-component-spec.cjsx b/spec/components/contenteditable-component-spec.cjsx index b1c04fed2..6f92ff555 100644 --- a/spec/components/contenteditable-component-spec.cjsx +++ b/spec/components/contenteditable-component-spec.cjsx @@ -15,6 +15,7 @@ describe "Contenteditable", -> @component = ReactTestUtils.renderIntoDocument( ) + @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 New HTML') - expect(@onChange).toHaveBeenCalled() + runs => + @performEdit('Test New HTML') + 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 -> diff --git a/src/components/contenteditable/contenteditable.cjsx b/src/components/contenteditable/contenteditable.cjsx index e25b9fcae..66f93399a 100644 --- a/src/components/contenteditable/contenteditable.cjsx +++ b/src/components/contenteditable/contenteditable.cjsx @@ -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 + ref="toolbarController" + atomicEdit={@atomicEdit} + onSaveUrl={@_onSaveUrl} /> render: => _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 diff --git a/src/components/contenteditable/floating-toolbar-container.cjsx b/src/components/contenteditable/floating-toolbar-container.cjsx index e48c0da7c..0ae75b85c 100644 --- a/src/components/contenteditable/floating-toolbar-container.cjsx +++ b/src/components/contenteditable/floating-toolbar-container.cjsx @@ -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. diff --git a/src/components/contenteditable/floating-toolbar.cjsx b/src/components/contenteditable/floating-toolbar.cjsx index e69782bea..95f21b2da 100644 --- a/src/components/contenteditable/floating-toolbar.cjsx +++ b/src/components/contenteditable/floating-toolbar.cjsx @@ -9,31 +9,71 @@ class FloatingToolbar extends React.Component @displayName = "FloatingToolbar" @propTypes: + # Absolute position in px relative to parent top: React.PropTypes.number + + # Absolute position in px relative to parent 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
_renderButtons: => -
- - - - - {@_toolbarExtensions(@state.extensions)} -
+ @props.buttonConfigs.map (config, i) -> + if (config.iconUrl ? "").length > 0 + icon = + else icon = "" - _toolbarExtensions: (extensions) -> - buttons = [] - for extension in extensions - toolbarItem = extension.composerToolbar?() - if toolbarItem - buttons.push( - ) - return buttons - - _onExtensionsChanged: => - @setState extensions: ExtensionRegistry.Composer.extensions() - - _extensionMutateDom: (mutator) => - @props.onDomMutator(mutator) + _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 diff --git a/src/components/contenteditable/list-manager.coffee b/src/components/contenteditable/list-manager.coffee index 45c1915d5..bbeaba6b1 100644 --- a/src/components/contenteditable/list-manager.coffee +++ b/src/components/contenteditable/list-manager.coffee @@ -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") diff --git a/src/flux/extensions/composer-extension-adapter.coffee b/src/flux/extensions/composer-extension-adapter.coffee index 7a0f48599..e83ad006a 100644 --- a/src/flux/extensions/composer-extension-adapter.coffee +++ b/src/flux/extensions/composer-extension-adapter.coffee @@ -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) diff --git a/src/flux/extensions/composer-extension.coffee b/src/flux/extensions/composer-extension.coffee index e716fe486..8f34d0411 100644 --- a/src/flux/extensions/composer-extension.coffee +++ b/src/flux/extensions/composer-extension.coffee @@ -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. diff --git a/src/flux/extensions/contenteditable-extension.coffee b/src/flux/extensions/contenteditable-extension.coffee index 49788fcb6..6a20b14da 100644 --- a/src/flux/extensions/contenteditable-extension.coffee +++ b/src/flux/extensions/contenteditable-extension.coffee @@ -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 - `` 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 `` 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 diff --git a/src/nylas-env.coffee b/src/nylas-env.coffee index 3c15380d6..c0a9a8924 100644 --- a/src/nylas-env.coffee +++ b/src/nylas-env.coffee @@ -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}