diff --git a/internal_packages/composer-spellcheck/lib/draft-extension.coffee b/internal_packages/composer-spellcheck/lib/draft-extension.coffee index d74378973..ba7a10899 100644 --- a/internal_packages/composer-spellcheck/lib/draft-extension.coffee +++ b/internal_packages/composer-spellcheck/lib/draft-extension.coffee @@ -1,19 +1,47 @@ -{DraftStoreExtension, AccountStore} = require 'nylas-exports' +{DraftStoreExtension, AccountStore, DOMUtils} = require 'nylas-exports' _ = require 'underscore' +spellchecker = require('spellchecker') +remote = require('remote') +MenuItem = remote.require('menu-item') SpellcheckCache = {} class SpellcheckDraftStoreExtension extends DraftStoreExtension @isMisspelled: (word) -> - @spellchecker ?= require('spellchecker') - SpellcheckCache[word] ?= @spellchecker.isMisspelled(word) + SpellcheckCache[word] ?= spellchecker.isMisspelled(word) SpellcheckCache[word] - @onComponentDidUpdate: (editableNode) -> + @onInput: (editableNode) -> @walkTree(editableNode) - @onLearnSpelling: (editableNode, word) -> + @onShowContextMenu: (event, editableNode, selection, innerStateProxy, menu) => + range = DOMUtils.Mutating.getRangeAtAndSelectWord(selection, 0) + word = range.toString() + if @isMisspelled(word) + corrections = spellchecker.getCorrectionsForMisspelling(word) + if corrections.length > 0 + corrections.forEach (correction) => + menu.append(new MenuItem({ + label: correction, + click: @applyCorrection.bind(@, editableNode, range, selection, correction) + })) + else + menu.append(new MenuItem({ label: 'No Guesses Found', enabled: false})) + + menu.append(new MenuItem({ type: 'separator' })) + menu.append(new MenuItem({ + label: 'Learn Spelling', + click: @learnSpelling.bind(@, editableNode, word) + })) + menu.append(new MenuItem({ type: 'separator' })) + + @applyCorrection: (editableNode, range, selection, correction) => + DOMUtils.Mutating.applyTextInRange(range, selection, correction) + @walkTree(editableNode) + + @learnSpelling: (editableNode, word) => + spellchecker.add(word) delete SpellcheckCache[word] @walkTree(editableNode) diff --git a/internal_packages/composer/lib/composer-extensions-plugin.coffee b/internal_packages/composer/lib/composer-extensions-plugin.coffee new file mode 100644 index 000000000..468800538 --- /dev/null +++ b/internal_packages/composer/lib/composer-extensions-plugin.coffee @@ -0,0 +1,27 @@ +{DraftStore, DOMUtils, ContenteditablePlugin} = require 'nylas-exports' + +class ComposerExtensionsPlugin extends ContenteditablePlugin + @onInput: (event, editableNode, selection, innerStateProxy) -> + for extension in DraftStore.extensions() + extension.onInput?(editableNode, event) + + @onKeyDown: (event, editableNode, selection, innerStateProxy) -> + if event.key is "Tab" + range = DOMUtils.getRangeInScope(editableNode) + for extension in DraftStore.extensions() + extension.onTabDown?(editableNode, range, event) + + @onShowContextMenu: (args...) -> + for extension in DraftStore.extensions() + extension.onShowContextMenu?(args...) + + @onClick: (event, editableNode, selection, innerStateProxy) -> + range = DOMUtils.getRangeInScope(editableNode) + return unless range + try + for extension in DraftStore.extensions() + extension.onMouseUp?(editableNode, range, event) + catch e + console.error('DraftStore extension raised an error: '+e.toString()) + +module.exports = ComposerExtensionsPlugin diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx index 31e3c1a54..84f01b5bb 100644 --- a/internal_packages/composer/lib/composer-view.cjsx +++ b/internal_packages/composer/lib/composer-view.cjsx @@ -29,6 +29,8 @@ CollapsedParticipants = require './collapsed-participants' Fields = require './fields' +ComposerExtensionsPlugin = require './composer-extensions-plugin' + # The ComposerView is a unique React component because it (currently) is a # singleton. Normally, the React way to do things would be to re-render the # Composer with new props. @@ -78,7 +80,7 @@ class ComposerView extends React.Component @_usubs = [] @_usubs.push FileUploadStore.listen @_onFileUploadStoreChange @_usubs.push AccountStore.listen @_onAccountStoreChanged - @_applyFocusedField() + @_applyFieldFocus() componentWillUnmount: => @_unmounted = true # rarf @@ -94,7 +96,7 @@ class ComposerView extends React.Component # re-rendering. @_recoveredSelection = null if @_recoveredSelection? - @_applyFocusedField() + @_applyFieldFocus() _keymapHandlers: -> 'composer:send-message': => @_sendDraft() @@ -109,14 +111,18 @@ class ComposerView extends React.Component "composer:undo": @undo "composer:redo": @redo - _applyFocusedField: -> - if @state.focusedField + _applyFieldFocus: -> + if @state.focusedField and @_lastFocusedField isnt @state.focusedField + @_lastFocusedField = @state.focusedField return unless @refs[@state.focusedField] if @refs[@state.focusedField].focus @refs[@state.focusedField].focus() else React.findDOMNode(@refs[@state.focusedField]).focus() + if @state.focusedField is Fields.Body + @refs[Fields.Body].selectEnd() + componentWillReceiveProps: (newProps) => @_ignoreNextTrigger = false if newProps.draftClientId isnt @props.draftClientId @@ -223,7 +229,10 @@ class ComposerView extends React.Component {@_renderSubject()} -
+
{@_renderBody()} {@_renderFooterRegions()}
@@ -291,39 +300,10 @@ class ComposerView extends React.Component onScrollTo={@props.onRequestScrollTo} onFilePaste={@_onFilePaste} onScrollToBottom={@_onScrollToBottom()} - lifecycleCallbacks={@_contenteditableLifecycleCallbacks()} + plugins={[ComposerExtensionsPlugin]} getComposerBoundingRect={@_getComposerBoundingRect} initialSelectionSnapshot={@_recoveredSelection} /> - _contenteditableLifecycleCallbacks: -> - componentDidUpdate: (editableNode) => - for extension in DraftStore.extensions() - extension.onComponentDidUpdate?(editableNode) - - onInput: (editableNode, event) => - for extension in DraftStore.extensions() - extension.onInput?(editableNode, event) - - onTabDown: (editableNode, event, range) => - for extension in DraftStore.extensions() - extension.onTabDown?(editableNode, range, event) - - onSubstitutionPerformed: (editableNode) => - for extension in DraftStore.extensions() - extension.onSubstitutionPerformed?(editableNode) - - onLearnSpelling: (editableNode, text) => - for extension in DraftStore.extensions() - extension.onLearnSpelling?(editableNode, text) - - onMouseUp: (editableNode, event, range) => - return unless range - try - for extension in DraftStore.extensions() - extension.onMouseUp?(editableNode, range, event) - catch e - console.error('DraftStore extension raised an error: '+e.toString()) - # The contenteditable decides when to request a scroll based on the # position of the cursor and its relative distance to this composer # component. We provide it our boundingClientRect so it can calculate @@ -472,8 +452,23 @@ class ComposerView extends React.Component # This lets us click outside of the `contenteditable`'s `contentBody` # and simulate what happens when you click beneath the text *in* the # contentEditable. - _onClickComposeBody: (event) => - @refs[Fields.Body].selectEnd() + # + # Unfortunately, we need to manually keep track of the "click" in + # separate mouseDown, mouseUp events because we need to ensure that the + # start and end target are both not in the contenteditable. This ensures + # that this behavior doesn't interfear with a click and drag selection. + _onMouseDownComposerBody: (event) => + if React.findDOMNode(@refs[Fields.Body]).contains(event.target) + @_mouseDownTarget = null + else @_mouseDownTarget = event.target + + _onMouseUpComposerBody: (event) => + if event.target is @_mouseDownTarget + @refs[Fields.Body].selectEnd() + @_mouseDownTarget = null + + _onMouseMoveComposeBody: (event) => + if @_mouseComposeBody is "down" then @_mouseComposeBody = "move" _onDraftChanged: => return if @_ignoreNextTrigger diff --git a/spec/components/contenteditable/automatic-list-manager-spec.coffee b/spec/components/contenteditable/automatic-list-manager-spec.coffee new file mode 100644 index 000000000..13dc1310f --- /dev/null +++ b/spec/components/contenteditable/automatic-list-manager-spec.coffee @@ -0,0 +1,105 @@ + +xdescribe "ListManager", -> + beforeEach -> + @ce = new ContenteditableTestHarness + + it "Creates ordered lists", -> + @ce.type ['1', '.', ' '] + @ce.expectHTML "
" + @ce.expectSelection (dom) -> + dom.querySelectorAll("li")[0] + + it "Undoes ordered list creation with backspace", -> + @ce.type ['1', '.', ' ', 'backspace'] + @ce.expectHTML "1. " + @ce.expectSelection (dom) -> + node: dom.childNodes[0] + offset: 3 + + it "Creates unordered lists with star", -> + @ce.type ['*', ' '] + @ce.expectHTML "" + @ce.expectSelection (dom) -> + dom.querySelectorAll("li")[0] + + it "Undoes unordered list creation with backspace", -> + @ce.type ['*', ' ', 'backspace'] + @ce.expectHTML "* " + @ce.expectSelection (dom) -> + node: dom.childNodes[0] + offset: 2 + + it "Creates unordered lists with dash", -> + @ce.type ['-', ' '] + @ce.expectHTML "" + @ce.expectSelection (dom) -> + dom.querySelectorAll("li")[0] + + it "Undoes unordered list creation with backspace", -> + @ce.type ['-', ' ', 'backspace'] + @ce.expectHTML "- " + @ce.expectSelection (dom) -> + node: dom.childNodes[0] + offset: 2 + + it "create a single item then delete it with backspace", -> + @ce.type ['-', ' ', 'a', 'left', 'backspace'] + @ce.expectHTML "a" + @ce.expectSelection (dom) -> + node: dom.childNodes[0] + offset: 0 + + it "create a single item then delete it with tab", -> + @ce.type ['-', ' ', 'a', 'shift-tab'] + @ce.expectHTML "a" + @ce.expectSelection (dom) -> dom.childNodes[0] + node: dom.childNodes[0] + offset: 1 + + describe "when creating two items in a list", -> + beforeEach -> + @twoItemKeys = ['-', ' ', 'a', 'enter', 'b'] + + it "creates two items with enter at end", -> + @ce.type @twoItemKeys + @ce.expectHTML "" + @ce.expectSelection (dom) -> + node: dom.querySelectorAll('li')[1].childNodes[0] + offset: 1 + + it "backspace from the start of the 1st item outdents", -> + @ce.type @twoItemKeys.concat ['left', 'up', 'backspace'] + + it "backspace from the start of the 2nd item outdents", -> + @ce.type @twoItemKeys.concat ['left', 'backspace'] + + it "shift-tab from the start of the 1st item outdents", -> + @ce.type @twoItemKeys.concat ['left', 'up', 'shift-tab'] + + it "shift-tab from the start of the 2nd item outdents", -> + @ce.type @twoItemKeys.concat ['left', 'shift-tab'] + + it "shift-tab from the end of the 1st item outdents", -> + @ce.type @twoItemKeys.concat ['up', 'shift-tab'] + + it "shift-tab from the end of the 2nd item outdents", -> + @ce.type @twoItemKeys.concat ['shift-tab'] + + it "backspace from the end of the 1st item doesn't outdent", -> + @ce.type @twoItemKeys.concat ['up', 'backspace'] + + it "backspace from the end of the 2nd item doesn't outdent", -> + @ce.type @twoItemKeys.concat ['backspace'] + + describe "multi-depth bullets", -> + it "creates multi level bullet when tabbed in", -> + @ce.type ['-', ' ', 'a', 'tab'] + + it "creates multi level bullet when tabbed in", -> + @ce.type ['-', ' ', 'tab', 'a'] + + it "returns to single level bullet on backspace", -> + @ce.type ['-', ' ', 'a', 'tab', 'left', 'backspace'] + + it "returns to single level bullet on shift-tab", -> + @ce.type ['-', ' ', 'a', 'tab', 'shift-tab'] diff --git a/src/components/contenteditable/contenteditable-plugin.coffee b/src/components/contenteditable/contenteditable-plugin.coffee new file mode 100644 index 000000000..bc3cb85e3 --- /dev/null +++ b/src/components/contenteditable/contenteditable-plugin.coffee @@ -0,0 +1,26 @@ +### +ContenteditablePlugin is an abstract base class. Implementations of this +are used to make additional changes to a component +beyond a user's input intents. + +While some ContenteditablePlugins are included with the core + component, others may be added via the `plugins` +prop. +### +class ContenteditablePlugin + + # The onInput event can be triggered by a variety of events, some of + # which could have been already been looked at by a callback. + # Pretty much any DOM mutation will fire this. + # Sometimes those mutations are the cause of callbacks. + @onInput: (event, editableNode, selection, innerStateProxy) -> + + @onBlur: (event, editableNode, selection, innerStateProxy) -> + + @onFocus: (event, editableNode, selection, innerStateProxy) -> + + @onClick: (event, editableNode, selection, innerStateProxy) -> + + @onKeyDown: (event, editableNode, selection, innerStateProxy) -> + + @onShowContextMenu: (event, editableNode, selection, innerStateProxy, menu) -> diff --git a/src/components/contenteditable/contenteditable.cjsx b/src/components/contenteditable/contenteditable.cjsx index d7dc047ec..eb5d40c56 100644 --- a/src/components/contenteditable/contenteditable.cjsx +++ b/src/components/contenteditable/contenteditable.cjsx @@ -5,6 +5,8 @@ React = require 'react' ClipboardService = require './clipboard-service' FloatingToolbarContainer = require './floating-toolbar-container' +ListManager = require './list-manager' + ### Public: A modern, well-behaved, React-compatible contenteditable @@ -40,25 +42,37 @@ class Contenteditable extends React.Component onScrollTo: React.PropTypes.func onScrollToBottom: React.PropTypes.func - # A series of callbacks that can get executed at various points along - # the contenteditable. - lifecycleCallbacks: React.PropTypes.object + # A list of objects that extend {ContenteditablePlugin} + plugins: React.PropTypes.array spellcheck: React.PropTypes.bool floatingToolbar: React.PropTypes.bool @defaultProps: + plugins: [] spellcheck: true floatingToolbar: true - lifecycleCallbacks: - componentDidUpdate: (editableNode) -> - onInput: (editableNode, event) -> - onTabDown: (editableNode, event, range) -> - onLearnSpelling: (editableNode, text) -> - onSubstitutionPerformed: (editableNode) -> - onMouseUp: (editableNode, event, range) -> + corePlugins: [ListManager] + + # We allow extensions to read, and mutate the: + # + # 1. DOM of the contenteditable + # 2. The Selection + # 3. The innerState of the component + # 4. The context menu (onShowContextMenu) + # + # We treat mutations as a single atomic change (even if multiple actual + # mutations happened). + atomicEdit: (editingFunction, event, extraArgs...) -> + @_teardownSelectionListeners() + innerStateProxy = + get: => return @innerState + set: (newInnerState) => @setInnerState(newInnerState) + args = [event, @_editableNode(), document.getSelection(), innerStateProxy, extraArgs...] + editingFunction.apply(null, args) + @_setupSelectionListeners() constructor: (@props) -> @innerState = {} @@ -73,7 +87,7 @@ class Contenteditable extends React.Component @refs["toolbarController"]?.componentWillReceiveInnerProps(innerState) componentDidMount: => - @_editableNode().addEventListener('contextmenu', @_onShowContextualMenu) + @_editableNode().addEventListener('contextmenu', @_onShowContextMenu) @_setupSelectionListeners() @_setupGlobalMouseListener() @_cleanHTML() @@ -88,7 +102,7 @@ class Contenteditable extends React.Component not Utils.isEqualReact(nextState, @state)) componentWillUnmount: => - @_editableNode().removeEventListener('contextmenu', @_onShowContextualMenu) + @_editableNode().removeEventListener('contextmenu', @_onShowContextMenu) @_teardownSelectionListeners() @_teardownGlobalMouseListener() @@ -99,13 +113,9 @@ class Contenteditable extends React.Component componentDidUpdate: => @_cleanHTML() - @_restoreSelection() editableNode = @_editableNode() - - @props.lifecycleCallbacks.componentDidUpdate(editableNode) - @setInnerState links: editableNode.querySelectorAll("*[href]") editableNode: editableNode @@ -191,164 +201,88 @@ class Contenteditable extends React.Component @_setupSelectionListeners() @_onInput() + # Will execute the event handlers on each of the registerd and core plugins + # In this context, event.preventDefault and event.stopPropagation don't refer + # to stopping default DOM behavior or prevent event bubbling through the DOM, + # but rather prevent our own Contenteditable default behavior, and preventing + # other plugins from being called. + # If any of the plugins calls event.preventDefault() it will prevent the + # default behavior for the Contenteditable, which basically means preventing + # the core plugin handlers from being called. + # If any of the plugins calls event.stopPropagation(), it will prevent any + # other plugin handlers from being called. + _runPluginHandlersForEvent: (method, event, args...) => + executeCallback = (plugin) => + return if not plugin[method]? + callback = callback.bind(plugin) + @atomicEdit(callback, event, args...) + + for plugin in @props.plugins + break if event.isPropagationStopped() + executeCallback(plugin) + + return if event.defaultPrevented or event.isPropagationStopped() + for plugin in @corePlugins + break if event.isPropagationStopped() + executeCallback(plugin) + _onKeyDown: (event) => + @_runPluginHandlersForEvent("onKeyDown", event) + + # This is a special case where we don't want to bubble up the event to the + # keymap manager if the plugin prevented the default behavior + if event.defaultPrevented + event.stopPropagation() + return + if event.key is "Tab" - @_onTabDown(event) - if event.key is "Backspace" - @_onBackspaceDown(event) + @_onTabDownDefaultBehavior(event) + U = 85 if event.which is U and (event.metaKey or event.ctrlKey) event.preventDefault() document.execCommand("underline") return + # Every time the contents of the contenteditable DOM node change, the + # `onInput` event gets fired. + # + # If we are in the middle of an `atomic` change transaction, we ignore + # those changes. + # + # At all other times we take the change, apply various filters to the + # new content, then notify our parent that the content has been updated. _onInput: (event) => return if @_ignoreInputChanges @_ignoreInputChanges = true - @_resetInnerStateOnInput() - @_runCoreFilters() - - @props.lifecycleCallbacks.onInput(@_editableNode(), event) + @_runPluginHandlersForEvent("onInput", event) @_normalize() @_saveSelectionState() - @_saveNewHtml() + @_notifyParentOfChange() @_ignoreInputChanges = false return _resetInnerStateOnInput: -> - @_justCreatedList = false + @_autoCreatedListFromText = false @setInnerState dragging: false if @innerState.dragging @setInnerState doubleDown: false if @innerState.doubleDown - _runCoreFilters: -> - @_createLists() - - _saveNewHtml: -> + _notifyParentOfChange: -> @props.onChange(target: {value: @_editableNode().innerHTML}) - # Determines if the user wants to add an ordered or unordered list. - _createLists: -> - # The `execCommand` will update the DOM and move the cursor. Since - # this is happening in the middle of an `_onInput` callback, we want - # the whole operation to look "atomic". As such we'll do any necessary - # DOM cleanup and fire the `exec` command with the listeners off, then - # re-enable at the end. - if @_resetListToText - @_resetListToText = false - return - - updateDOM = (command) => - @_teardownSelectionListeners() - document.execCommand(command) - selection = document.getSelection() - selection.anchorNode.parentElement.innerHTML = "" - @_setupSelectionListeners() - - text = @_textContentAtCursor() - if (/^\d\.\s$/).test text - @_justCreatedList = text - updateDOM("insertOrderedList") - else if (/^[*-]\s$/).test text - @_justCreatedList = text - updateDOM("insertUnorderedList") - - _onBackspaceDown: (event) -> - if document.getSelection()?.isCollapsed - if @_atStartOfList() - li = @_closestAtCursor("li") - list = @_closestAtCursor("ul, ol") - return unless li and list - event.preventDefault() - if list.querySelectorAll('li')?[0] is li # We're in first li - if @_justCreatedList - @_resetListToText = true - @_replaceFirstListItem(li, @_justCreatedList) - else - @_replaceFirstListItem(li, "") - else - document.execCommand("outdent") - - # The native document.execCommand('outdent') - _outdent: -> - - _closestAtCursor: (selector) -> - selection = document.getSelection() - return unless selection?.isCollapsed - return @_closest(selection.anchorNode, selector) - - # https://developer.mozilla.org/en-US/docs/Web/API/Element/closest - # Only Elements (not Text nodes) have the `closest` method - _closest: (node, selector) -> - el = if node instanceof HTMLElement then node else node.parentElement - return el.closest(selector) - - _replaceFirstListItem: (li, replaceWith) -> - @_teardownSelectionListeners() - list = @_closest(li, "ul, ol") - - if replaceWith.length is 0 - replaceWith = replaceWith.replace /\s/g, " " - text = document.createElement("div") - text.innerHTML = "
" - else - replaceWith = replaceWith.replace /\s/g, " " - text = document.createElement("span") - text.innerHTML = "#{replaceWith}" - - if list.querySelectorAll('li').length <= 1 - # Delete the whole list and replace with text - list.parentNode.replaceChild(text, list) - else - # Delete the list item and prepend the text before the rest of the - # list - li.parentNode.removeChild(li) - list.parentNode.insertBefore(text, list) - - child = text.childNodes[0] ? text - index = Math.max(replaceWith.length - 1, 0) - selection = document.getSelection() - selection.setBaseAndExtent(child, index, child, index) - - @_setupSelectionListeners() - @_onInput() - - _onTabDown: (event) -> - editableNode = @_editableNode() - range = DOMUtils.getRangeInScope(editableNode) - - @props.lifecycleCallbacks.onTabDown(editableNode, event, range) - - return if event.defaultPrevented - @_onTabDownDefaultBehavior(event) - _onTabDownDefaultBehavior: (event) -> - event.preventDefault() - selection = document.getSelection() if selection?.isCollapsed - # Only Elements (not Text nodes) have the `closest` method - li = @_closestAtCursor("li") - if li - if event.shiftKey - list = @_closestAtCursor("ul, ol") - # BUG: As of 9/25/15 if you outdent the first item in a list, it - # doesn't work :( - if list.querySelectorAll('li')?[0] is li # We're in first li - @_replaceFirstListItem(li, li.innerHTML) - else - document.execCommand("outdent") - else - document.execCommand("indent") - else if event.shiftKey - if @_atTabChar() - @_removeLastCharacter() - else if @_atBeginning() + if event.shiftKey + if DOMUtils.isAtTabChar(selection) + @_removeLastCharacter(selection) + else if DOMUtils.isAtBeginningOfDocument(@_editableNode(), selection) return # Don't stop propagation else document.execCommand("insertText", false, "\t") @@ -357,41 +291,11 @@ class Contenteditable extends React.Component document.execCommand("insertText", false, "") else document.execCommand("insertText", false, "\t") + event.preventDefault() event.stopPropagation() - _selectionInText: (selection) -> - return false unless selection - return selection.isCollapsed and selection.anchorNode.nodeType is Node.TEXT_NODE and selection.anchorOffset > 0 - - _atTabChar: -> - selection = document.getSelection() - if @_selectionInText(selection) - return selection.anchorNode.textContent[selection.anchorOffset - 1] is "\t" - else return false - - _atStartOfList: -> - selection = document.getSelection() - anchor = selection.anchorNode - return false if not selection.isCollapsed - return true if anchor?.nodeName is "LI" - return false if selection.anchorOffset > 0 - li = @_closest(anchor, "li") - return unless li - return DOMUtils.isFirstChild(li, anchor) - - _atBeginning: -> - selection = document.getSelection() - return false if not selection.isCollapsed - return false if selection.anchorOffset > 0 - el = @_editableNode() - return true if el.childNodes.length is 0 - return true if selection.anchorNode is el - firstChild = el.childNodes[0] - return selection.anchorNode is firstChild - - _removeLastCharacter: -> - selection = document.getSelection() - if @_selectionInText(selection) + _removeLastCharacter: (selection) -> + if DOMUtils.isSelectionInTextNode(selection) node = selection.anchorNode offset = selection.anchorOffset @_teardownSelectionListeners() @@ -399,12 +303,6 @@ class Contenteditable extends React.Component document.execCommand("delete") @_setupSelectionListeners() - _textContentAtCursor: -> - selection = document.getSelection() - if selection.isCollapsed - return selection.anchorNode?.textContent - else return null - # This component works by re-rendering on every change and restoring the # selection. This is also how standard React controlled inputs work too. # @@ -468,7 +366,7 @@ class Contenteditable extends React.Component els = @_editableNode().querySelectorAll('ul') # This mutates the DOM in place. - DOMUtils.collapseAdjacentElements(els) + DOMUtils.Mutating.collapseAdjacentElements(els) # After an input, the selection can sometimes get itself into a state # that either can't be restored properly, or will cause undersirable @@ -665,7 +563,7 @@ class Contenteditable extends React.Component rect = rangeInScope.getBoundingClientRect() if DOMUtils.isEmptyBoudingRect(rect) - rect = @_getSelectionRectFromDOM(selection) + rect = DOMUtils.getSelectionRectFromDOM(selection) if rect @props.onScrollTo({rect}) @@ -692,17 +590,6 @@ class Contenteditable extends React.Component selfRect = @_editableNode().getBoundingClientRect() return Math.abs(parentRect.bottom - selfRect.bottom) <= 250 - _getSelectionRectFromDOM: (selection) -> - node = selection.anchorNode - if node.nodeType is Node.TEXT_NODE - r = document.createRange() - r.selectNodeContents(node) - return r.getBoundingClientRect() - else if node.nodeType is Node.ELEMENT_NODE - return node.getBoundingClientRect() - else - return null - # We use global listeners to determine whether or not dragging is # happening. This is because dragging may stop outside the scope of # this element. Note that the `dragstart` and `dragend` events don't @@ -718,19 +605,12 @@ class Contenteditable extends React.Component window.removeEventListener("mousedown", @__onMouseDown) window.removeEventListener("mouseup", @__onMouseUp) - _onShowContextualMenu: (event) => + _onShowContextMenu: (event) => @refs["toolbarController"]?.forceClose() event.preventDefault() selection = document.getSelection() - range = selection.getRangeAt(0) - - # On Windows, right-clicking a word does not select it at the OS-level. - # We need to implement this behavior locally for the rest of the logic here. - if range.collapsed - DOMUtils.selectWordContainingRange(range) - range = selection.getRangeAt(0) - + range = DOMUtils.Mutating.getRangeAtAndSelectWord(selection, 0) text = range.toString() remote = require('remote') @@ -738,48 +618,22 @@ class Contenteditable extends React.Component Menu = remote.require('menu') MenuItem = remote.require('menu-item') - apply = (newtext) => - range.deleteContents() - node = document.createTextNode(newtext) - range.insertNode(node) - range.selectNode(node) - selection.removeAllRanges() - selection.addRange(range) - @props.lifecycleCallbacks.onSubstitutionPerformed(@_editableNode()) - cut = => clipboard.writeText(text) - apply('') + DOMUtils.Mutating.applyTextInRange(range, selection, '') copy = => clipboard.writeText(text) paste = => - apply(clipboard.readText()) + DOMUtils.Mutating.applyTextInRange(range, selection, clipboard.readText()) menu = new Menu() - ## TODO, move into spellcheck package - if @props.spellcheck - spellchecker = require('spellchecker') - learnSpelling = => - spellchecker.add(text) - @props.lifecycleCallbacks.onLearnSpelling(@_editableNode(), text) - if spellchecker.isMisspelled(text) - corrections = spellchecker.getCorrectionsForMisspelling(text) - if corrections.length > 0 - corrections.forEach (correction) -> - menu.append(new MenuItem({ label: correction, click:( -> apply(correction))})) - else - menu.append(new MenuItem({ label: 'No Guesses Found', enabled: false})) - - menu.append(new MenuItem({ type: 'separator' })) - menu.append(new MenuItem({ label: 'Learn Spelling', click: learnSpelling})) - menu.append(new MenuItem({ type: 'separator' })) - - menu.append(new MenuItem({ label: 'Cut', click:cut})) - menu.append(new MenuItem({ label: 'Copy', click:copy})) - menu.append(new MenuItem({ label: 'Paste', click:paste})) + @_runPluginHandlersForEvent("onShowContextMenu", event, menu) + menu.append(new MenuItem({ label: 'Cut', click: cut})) + menu.append(new MenuItem({ label: 'Copy', click: copy})) + menu.append(new MenuItem({ label: 'Paste', click: paste})) menu.popup(remote.getCurrentWindow()) _onMouseDown: (event) => @@ -820,10 +674,7 @@ class Contenteditable extends React.Component selection = document.getSelection() return event unless DOMUtils.selectionInScope(selection, editableNode) - range = DOMUtils.getRangeInScope(editableNode) - - @props.lifecycleCallbacks.onMouseUp(editableNode, event, range) - + @_runPluginHandlersForEvent("onClick", event) return event _onDragStart: (event) => @@ -883,9 +734,6 @@ class Contenteditable extends React.Component @_ensureSelectionVisible(selection) @_setupSelectionListeners() - _getNodeIndex: (nodeToFind) => - DOMUtils.findSimilarNodes(@_editableNode(), nodeToFind).indexOf nodeToFind - # This needs to be in the contenteditable area because we need to first # restore the selection before calling the `execCommand` # diff --git a/src/components/contenteditable/list-manager.coffee b/src/components/contenteditable/list-manager.coffee new file mode 100644 index 000000000..c37133fcf --- /dev/null +++ b/src/components/contenteditable/list-manager.coffee @@ -0,0 +1,100 @@ +_str = require 'underscore.string' +{DOMUtils} = require 'nylas-exports' +ContenteditablePlugin = require './contenteditable-plugin' + +class ListManager extends ContenteditablePlugin + @onInput: (event, editableNode, selection) -> + if @_spaceEntered and @hasListStartSignature(selection) + @createList(event, selection) + + @onKeyDown: (event, editableNode, selection) -> + @_spaceEntered = event.key is " " + if DOMUtils.isInList() + if event.key is "Backspace" and DOMUtils.atStartOfList() + event.preventDefault() + @outdentListItem(selection) + else if event.key is "Tab" and selection.isCollapsed + event.preventDefault() + if event.shiftKey + @outdentListItem(selection) + else + document.execCommand("indent") + else + # Do nothing, let the event through. + @originalInput = null + else + @originalInput = null + + return event + + @bulletRegex: -> /^[*-]\s/ + + @numberRegex: -> /^\d\.\s/ + + @hasListStartSignature: (selection) -> + return false unless selection?.anchorNode + return false if not selection.isCollapsed + + text = selection.anchorNode.textContent + return @numberRegex().test(text) or @bulletRegex().test(text) + + @createList: (event, selection) -> + text = selection.anchorNode?.textContent + + if @numberRegex().test(text) + @originalInput = text[0...3] + document.execCommand("insertOrderedList") + @removeListStarter(@numberRegex(), selection) + else if @bulletRegex().test(text) + @originalInput = text[0...2] + document.execCommand("insertUnorderedList") + @removeListStarter(@bulletRegex(), selection) + else + return + el = DOMUtils.closest(selection.anchorNode, "li") + DOMUtils.Mutating.removeEmptyNodes(el) + event.preventDefault() + + @removeListStarter: (starterRegex, selection) -> + el = DOMUtils.closest(selection.anchorNode, "li") + textContent = el.textContent.replace(starterRegex, "") + if textContent.trim().length is 0 + el.innerHTML = "
" + else + textNode = DOMUtils.findFirstTextNode(el) + textNode.textContent = textNode.textContent.replace(starterRegex, "") + + # From a newly-created list + # Outdent returns to a

structure + # I need to turn into
+ # + # From a list with content + # Outent returns to
sometext
+ # We need to turn that into
- sometext
+ @restoreOriginalInput: (selection) -> + node = selection.anchorNode + return unless node + if node.nodeType is Node.TEXT_NODE + node.textContent = @originalInput + node.textContent + else if node.nodeType is Node.ELEMENT_NODE + textNode = DOMUtils.findFirstTextNode(node) + if not textNode + node.innerHTML = @originalInput.replace(" ", " ") + node.innerHTML + else + textNode.textContent = @originalInput + textNode.textContent + + if @numberRegex().test(@originalInput) + DOMUtils.Mutating.moveSelectionToIndexInAnchorNode(selection, 3) # digit plus dot + if @bulletRegex().test(@originalInput) + DOMUtils.Mutating.moveSelectionToIndexInAnchorNode(selection, 2) # dash or star + + @originalInput = null + + @outdentListItem: (selection) -> + if @originalInput + document.execCommand("outdent") + @restoreOriginalInput(selection) + else + document.execCommand("outdent") + +module.exports = ListManager diff --git a/src/contenteditable-workarounds.coffee b/src/contenteditable-workarounds.coffee new file mode 100644 index 000000000..bb913a66d --- /dev/null +++ b/src/contenteditable-workarounds.coffee @@ -0,0 +1,66 @@ +DOMUtils = require './dom-utils' + +### +The Nylas N1 Contenteditable component relies on Chrome's (via Electron) implementation of DOM Contenteditable. + +Contenteditable is problematic when multiple browser support is required. Since we only support one browser (Electron), its behavior is consistent. + +Unfortunately there are still a handful of issues in its implementation. + +For more reading on Contenteditable and its issues see: + +- https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_Editable +- https://medium.com/medium-eng/why-contenteditable-is-terrible-122d8a40e480 +- https://blog.whatwg.org/the-road-to-html-5-contenteditable +- https://github.com/basecamp/trix +### +class Workarounds + + @patch: -> + return + @origExecCommand ?= document.execCommand + @patchOutdent() + + # As of Electron 0.29.2 `document.execCommand('outdent')` does not + # properly work on the
  • tag of both
      and
        lists when + # there is exactly one
      1. tag. + # + # We must manually perform the outdent when we detect we are at at the + # first item in a list. + # + # Given + # ```html + #
          + #
        • a
        • + #
        + # ``` + @patchOutdent: -> + document.execCommand = (command, args...) => + if command is "outdent" + @customOutdent() + else + @origExecCommand.apply(document, [command].concat(args)) + + @customOutdent: -> + parentList = DOMUtils.closestAtCursor("ul, ol") + if parentList + listItems = parentList.querySelectorAll('li') + if listItems.length is 1 + originalText = listItems[0].innerHTML + DOMUtils.Mutating.replaceFirstListItem(listItems[0], originalText) + else + @origExecCommand.call(document, "outdent") + else + @origExecCommand.call(document, "outdent") + + # outdentFirstListItem: (replaceWithContent) -> + # + # # Detects if the cursor is in the first list item. + # detectOutdentFirstListItem: -> + # li = DOMUtils.closestAtCursor("li") + # return false if not li + # list = DOMUtils.closestAtCursor("ul, ol") + # return list.querySelectorAll('li')?[0] is li + +Workarounds.patch() +module.exports = Workarounds diff --git a/src/dom-utils.coffee b/src/dom-utils.coffee index c767a5b48..fafe01580 100644 --- a/src/dom-utils.coffee +++ b/src/dom-utils.coffee @@ -2,38 +2,188 @@ _ = require 'underscore' _s = require 'underscore.string' DOMUtils = + Mutating: + replaceFirstListItem: (li, replaceWith) -> + list = DOMUtils.closest(li, "ul, ol") - # Given a bunch of elements, it will go through and find all elements - # that are adjacent to that one of the same type. For each set of - # adjacent elements, it will put all children of those elements into the - # first one and delete the remaining elements. - # - # WARNING: This mutates the DOM in place! - collapseAdjacentElements: (els=[]) -> - return if els.length is 0 - els = Array::slice.call(els) + if replaceWith.length is 0 + replaceWith = replaceWith.replace /\s/g, " " + text = document.createElement("div") + text.innerHTML = "
        " + else + replaceWith = replaceWith.replace /\s/g, " " + text = document.createElement("span") + text.innerHTML = "#{replaceWith}" - seenEls = [] - toMerge = [] + if list.querySelectorAll('li').length <= 1 + # Delete the whole list and replace with text + list.parentNode.replaceChild(text, list) + else + # Delete the list item and prepend the text before the rest of the + # list + li.parentNode.removeChild(li) + list.parentNode.insertBefore(text, list) - for el in els - continue if el in seenEls - adjacent = DOMUtils.collectAdjacent(el) - seenEls = seenEls.concat(adjacent) - continue if adjacent.length <= 1 - toMerge.push(adjacent) + child = text.childNodes[0] ? text + index = Math.max(replaceWith.length - 1, 0) + selection = document.getSelection() + selection.setBaseAndExtent(child, index, child, index) - anchors = [] - for mergeSet in toMerge - anchor = mergeSet[0] - remaining = mergeSet[1..-1] - for el in remaining - while (el.childNodes.length > 0) - anchor.appendChild(el.childNodes[0]) - DOMUtils.removeElements(remaining) - anchors.push(anchor) + removeEmptyNodes: (node) -> + Array::slice.call(node.childNodes).forEach (child) -> + if child.textContent is '' + node.removeChild(child) + else + DOMUtils.Mutating.removeEmptyNodes(child) - return anchors + # Given a bunch of elements, it will go through and find all elements + # that are adjacent to that one of the same type. For each set of + # adjacent elements, it will put all children of those elements into + # the first one and delete the remaining elements. + collapseAdjacentElements: (els=[]) -> + return if els.length is 0 + els = Array::slice.call(els) + + seenEls = [] + toMerge = [] + + for el in els + continue if el in seenEls + adjacent = DOMUtils.collectAdjacent(el) + seenEls = seenEls.concat(adjacent) + continue if adjacent.length <= 1 + toMerge.push(adjacent) + + anchors = [] + for mergeSet in toMerge + anchor = mergeSet[0] + remaining = mergeSet[1..-1] + for el in remaining + while (el.childNodes.length > 0) + anchor.appendChild(el.childNodes[0]) + DOMUtils.Mutating.removeElements(remaining) + anchors.push(anchor) + + return anchors + + removeElements: (elements=[]) -> + for el in elements + try + if el.parentNode then el.parentNode.removeChild(el) + catch + # This can happen if we've already removed ourselves from the + # node or it no longer exists + continue + return elements + + applyTextInRange: (range, selection, newText) -> + range.deleteContents() + node = document.createTextNode(newText) + range.insertNode(node) + range.selectNode(node) + selection.removeAllRanges() + selection.addRange(range) + + getRangeAtAndSelectWord: (selection, index) -> + range = selection.getRangeAt(index) + + # On Windows, right-clicking a word does not select it at the OS-level. + if range.collapsed + DOMUtils.Mutating.selectWordContainingRange(range) + range = selection.getRangeAt(index) + return range + + # This method finds the bounding points of the word that the range + # is currently within and selects that word. + selectWordContainingRange: (range) -> + selection = document.getSelection() + node = selection.focusNode + text = node.textContent + wordStart = _s.reverse(text.substring(0, selection.focusOffset)).search(/\s/) + if wordStart is -1 + wordStart = 0 + else + wordStart = selection.focusOffset - wordStart + wordEnd = text.substring(selection.focusOffset).search(/\s/) + if wordEnd is -1 + wordEnd = text.length + else + wordEnd += selection.focusOffset + + selection.removeAllRanges() + range = new Range() + range.setStart(node, wordStart) + range.setEnd(node, wordEnd) + selection.addRange(range) + + moveSelectionToIndexInAnchorNode: (selection, index) -> + return unless selection.isCollapsed + node = selection.anchorNode + selection.setBaseAndExtent(node, index, node, index) + + moveSelectionToEnd: (selection) -> + return unless selection.isCollapsed + node = DOMUtils.findLastTextNode(selection.anchorNode) + index = node.length + selection.setBaseAndExtent(node, index, node, index) + + getSelectionRectFromDOM: (selection) -> + selection ?= document.getSelection() + node = selection.anchorNode + if node.nodeType is Node.TEXT_NODE + r = document.createRange() + r.selectNodeContents(node) + return r.getBoundingClientRect() + else if node.nodeType is Node.ELEMENT_NODE + return node.getBoundingClientRect() + else + return null + + isSelectionInTextNode: (selection) -> + selection ?= document.getSelection() + return false unless selection + return selection.isCollapsed and selection.anchorNode.nodeType is Node.TEXT_NODE and selection.anchorOffset > 0 + + isAtTabChar: (selection) -> + selection ?= document.getSelection() + if DOMUtils.isSelectionInTextNode(selection) + return selection.anchorNode.textContent[selection.anchorOffset - 1] is "\t" + else return false + + isAtBeginningOfDocument: (dom, selection) -> + selection ?= document.getSelection() + return false if not selection.isCollapsed + return false if selection.anchorOffset > 0 + return true if dom.childNodes.length is 0 + return true if selection.anchorNode is dom + firstChild = dom.childNodes[0] + return selection.anchorNode is firstChild + + atStartOfList: -> + selection = document.getSelection() + anchor = selection.anchorNode + return false if not selection.isCollapsed + return true if anchor?.nodeName is "LI" + return false if selection.anchorOffset > 0 + li = DOMUtils.closest(anchor, "li") + return unless li + return DOMUtils.isFirstChild(li, anchor) + + # https://developer.mozilla.org/en-US/docs/Web/API/Element/closest + # Only Elements (not Text nodes) have the `closest` method + closest: (node, selector) -> + el = if node instanceof HTMLElement then node else node.parentElement + return el.closest(selector) + + closestAtCursor: (selector) -> + selection = document.getSelection() + return unless selection?.isCollapsed + return DOMUtils.closest(selection.anchorNode, selector) + + isInList: -> + li = DOMUtils.closestAtCursor("li") + list = DOMUtils.closestAtCursor("ul, ol") + return li and list # Returns an array of all immediately adjacent nodes of a particular # nodeName relative to the root. Includes the root if it has the correct @@ -192,6 +342,28 @@ DOMUtils = else continue return lastNode + findLastTextNode: (node) -> + return null unless node + return node if node.nodeType is Node.TEXT_NODE + for childNode in node.childNodes by -1 + if childNode.nodeType is Node.TEXT_NODE + return childNode + else if childNode.nodeType is Node.ELEMENT_NODE + return DOMUtils.findLastTextNode(childNode) + else continue + return null + + findFirstTextNode: (node) -> + return null unless node + return node if node.nodeType is Node.TEXT_NODE + for childNode in node.childNodes + if childNode.nodeType is Node.TEXT_NODE + return childNode + else if childNode.nodeType is Node.ELEMENT_NODE + return DOMUtils.findFirstTextNode(childNode) + else continue + return null + isBlankTextNode: (node) -> return if not node?.data # \u00a0 is   @@ -218,16 +390,6 @@ DOMUtils = "'": ''' text.replace /[&<>"']/g, (m) -> map[m] - removeElements: (elements=[]) -> - for el in elements - try - if el.parentNode then el.parentNode.removeChild(el) - catch - # This can happen if we've already removed ourselves from the node - # or it no longer exists - continue - return elements - # Checks to see if a particular node is visible and any of its parents # are visible. # @@ -301,29 +463,6 @@ DOMUtils = return true if root.childNodes[0] is node return DOMUtils.isFirstChild(root.childNodes[0], node) - # This method finds the bounding points of the word that the range - # is currently within and selects that word. - selectWordContainingRange: (range) -> - selection = document.getSelection() - node = selection.focusNode - text = node.textContent - wordStart = _s.reverse(text.substring(0, selection.focusOffset)).search(/\s/) - if wordStart is -1 - wordStart = 0 - else - wordStart = selection.focusOffset - wordStart - wordEnd = text.substring(selection.focusOffset).search(/\s/) - if wordEnd is -1 - wordEnd = text.length - else - wordEnd += selection.focusOffset - - selection.removeAllRanges() - range = new Range() - range.setStart(node, wordStart) - range.setEnd(node, wordEnd) - selection.addRange(range) - commonAncestor: (nodes=[]) -> nodes = Array::slice.call(nodes) diff --git a/src/global/nylas-exports.coffee b/src/global/nylas-exports.coffee index 96ba7b8d8..e53255c9f 100644 --- a/src/global/nylas-exports.coffee +++ b/src/global/nylas-exports.coffee @@ -144,6 +144,9 @@ class NylasExports @load "BufferedProcess", 'buffered-process' @get "APMWrapper", -> require('../apm-wrapper') + # Contenteditable + @load "ContenteditablePlugin", 'components/contenteditable/contenteditable-plugin' + # Testing @get "NylasTestUtils", -> require '../../spec/nylas-test-utils' diff --git a/src/nylas-env.coffee b/src/nylas-env.coffee index 41b644443..4bcc32e94 100644 --- a/src/nylas-env.coffee +++ b/src/nylas-env.coffee @@ -30,6 +30,9 @@ module.exports = class NylasEnvConstructor extends Model @version: 1 # Increment this when the serialization format changes + assert: (bool, msg) -> + throw new Error("Assertion error: #{msg}") if not bool + # Load or create the application environment # Returns an NylasEnv instance, fully initialized @loadOrCreate: -> @@ -144,6 +147,8 @@ class NylasEnvConstructor extends Model unless @inDevMode() or @inSpecMode() require('grim').deprecate = -> + @enhanceEventObject() + @setupErrorLogger() @unsubscribe() @@ -919,3 +924,12 @@ class NylasEnvConstructor extends Model remote.require('app').quit() else @close() + + enhanceEventObject: -> + overriddenStop = Event::stopPropagation + Event::stopPropagation = -> + @propagationStopped = true + overriddenStop.apply(@, arguments) + Event::isPropagationStopped = -> + @propagationStopped + diff --git a/src/services/quoted-html-parser.coffee b/src/services/quoted-html-parser.coffee index 70e79c8dd..eb5354fab 100644 --- a/src/services/quoted-html-parser.coffee +++ b/src/services/quoted-html-parser.coffee @@ -44,7 +44,7 @@ class QuotedHTMLParser if options.keepIfWholeBodyIsQuote and @_wholeBodyIsQuote(doc, quoteElements) return doc.children[0].innerHTML else - DOMUtils.removeElements(quoteElements, options) + DOMUtils.Mutating.removeElements(quoteElements, options) childNodes = doc.body.childNodes extraTailBrTags = [] @@ -56,7 +56,7 @@ class QuotedHTMLParser else break - DOMUtils.removeElements(extraTailBrTags) + DOMUtils.Mutating.removeElements(extraTailBrTags) return doc.children[0].innerHTML appendQuotedHTML: (htmlWithoutQuotes, originalHTML) ->