From c2ceb6fd6cf0925ceb9937144cad90fd77feef8b Mon Sep 17 00:00:00 2001 From: Evan Morikawa Date: Wed, 20 Jan 2016 14:35:20 -0800 Subject: [PATCH] refactor(toolbar): allow toolbar extensions in contenteditable Summary: This is a refactor of the toolbar in the contenteditable. Goals of this are: 1. Allow developers to add new buttons to the toolbar 2. Allow developers to add other component types to the floating toolbar (like the LinkEditor) 3. Make the toolbar declaratively defined instead of imperatively set 4. Separate out logical units of the toolbar into individual sections 5. Clean up `innerState` of the Contenteditable The Floating Toolbar used to be an imperative mess. Doing simple functionality additions required re-understanding a very complex set of logic to hide and show the toolbar and delecately manage focus states. There also was no real capacity for any developer to extend the toolbar. It also used to be completely outside of our `atomicEdit` system and was a legacy of having raw access to contenteditable controls (since it all used to be directly inside of the contenteditable) Finally it was difficult to declaratively define things because the `innerState` of the Contenteditable was inconsistently used and its lifecycle not properly thought through. This fixed several lifecycle bugs with that. Along the way several of the DOMUtils methods were also subtly not functional and fixed. The Toolbar is now broken apart into separate logical units. There are now `ContentedtiableExtension`s that declare what should be displayed in the toolbar at any given moment. They define a method called `toolbarComponentData`. This is a pure function of the state of the `Contenteditable`. If selection and content conditions look correct, then that method will return a component to render. This is how we declaratively define whether a toolbar should be visible or not instead of manually setting `hide` & `show` bits. There is also a `toolbarButtons` method that declaratively defines buttons that can go in the new `` component. The `ToolbarButtonManager` takes care of extracting these and binding the correct editorAPI context. Now the `` is a separate component from the `` instead of being smashed together. The `LinkManager` takes care of declaring when the `LinkEditor` should be displayed and has properly bound methods to update the `contenteditable` through the standard `atomicEdit` interface. If users have additional contenteditable popup plugins (like displaying extra info on a name or some content in the composer), they can now implement the `toolbarComponentData` api and declaratively define that information based on the state of the contenteditable. Test Plan: TODO Reviewers: bengotow, juan Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D2442 --- .../composer/lib/composer-editor.jsx | 18 +- keymaps/base.cson | 1 - .../contenteditable/blockquote-manager.coffee | 6 + .../contenteditable/contenteditable.cjsx | 243 +++++++----- .../contenteditable/editor-api.coffee | 55 ++- .../emphasis-formatting-extension.coffee | 50 +++ .../contenteditable/extended-selection.coffee | 43 +- .../floating-toolbar-container.cjsx | 327 ---------------- .../contenteditable/floating-toolbar.cjsx | 370 ++++++++---------- .../contenteditable/link-editor.cjsx | 98 +++++ .../contenteditable/link-manager.coffee | 130 ++++++ .../contenteditable/list-manager.coffee | 6 + .../contenteditable/mouse-service.coffee | 11 + .../paragraph-formatting-extension.coffee | 27 ++ .../toolbar-button-manager.coffee | 46 +++ .../contenteditable/toolbar-buttons.cjsx | 38 ++ src/components/flexbox.cjsx | 2 +- src/components/spinner.cjsx | 4 +- src/components/tokenizing-text-field.cjsx | 2 +- src/components/unsafe-component.cjsx | 5 + src/dom-utils.coffee | 30 +- src/extensions/composer-extension.coffee | 48 +-- .../contenteditable-extension.coffee | 69 ++++ src/nylas-env.coffee | 3 +- src/regexp-utils.coffee | 9 + static/components/contenteditable.less | 8 +- 26 files changed, 951 insertions(+), 698 deletions(-) create mode 100644 src/components/contenteditable/emphasis-formatting-extension.coffee delete mode 100644 src/components/contenteditable/floating-toolbar-container.cjsx create mode 100644 src/components/contenteditable/link-editor.cjsx create mode 100644 src/components/contenteditable/link-manager.coffee create mode 100644 src/components/contenteditable/paragraph-formatting-extension.coffee create mode 100644 src/components/contenteditable/toolbar-button-manager.coffee create mode 100644 src/components/contenteditable/toolbar-buttons.cjsx diff --git a/internal_packages/composer/lib/composer-editor.jsx b/internal_packages/composer/lib/composer-editor.jsx index 0388dad8f..77df51eed 100644 --- a/internal_packages/composer/lib/composer-editor.jsx +++ b/internal_packages/composer/lib/composer-editor.jsx @@ -1,5 +1,5 @@ import React, {Component, PropTypes} from 'react'; -import {ExtensionRegistry, DOMUtils} from 'nylas-exports'; +import {ContenteditableExtension, ExtensionRegistry, DOMUtils} from 'nylas-exports'; import {ScrollRegion, Contenteditable} from 'nylas-component-kit'; /** @@ -26,6 +26,7 @@ import {ScrollRegion, Contenteditable} from 'nylas-component-kit'; * @param {props.onBodyChanged} props.onBodyChanged * @class ComposerEditor */ + class ComposerEditor extends Component { static displayName = 'ComposerEditor' @@ -84,10 +85,17 @@ class ComposerEditor extends Component { this.state = { extensions: ExtensionRegistry.Composer.extensions(), }; - this._coreExtension = { - onFocus: props.onFocus, - onBlur: props.onBlur, - }; + + class ComposerFocusManager extends ContenteditableExtension { + static onFocus() { + return props.onFocus(); + } + static onBlur() { + return props.onBlur(); + } + } + + this._coreExtension = ComposerFocusManager; } componentDidMount() { diff --git a/keymaps/base.cson b/keymaps/base.cson index d944486a7..a08901dee 100644 --- a/keymaps/base.cson +++ b/keymaps/base.cson @@ -82,7 +82,6 @@ ### Advanced formatting commands ### 'cmdctrl-&': 'contenteditable:numbered-list' - 'cmdctrl-#': 'contenteditable:numbered-list' 'cmdctrl-*': 'contenteditable:bulleted-list' 'cmdctrl-(': 'contenteditable:quote' diff --git a/src/components/contenteditable/blockquote-manager.coffee b/src/components/contenteditable/blockquote-manager.coffee index 91ce2144f..7b0bdd6e5 100644 --- a/src/components/contenteditable/blockquote-manager.coffee +++ b/src/components/contenteditable/blockquote-manager.coffee @@ -1,12 +1,18 @@ {DOMUtils, ContenteditableExtension} = require 'nylas-exports' class BlockquoteManager extends ContenteditableExtension + @keyCommandHandlers: -> + "contenteditable:quote": @_onCreateBlockquote + @onKeyDown: ({editor, event}) -> if event.key is "Backspace" if @_isInBlockquote(editor) and @_isAtStartOfLine(editor) editor.outdent() event.preventDefault() + @_onCreateBlockquote: ({editor, event}) -> + editor.formatBlock("BLOCKQUOTE") + @_isInBlockquote: (editor) -> sel = editor.currentSelection() return unless sel.isCollapsed diff --git a/src/components/contenteditable/contenteditable.cjsx b/src/components/contenteditable/contenteditable.cjsx index af726ca55..304f53c6b 100644 --- a/src/components/contenteditable/contenteditable.cjsx +++ b/src/components/contenteditable/contenteditable.cjsx @@ -3,17 +3,21 @@ React = require 'react' {Utils, DOMUtils} = require 'nylas-exports' {KeyCommandsRegion} = require 'nylas-component-kit' -FloatingToolbarContainer = require './floating-toolbar-container' +FloatingToolbar = require './floating-toolbar' EditorAPI = require './editor-api' ExtendedSelection = require './extended-selection' TabManager = require './tab-manager' +LinkManager = require './link-manager' ListManager = require './list-manager' MouseService = require './mouse-service' DOMNormalizer = require './dom-normalizer' ClipboardService = require './clipboard-service' BlockquoteManager = require './blockquote-manager' +ToolbarButtonManager = require './toolbar-button-manager' +EmphasisFormattingExtension = require './emphasis-formatting-extension' +ParagraphFormattingExtension = require './paragraph-formatting-extension' ### Public: A modern React-compatible contenteditable @@ -63,12 +67,20 @@ class Contenteditable extends React.Component coreServices: [MouseService, ClipboardService] - coreExtensions: [DOMNormalizer, ListManager, TabManager, BlockquoteManager] + coreExtensions: [ + ToolbarButtonManager + ListManager + TabManager + EmphasisFormattingExtension + ParagraphFormattingExtension + LinkManager + BlockquoteManager + DOMNormalizer + ] - - ######################################################################## - ########################### Public Methods ############################# - ######################################################################## + ###################################################################### + ########################### Public Methods ########################### + ###################################################################### ### Public: perform an editing operation on the Contenteditable @@ -91,28 +103,40 @@ class Contenteditable extends React.Component editor.importSelection(@innerState.exportedSelection) argsObj = _.extend(extraArgsObj, {editor}) - editingFunction(argsObj) + + try + editingFunction(argsObj) + catch error + NylasEnv.emitError(error) @_setupListeners() focus: => @_editableNode().focus() - ######################################################################## - ########################### React Lifecycle ############################ - ######################################################################## + ###################################################################### + ########################## React Lifecycle ########################### + ###################################################################### constructor: (@props) -> - @innerState = {} + @state = {} + @innerState = { + dragging: false + doubleDown: false + hoveringOver: false # see {MouseService} + editableNode: null + exportedSelection: null + previousExportedSelection: null + } @_mutationObserver = new MutationObserver(@_onDOMMutated) componentWillMount: => @_setupServices() componentDidMount: => + @setInnerState editableNode: @_editableNode() @_setupListeners() @_mutationObserver.observe(@_editableNode(), @_mutationConfig()) - @setInnerState editableNode: @_editableNode() # When we have a composition event in progress, we should not update # because otherwise our composition event will be blown away. @@ -123,16 +147,16 @@ class Contenteditable extends React.Component componentWillReceiveProps: (nextProps) => if nextProps.initialSelectionSnapshot? - @_saveExportedSelection(nextProps.initialSelectionSnapshot) + @setInnerState + exportedSelection: nextProps.initialSelectionSnapshot + previousExportedSelection: @innerState.exportedSelection componentDidUpdate: => @_restoreSelection() @_refreshServices() @_mutationObserver.disconnect() @_mutationObserver.observe(@_editableNode(), @_mutationConfig()) - @setInnerState - links: @_editableNode().querySelectorAll("*[href]") - editableNode: @_editableNode() + @setInnerState editableNode: @_editableNode() componentWillUnmount: => @_mutationObserver.disconnect() @@ -140,8 +164,10 @@ class Contenteditable extends React.Component @_teardownServices() setInnerState: (innerState={}) => + return if _.isMatch(@innerState, innerState) @innerState = _.extend @innerState, innerState - @refs["toolbarController"]?.componentWillReceiveInnerProps(innerState) + if @_broadcastInnerStateToToolbar + @refs["toolbarController"]?.componentWillReceiveInnerProps(@innerState) @_refreshServices() _setupServices: -> @@ -157,9 +183,9 @@ class Contenteditable extends React.Component service.teardown() for service in @_services - ######################################################################## - ############################### Render ################################# - ######################################################################## + ###################################################################### + ############################## Render ################################ + ###################################################################### render: => return unless @props.floatingToolbar - + _editableNode: => React.findDOMNode(@refs.contenteditable) - ######################################################################## - ############################ Listener Setup ############################ - ######################################################################## + ###################################################################### + ########################### Listener Setup ########################### + ###################################################################### _eventHandlers: => handlers = {} _.extend(handlers, service.eventHandlers()) for service in @_services + + # NOTE: See {MouseService} for more handlers handlers = _.extend handlers, onBlur: @_onBlur onFocus: @_onFocus @@ -198,29 +228,38 @@ class Contenteditable extends React.Component onCompositionStart: @_onCompositionStart return handlers - _keymapHandlers: -> - atomicEditWrap = (command) => - (event) => - @atomicEdit((({editor}) -> editor[command]()), event) - - 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") - } - + # This extracts extensions keymap handlers and binds them to be called + # through `atomicEdit`. This exposes the `{editor, event}` props to any + # keyCommandHandlers callbacks. + _boundExtensionKeymapHandlers: -> + keymapHandlers = {} + @_extensions().forEach (extension) => + return unless _.isFunction(extension.keyCommandHandlers) + try + extensionHandlers = extension.keyCommandHandlers.call(extension) + _.each extensionHandlers, (handler, command) => + keymapHandlers[command] = (event) => + @atomicEdit(handler, {event}) + catch error + NylasEnv.emitError(error) return keymapHandlers + # NOTE: Keymaps are now broken apart into individual extensions. See the + # `EmphasisFormattingExtension`, `ParagraphFormattingExtension`, + # `ListManager`, and `LinkManager` for examples of extensions listening + # to keymaps. + _keymapHandlers: -> + defaultKeymaps = {} + return _.extend(defaultKeymaps, @_boundExtensionKeymapHandlers()) + _setupListeners: => - document.addEventListener("selectionchange", @_onSelectionChange) + @_broadcastInnerStateToToolbar = true + document.addEventListener("selectionchange", @_saveSelection) @_editableNode().addEventListener('contextmenu', @_onShowContextMenu) _teardownListeners: => - document.removeEventListener("selectionchange", @_onSelectionChange) + @_broadcastInnerStateToToolbar = false + document.removeEventListener("selectionchange", @_saveSelection) @_editableNode().removeEventListener('contextmenu', @_onShowContextMenu) # https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver @@ -233,9 +272,9 @@ class Contenteditable extends React.Component characterDataOldValue: true - ######################################################################## - ############################ Event Handlers ############################ - ######################################################################## + ###################################################################### + ########################### Event Handlers ########################### + ###################################################################### # Every time the contents of the contenteditable DOM node change, the # `_onDOMMutated` event gets fired. @@ -251,15 +290,22 @@ class Contenteditable extends React.Component @_mutationObserver.disconnect() @setInnerState dragging: false if @innerState.dragging @setInnerState doubleDown: false if @innerState.doubleDown + @_broadcastInnerStateToToolbar = false @_runCallbackOnExtensions("onContentChanged", {mutations}) - selection = new ExtendedSelection(@_editableNode()) - if selection?.isInScope() - @_saveExportedSelection(selection.exportSelection()) + # NOTE: The DOMNormalizer should be the last extension to run. This + # will ensure that when we extract our innerHTML and re-set it during + # the next render the contents should look identical. + # + # Also, remember that our selection listeners have been turned off. + # It's very likely that one of our callbacks mutated the DOM and the + # selection. We need to be sure to re-save the selection. + @_saveSelection() @props.onChange(target: {value: @_editableNode().innerHTML}) + @_broadcastInnerStateToToolbar = true @_mutationObserver.observe(@_editableNode(), @_mutationConfig()) return @@ -267,10 +313,8 @@ class Contenteditable extends React.Component @setInnerState dragging: false return if @_editableNode().parentElement.contains event.relatedTarget @dispatchEventToExtensions("onBlur", event) - @setInnerState editableFocused: false _onFocus: (event) => - @setInnerState editableFocused: true @dispatchEventToExtensions("onFocus", event) _onKeyDown: (event) => @@ -317,12 +361,15 @@ class Contenteditable extends React.Component menu.popup(remote.getCurrentWindow()) - ######################################################################## - ############################# Extensions ############################### - ######################################################################## + ###################################################################### + ############################ Extensions ############################## + ###################################################################### + + _extensions: -> + @props.extensions.concat(@coreExtensions) _runCallbackOnExtensions: (method, argsObj={}) => - for extension in @props.extensions.concat(@coreExtensions) + for extension in @_extensions() @_runExtensionMethod(extension, method, argsObj) # Will execute the event handlers on each of the registerd and core @@ -352,9 +399,9 @@ class Contenteditable extends React.Component @atomicEdit(editingFunction, argsObj) - ######################################################################## - ############################## Selection ############################### - ######################################################################## + ###################################################################### + ############################# Selection ############################## + ###################################################################### # Saving and restoring a selection is difficult with React. # # React only handles Input and Textarea elements: @@ -396,50 +443,62 @@ class Contenteditable extends React.Component # # http://www.w3.org/TR/selection-api/#selectstart-event - getCurrentSelection: => @innerState.exportedSelection ? {} - getPreviousSelection: => @innerState.previousExportedSelection ? {} + ## TODO DEPRECATE ME: This is only necessary because Undo/Redo is still + #part of the composer and not a core part of the Contenteditable. + getCurrentSelection: => @innerState.exportedSelection + getPreviousSelection: => @innerState.previousExportedSelection - # We save an {ExportedSelection} to `innerState`. + # Every time the selection changes we save its state. # - # Whatever we set `innerState.exportedSelection` to will be implemented - # on the next `componentDidUpdate` by `_restoreSelection` + # In an ideal world, the selection state, much like the body, would + # behave like any other controlled React input: onchange we'd notify our + # parent, they'd update our props, and we'd re-render. # - # We also allow props to manually set our `exportedSelection` state. - # This is useful in undo/redo situations when we want to revert the - # selection to where it was at a previous time. + # Unfortunately, Selection is not something React natively keeps track + # of in its virtual DOM, the performance would be terrible if we + # re-rendered on every selection change (think about dragging a + # selection), and having every user of `` need to + # remember to deal with, save, and set the Selection object is a pain. # - # NOTE: The `exportedSelection` object may have `anchorNode` and - # `focusNode` references to similar, but not equal, DOMNodes than what - # we currently have rendered. Every time React re-renders the component - # we get new DOM objects. When the `exportedSelection` is re-imported - # during `_restoreSelection`, the `ExtendedSelection` class will attempt - # to find the appropriate DOM Nodes via the `similar nodes` conveience methods - # in DOMUtils. + # To counter this we save local instance copies of the Selection. # - # When React re-renders it doesn't restore the Selection. We need to do - # this manually with `_restoreSelection` + # First of all we wrap the native Selection object in an + # [ExtendedSelection} object. This is a pure extension and has all + # standard methods. # - # As a performance optimization, we don't attach this to React `state`. - # Since re-rendering generates new DOM objects on the heap, testing for - # selection equality is expensive and requires a full tree walk. + # We then save out 3 types of selections on `innerState` for us to use + # later: # - # We also need to keep references to the previous selection state in - # order for undo/redo to work properly. - _saveExportedSelection: (exportedSelection) => - return if exportedSelection and exportedSelection.isEqual(@innerState.exportedSelection) - - @setInnerState - exportedSelection: exportedSelection - editableFocused: true - previousExportedSelection: @innerState.exportedSelection - - # Every time the cursor changes we need to save its location and state. - # We update our cache every time the selection changes by listening to - # the `document` `selectionchange` event. - _onSelectionChange: (event) => + # 1. `selectionSnapshot` - This is accessed by any sub-components of + # the Contenteditable such as the `` and its + # extensions. + # + # It is slightly different from an `exportedSelection` in that the + # anchorNode property points to an attached DOM reference and not the + # clone of a node. This is necessary for extensions to be able to + # traverse the actual current DOM from the anchorNode. The + # `exportedSelection`'s, cloned nodes don't have parentNOdes. + # + # This is crucially not a reference to the `rawSelection` object, + # because the anchorNodes of that may change from underneath us at any + # time. + # + # 2. `exportedSelection` - This is an {ExportedSelection} object and is + # used to restore the selection even after the DOM has changed. When our + # component re-renders the actual DOM objects on the heap will be + # different. An {ExportedSelection} contains counting indicies we use to + # re-find the correct DOM Nodes in the new document. + # + # 3. `previousExportedSelection` - This is used for undo / redo so when + # you revert to a previous state, the selection updates as well. + _saveSelection: => selection = new ExtendedSelection(@_editableNode()) return unless selection?.isInScope() - @_saveExportedSelection(selection.exportSelection()) + + @setInnerState + selectionSnapshot: selection.selectionSnapshot() + exportedSelection: selection.exportSelection() + previousExportedSelection: @innerState.exportedSelection _restoreSelection: => return unless @_shouldRestoreSelection() diff --git a/src/components/contenteditable/editor-api.coffee b/src/components/contenteditable/editor-api.coffee index 5f1bd0639..23cb99b0b 100644 --- a/src/components/contenteditable/editor-api.coffee +++ b/src/components/contenteditable/editor-api.coffee @@ -1,3 +1,4 @@ +_ = require 'underscore' {DOMUtils} = require 'nylas-exports' ExtendedSelection = require './extended-selection' @@ -25,11 +26,19 @@ class EditorAPI constructor: (@rootNode) -> @_extendedSelection = new ExtendedSelection(@rootNode) - wrapSelection:(nodeName) -> - wrapped = DOMUtils.wrap(@_selection.getRangeAt(0), nodeName) + wrapSelection: (nodeName) -> + wrapped = DOMUtils.wrap(@_extendedSelection.getRangeAt(0), nodeName) @select(wrapped) return @ + unwrapNodeAndSelectAll: (node) -> + replacedNodes = DOMUtils.unwrapNode(node) + return @ if replacedNodes.length is 0 + first = replacedNodes[0] + last = _.last(replacedNodes) + @_extendedSelection.selectFromTo(first, last) + return @ + regExpSelectorAll:(regex) -> DOMUtils.regExpSelectorAll(@rootNode, regex) @@ -44,17 +53,26 @@ class EditorAPI fn() @select(sel) - getSelectionTextIndex: (args...) -> @_extendedSelection.getSelectionTextIndex(args...) + getSelectionTextIndex: (args...) -> + @_extendedSelection.getSelectionTextIndex(args...) + importSelection: (args...) -> + @_extendedSelection.importSelection(args...); @ - collapse: (args...) -> @_extendedSelection.collapse(args...); @ - collapseToStart: (args...) -> @_extendedSelection.collapseToStart(args...); @ - collapseToEnd: (args...) -> @_extendedSelection.collapseToEnd(args...); @ - importSelection: (args...) -> @_extendedSelection.importSelection(args...); @ - select: (args...) -> @_extendedSelection.select(args...); @ - selectAllChildren: (args...) -> @_extendedSelection.selectAllChildren(args...); @ - restoreSelectionByTextIndex: (args...) -> @_extendedSelection.restoreSelectionByTextIndex(args...); @ + select: (args...) -> + @_extendedSelection.select(args...); @ + selectAllChildren: (args...) -> + @_extendedSelection.selectAllChildren(args...); @ + + restoreSelectionByTextIndex: (args...) -> + @_extendedSelection.restoreSelectionByTextIndex(args...); @ + + normalize: -> @rootNode.normalize(); @ + + ######################################################################## + ####################### execCommand Delegation ######################### + ######################################################################## backColor: (color) -> @_ec("backColor", false, color) bold: -> @_ec("bold", false) copy: -> @_ec("copy", false) @@ -72,7 +90,17 @@ class EditorAPI increaseFontSize: -> @_ec("increaseFontSize", false) indent: -> @_ec("indent", false) insertHorizontalRule: -> @_ec("insertHorizontalRule", false) - insertHTML: (html) -> @_ec("insertHTML", false, html) + + insertHTML: (html, {selectInsertion}) -> + if selectInsertion + wrappedHtml = "#{html}" + @_ec("insertHTML", false, wrappedHtml) + wrap = @rootNode.querySelector("#tmp-html-insertion-wrap") + @unwrapNodeAndSelectAll(wrap) + return @ + else + @_ec("insertHTML", false, wrappedHtml) + insertImage: (uri) -> @_ec("insertImage", false, uri) insertOrderedList: -> @_ec("insertOrderedList", false) insertUnorderedList: -> @_ec("insertUnorderedList", false) @@ -96,14 +124,15 @@ class EditorAPI unlink: -> @_ec("unlink", false) styleWithCSS: (style) -> @_ec("styleWithCSS", false, style) - normalize: -> @rootNode.normalize(); @ - contentReadOnly: -> @_notImplemented() enableInlineTableEditing: -> @_notImplemented() enableObjectResizing: -> @_notImplemented() insertBrOnReturn: -> @_notImplemented() useCSS: -> @_notImplemented() + ######################################################################## + ####################### Private Helper Methods ######################### + ######################################################################## _ec: (args...) -> document.execCommand(args...); return @ _notImplemented: -> throw new Error("Not implemented") diff --git a/src/components/contenteditable/emphasis-formatting-extension.coffee b/src/components/contenteditable/emphasis-formatting-extension.coffee new file mode 100644 index 000000000..debacf181 --- /dev/null +++ b/src/components/contenteditable/emphasis-formatting-extension.coffee @@ -0,0 +1,50 @@ +{ContenteditableExtension} = require 'nylas-exports' + +# This provides the default baisc formatting options for the +# Contenteditable using the declarative extension API. +class EmphasisFormattingExtension extends ContenteditableExtension + @keyCommandHandlers: => + "contenteditable:bold": @_onBold + "contenteditable:italic": @_onItalic + "contenteditable:underline": @_onUnderline + "contenteditable:strikeThrough": @_onStrikeThrough + + @toolbarButtons: => [ + { + className: "btn-bold" + onClick: @_onBold + tooltip: "Bold" + iconUrl: null # Defined in the css of btn-bold + } + { + className: "btn-italic" + onClick: @_onItalic + tooltip: "Italic" + iconUrl: null # Defined in the css of btn-italic + } + { + className: "btn-underline" + onClick: @_onUnderline + tooltip: "Underline" + iconUrl: null # Defined in the css of btn-underline + } + ] + + @_onBold: ({editor, event}) -> editor.bold() + + @_onItalic: ({editor, event}) -> editor.italic() + + @_onUnderline: ({editor, event}) -> editor.underline() + + @_onStrikeThrough: ({editor, event}) -> editor.strikeThrough() + + # None of the emphasis formatting buttons need a custom component. + # + # They use the default component via the + # `toolbarButtons` extension API. + # + # The core component is managed by the + # {ToolbarButtonManager} + @toolbarComponentConfig: => null + +module.exports = EmphasisFormattingExtension diff --git a/src/components/contenteditable/extended-selection.coffee b/src/components/contenteditable/extended-selection.coffee index 0eca656b7..272414e1d 100644 --- a/src/components/contenteditable/extended-selection.coffee +++ b/src/components/contenteditable/extended-selection.coffee @@ -17,6 +17,7 @@ class ExtendedSelection @scopeNode.contains(@anchorNode) and @scopeNode.contains(@focusNode) + # Public: Conveniently select nodes. select: (args...) -> if args.length is 0 throw @_errBadUsage() @@ -35,28 +36,43 @@ class ExtendedSelection @selectFromToWithIndex(args...) else if args.length >= 5 throw @_errBadUsage() + return @ selectAt: (at) -> - nodeAt = @findNodeAt(at) + nodeAt = @findSelectableNodeAt(at) @setBaseAndExtent(nodeAt, 0, nodeAt, (nodeAt.length ? 0)) selectRange: (range) -> @setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset) selectFromTo: (from, to) -> - fromNode = @findNodeAt(from) - toNode = @findNodeAt(to) + fromNode = @findSelectableNodeAt(from) + toNode = @findSelectableNodeAt(to) @setBaseAndExtent(fromNode, 0, toNode, (toNode.length ? 0)) selectFromToWithIndex: (from, fromIndex, to, toIndex) -> - fromNode = @findNodeAt(from) - toNode = @findNodeAt(to) + fromNode = @findSelectableNodeAt(from) + toNode = @findSelectableNodeAt(to) if (not _.isNumber(fromIndex)) or (not _.isNumber(toIndex)) throw @_errBadUsage() @setBaseAndExtent(fromNode, fromIndex, toNode, toIndex) exportSelection: -> new ExportedSelection(@rawSelection, @scopeNode) + # A selectionSnapshot is slightly different from an {ExportedSelection}. + # An {ExportedSelection} maintains clones of the nodes (which don't have + # parentNodes nor are attached to the dcoument). An {ExportedSelection} + # also contains counting indices for future restoration. + # + # This is necessary since references to `rawSelection` can have its + # anchorNodes change out from underneath it as the selection changes. + selectionSnapshot: -> + anchorNode: @rawSelection.anchorNode + anchorOffset: @rawSelection.anchorOffset + focusNode: @rawSelection.focusNode + focusOffset: @rawSelection.focusOffset + isCollapsed: @rawSelection.isCollapsed + # Since the last time we exported the selection, the DOM may have # completely changed due to a re-render. To the user it may look # identical, but the newly rendered region may be comprised of @@ -81,15 +97,22 @@ class ExtendedSelection newFocusNode, exportedSelection.focusOffset) - findNodeAt: (arg) -> + findSelectableNodeAt: (arg) -> + node = null if arg instanceof Node - return arg + node = arg else if _.isString(arg) - return @scopeNode.querySelector(arg) + node = @scopeNode.querySelector(arg) else if _.isRegExp(arg) ## TODO - DOMUtils.findNodeByRegex(@scopeNode, arg) - return + node = DOMUtils.findNodeByRegex(@scopeNode, arg) + + # Normally, selections are designed to work on TextNodes, but you + # query by Elements. If an Element has just one textNode, we'll use + # that. If an Element has multiple children, it's ambiguous and we + # won't attempt to find the Text Node for you. + textNode = DOMUtils.findOnlyChildTextNode(node) + if textNode then return textNode else return node # Finds the start and end text index of the current selection relative # to a given Node or Range. Returns an object of the form: diff --git a/src/components/contenteditable/floating-toolbar-container.cjsx b/src/components/contenteditable/floating-toolbar-container.cjsx deleted file mode 100644 index a36aaf279..000000000 --- a/src/components/contenteditable/floating-toolbar-container.cjsx +++ /dev/null @@ -1,327 +0,0 @@ -_ = require 'underscore' -React = require 'react' - -{Utils, DOMUtils, ExtensionRegistry} = require 'nylas-exports' - -FloatingToolbar = require './floating-toolbar' - -# This is responsible for the logic required to position a floating -# toolbar -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 - # exportedSelection object plus any extra args (like DOM event - # objects) to the callback - atomicEdit: React.PropTypes.func - - @innerPropTypes: - links: React.PropTypes.array - dragging: React.PropTypes.bool - doubleDown: React.PropTypes.bool - editableNode: React.PropTypes.object - editableFocused: React.PropTypes.bool - exportedSelection: React.PropTypes.object - - constructor: (@props) -> - @state = - toolbarTop: 0 - toolbarMode: "buttons" - toolbarLeft: 0 - toolbarPos: "above" - editAreaWidth: 9999 # This will get set on first exportedSelection - toolbarVisible: false - linkHoveringOver: null - @_setToolbarState = _.debounce(@_setToolbarState, 10) - @innerProps = - links: [] - dragging: false - doubleDown: false - editableNode: null - toolbarFocus: false - editableFocused: null - exportedSelection: null - - shouldComponentUpdate: (nextProps, nextState) -> - not Utils.isEqualReact(nextProps, @props) or - not Utils.isEqualReact(nextState, @state) - - # Some properties (like whether we're dragging or clicking the mouse) - # should in a strict-sense be props, but update in a way that's not - # performant to got through the full React re-rendering cycle, - # especially given the complexity of the composer component. - # - # We call these performance-optimized props & state innerProps and - # innerState. - componentWillReceiveInnerProps: (nextInnerProps) => - @innerProps = _.extend @innerProps, nextInnerProps - @fullProps = _.extend(@innerProps, @props) - if "links" of nextInnerProps - @_refreshLinkHoverListeners() - @_setToolbarState() - - componentWillReceiveProps: (nextProps) => - @fullProps = _.extend(@innerProps, nextProps) - @_setToolbarState() - - # The context menu, when activated, needs to make sure that the toolbar - # is closed. Unfortunately, since there's no onClose callback for the - # context menu, we can't hook up a reliable declarative state to the - # menu. We break our declarative pattern in this one case. - forceClose: -> - @setState toolbarVisible: false - - render: -> - - - _onSaveUrl: (url, linkToModify) => - @props.atomicEdit ({editor}) -> - if linkToModify? - equivalentNode = DOMUtils.findSimilarNodeAtIndex(editor.rootNode, linkToModify, 0) - return unless equivalentNode? - equivalentLinkText = DOMUtils.findFirstTextNode(equivalentNode) - return if linkToModify.getAttribute?('href')?.trim() is url.trim() - toSelect = equivalentLinkText - else - # When atomicEdit gets run, the exportedSelection is already restored to - # the last saved exportedSelection state. Any operation we perform will - # apply to the last saved exportedSelection state. - toSelect = null - - if url.trim().length is 0 - if toSelect then editor.select(toSelect).unlink() - else editor.unlink() - else - if toSelect then editor.select(toSelect).createLink(url) - else editor.createLink(url) - - # 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((({editor}) -> editor[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. - _onDoneWithLink: => - @componentWillReceiveInnerProps linkHoveringOver: null - @setState - toolbarMode: "buttons" - toolbarVisible: false - return - - # We explicitly control the focus of the FloatingToolbar because we can - # do things like switch from "buttons" mode to "edit-link" mode (which - # natively fires focus change events) but not want to signify a "focus" - # change - _onChangeFocus: (focus) => - @componentWillReceiveInnerProps toolbarFocus: focus - - # We want the toolbar's state to be declaratively defined from other - # states. - _setToolbarState: => - props = @fullProps ? {} - - return if props.dragging or (props.doubleDown and not @state.toolbarVisible) - - if props.toolbarFocus - @setState toolbarVisible: true - return - - if @_shouldHideToolbar(props) - @setState - toolbarVisible: false - toolbarMode: "buttons" - return - - if props.linkHoveringOver - url = props.linkHoveringOver.getAttribute('href') - rect = props.linkHoveringOver.getBoundingClientRect() - [left, top, editAreaWidth, toolbarPos] = @_getToolbarPos(rect) - @setState - toolbarVisible: true - toolbarMode: "edit-link" - toolbarTop: top - toolbarLeft: left - toolbarPos: toolbarPos - linkToModify: props.linkHoveringOver - editAreaWidth: editAreaWidth - else - # return if @state.toolbarMode is "edit-link" - rect = DOMUtils.getRangeInScope(props.editableNode)?.getBoundingClientRect() - if not rect or DOMUtils.isEmptyBoundingRect(rect) - @setState - toolbarVisible: false - toolbarMode: "buttons" - else - [left, top, editAreaWidth, toolbarPos] = @_getToolbarPos(rect) - @setState - toolbarVisible: true - toolbarTop: top - toolbarLeft: left - toolbarPos: toolbarPos - linkToModify: null - editAreaWidth: editAreaWidth - - _shouldHideToolbar: (props) -> - return false if @state.toolbarMode is "edit-link" - return false if props.linkHoveringOver - return not props.editableFocused or - not props.exportedSelection or - props.exportedSelection.isCollapsed - - _refreshLinkHoverListeners: -> - @_teardownLinkHoverListeners() - @_links = {} - links = Array.prototype.slice.call(@innerProps.links) - links.forEach (link) => - link.hoverId = Utils.generateTempId() - @_links[link.hoverId] = {} - - context = this - enterListener = (event) -> - link = this - context._onEnterLink.call(context, link, event) - leaveListener = (event) -> - link = this - context._onLeaveLink.call(context, link, event) - - link.addEventListener "mouseenter", enterListener - link.addEventListener "mouseleave", leaveListener - @_links[link.hoverId].link = link - @_links[link.hoverId].enterListener = enterListener - @_links[link.hoverId].leaveListener = leaveListener - - _onEnterLink: (link, event) => - HOVER_IN_DELAY = 250 - @_clearLinkTimeouts() - @_links[link.hoverId].enterTimeout = setTimeout => - @componentWillReceiveInnerProps linkHoveringOver: link - , HOVER_IN_DELAY - - _onLeaveLink: (link, event) => - HOVER_OUT_DELAY = 500 - @_clearLinkTimeouts() - @_links[link.hoverId].leaveTimeout = setTimeout => - @componentWillReceiveInnerProps linkHoveringOver: null - , HOVER_OUT_DELAY - - _onEnterToolbar: (event) => - clearTimeout(@_clearTooltipTimeout) if @_clearTooltipTimeout? - - # 1. Hover over a link until the toolbar appears. - # 2. The toolbar's link input will be UNfocused - # 3. Moving the mouse off the link and over the toolbar will cause - # _onLinkLeave to fire. Before the `leaveTimeout` fires, clear it - # since our mouse has safely made it to the tooltip. - @_clearLinkTimeouts() - - # Called when the mouse leaves the "edit-link" mode toolbar. - # - # NOTE: The leave callback does NOT get called if the user has the input - # field focused. We don't want the make the box dissapear under the user - # when they're typing. - _onLeaveToolbar: (event) => - HOVER_OUT_DELAY = 250 - @_clearTooltipTimeout = setTimeout => - # If we've hovered over a link until the toolbar appeared, then - # `linkHoverOver` will be set to that link. When we move the mouse - # onto the toolbar, `_onEnterToolbar` will make sure that - # `linkHoveringOver` doesn't get cleared. If we then move our mouse - # off of the toolbar, we need to remember to clear the hovering - # link. - @componentWillReceiveInnerProps linkHoveringOver: null - , 250 - - _clearLinkTimeouts: -> - for hoverId, linkData of @_links - clearTimeout(linkData.enterTimeout) if linkData.enterTimeout? - clearTimeout(linkData.leaveTimeout) if linkData.leaveTimeout? - - _teardownLinkHoverListeners: => - for hoverId, linkData of @_links - clearTimeout linkData.enterTimeout - clearTimeout linkData.leaveTimeout - linkData.link.removeEventListener "mouseenter", linkData.enterListener - linkData.link.removeEventListener "mouseleave", linkData.leaveListener - @_links = {} - - CONTENT_PADDING: 15 - - _getToolbarPos: (referenceRect) => - return [0,0,0,0] unless @innerProps.editableNode - - TOP_PADDING = 10 - - BORDER_RADIUS_PADDING = 15 - - editArea = @innerProps.editableNode.getBoundingClientRect() - - calcLeft = (referenceRect.left - editArea.left) + referenceRect.width/2 - calcLeft = Math.min(Math.max(calcLeft, @CONTENT_PADDING+BORDER_RADIUS_PADDING), editArea.width - BORDER_RADIUS_PADDING) - - calcTop = referenceRect.top - editArea.top - 48 - toolbarPos = "above" - if calcTop < TOP_PADDING - calcTop = referenceRect.top - editArea.top + referenceRect.height + TOP_PADDING + 4 - toolbarPos = "below" - - return [calcLeft, calcTop, editArea.width, toolbarPos] - - _focusedOnToolbar: => - React.findDOMNode(@refs.floatingToolbar)?.contains(document.activeElement) - -module.exports = FloatingToolbarContainer diff --git a/src/components/contenteditable/floating-toolbar.cjsx b/src/components/contenteditable/floating-toolbar.cjsx index df340402f..4db991c07 100644 --- a/src/components/contenteditable/floating-toolbar.cjsx +++ b/src/components/contenteditable/floating-toolbar.cjsx @@ -1,255 +1,199 @@ _ = require 'underscore' -React = require 'react/addons' classNames = require 'classnames' -{CompositeDisposable} = require 'event-kit' -{RetinaImg} = require 'nylas-component-kit' -{ExtensionRegistry} = require 'nylas-exports' +React = require 'react' +{Utils, DOMUtils, ExtensionRegistry} = require 'nylas-exports' + +# Positions and renders a FloatingToolbar in the composer. +# +# The FloatingToolbar declaratively chooses a Component to render. Only +# extensions that expose a `toolbarComponentConfig` will be considered. +# Whether or not there's an available component to render determines +# whether or not the FloatingToolbar is visible. +# +# There's no `toolbarVisible` state. It uses the existance of a +# ToolbarComponent to determine what to display. +# +# The {ToolbarButtonManager} and the {LinkManager} are `coreExtensions` +# that declaratively register the special `` component +# and the `` component. class FloatingToolbar extends React.Component - @displayName = "FloatingToolbar" + @displayName: "FloatingToolbar" + # We are passed an array of Extensions. Those that implement the + # `toolbarButton` and/or the `toolbarComponent` methods will be + # injected into the Toolbar. + # + # Every time the `innerState` of the `Contenteditable` change, we get + # passed the data as new `innerProps`. @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 - 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 - - # 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 + atomicEdit: React.PropTypes.func + extensions: React.PropTypes.array + @innerPropTypes: + dragging: React.PropTypes.bool + selection: React.PropTypes.object + doubleDown: React.PropTypes.bool + hoveringOver: React.PropTypes.object + editableNode: React.PropTypes.object @defaultProps: - mode: "buttons" - onMouseEnter: -> - onMouseLeave: -> - buttonConfigs: [] + extensions: [] + @defaultInnerProps: + dragging: false + selection: null + doubleDown: false + hoveringOver: null + editableNode: null constructor: (@props) -> @state = - urlInputValue: @_initialUrl() ? "" - componentWidth: 0 + toolbarTop: 0 + toolbarMode: "buttons" + toolbarLeft: 0 + toolbarPos: "above" + editAreaWidth: 9999 # This will get set on first selection + toolbarWidth: 0 + toolbarComponent: null + toolbarLocationRef: null + toolbarComponentProps: {} + @innerProps = FloatingToolbar.defaultInnerProps - componentDidMount: => - @subscriptions = new CompositeDisposable() + shouldComponentUpdate: (nextProps, nextState) -> + not Utils.isEqualReact(nextProps, @props) or + not Utils.isEqualReact(nextState, @state) + + # Some properties (like whether we're dragging or clicking the mouse) + # should in a strict-sense be props, but update in a way that's not + # performant to got through the full React re-rendering cycle, + # especially given the complexity of the composer component. + # + # We call these performance-optimized props & state innerProps and + # innerState. + componentWillReceiveInnerProps: (nextInnerProps={}) => + fullProps = _.extend({}, @props, nextInnerProps) + @innerProps = _.extend @innerProps, nextInnerProps + @setState(@_getStateFromProps(fullProps)) componentWillReceiveProps: (nextProps) => - @setState - urlInputValue: @_initialUrl(nextProps) + fullProps = _.extend(@innerProps, nextProps) + @setState(@_getStateFromProps(fullProps)) - componentWillUnmount: => - @subscriptions?.dispose() + # The context menu, when activated, needs to make sure that the toolbar + # is closed. Unfortunately, since there's no onClose callback for the + # context menu, we can't hook up a reliable declarative state to the + # menu. We break our declarative pattern in this one case. + forceClose: -> + @setState toolbarVisible: false - componentDidUpdate: => - if @props.mode is "edit-link" and not @props.linkToModify - # Note, it's important that we're focused on the urlInput because - # the parent of this component needs to know to not hide us on their - # onBlur method. - React.findDOMNode(@refs.urlInput).focus() + # We render a ToolbarComponent in a floating frame. + render: -> + ToolbarComponent = @state.toolbarComponent + return false unless ToolbarComponent - render: => -
-
- {@_toolbarType()} +
+
+
+ +
+ _getStateFromProps: (props) -> + toolbarComponentState = @_getToolbarComponentData(props) + locationRefNode = toolbarComponentState.toolbarLocationRef + if locationRefNode + positionState = @_calculatePositionState(props, locationRefNode) + else positionState = {} + + return _.extend {}, positionState, toolbarComponentState + + # If this returns a `null` component, that means we don't want to show + # anything. + _getToolbarComponentData: (props) -> + toolbarComponent = null + toolbarWidth = 0 + toolbarLocationRef = null + toolbarComponentProps = {} + + for extension in props.extensions + try + params = extension.toolbarComponentConfig?(toolbarState: props) ? {} + if params.component + toolbarComponent = params.component + toolbarComponentProps = params.props ? {} + toolbarLocationRef = params.locationRefNode + toolbarWidth = params.width + catch error + NylasEnv.emitError(error) + + if toolbarComponent and not toolbarLocationRef + throw new Error("You must provider a locationRefNode for #{toolbarComponent.displayName}") + + return {toolbarComponent, toolbarComponentProps, toolbarLocationRef, toolbarWidth} + + @CONTENT_PADDING: 15 + + _calculatePositionState: (props, locationRefNode) => + editableNode = props.editableNode + + referenceRect = locationRefNode.getBoundingClientRect() + + if not editableNode or not referenceRect or DOMUtils.isEmptyBoundingRect(referenceRect) + return {toolbarTop: 0, toolbarLeft: 0, editAreaWidth: 0, toolbarPos: 'above'} + + TOP_PADDING = 10 + + BORDER_RADIUS_PADDING = 15 + + editArea = editableNode.getBoundingClientRect() + + calcLeft = (referenceRect.left - editArea.left) + referenceRect.width/2 + calcLeft = Math.min(Math.max(calcLeft, FloatingToolbar.CONTENT_PADDING+BORDER_RADIUS_PADDING), editArea.width - BORDER_RADIUS_PADDING) + + calcTop = referenceRect.top - editArea.top - 48 + toolbarPos = "above" + if calcTop < TOP_PADDING + calcTop = referenceRect.top - editArea.top + referenceRect.height + TOP_PADDING + 4 + toolbarPos = "below" + + return { + toolbarTop: calcTop + toolbarLeft: calcLeft + editAreaWidth: editArea.width + toolbarPos: toolbarPos + } + _toolbarClasses: => classes = {} - classes[@props.pos] = true + classes[@state.toolbarPos] = true classNames _.extend classes, "floating-toolbar": true "toolbar": true - "toolbar-visible": @props.visible _toolbarStyles: => styles = left: @_toolbarLeft() - top: @props.top - width: @_width() + top: @state.toolbarTop + width: @state.toolbarWidth return styles - _toolbarType: => - if @props.mode is "buttons" then @_renderButtons() - else if @props.mode is "edit-link" then @_renderLink() - else return
- - _renderButtons: => - @props.buttonConfigs.map (config, i) -> - if (config.iconUrl ? "").length > 0 - icon = - else icon = "" - - - - _renderLink: => - removeBtn = "" - withRemove = "" - if @_initialUrl() - withRemove = "with-remove" - removeBtn = - -
- - - - {removeBtn} -
- - _onPreventToolbarClose: (event) => - event.stopPropagation() - - _onMouseEnter: => - @props.onMouseEnter?() - - _onMouseLeave: => - if @props.linkToModify and document.activeElement isnt React.findDOMNode(@refs.urlInput) - @props.onMouseLeave?() - - _initialUrl: (props=@props) => - props.linkToModify?.getAttribute?('href') - - _onInputChange: (event) => - @setState urlInputValue: event.target.value - - _saveUrlOnEnter: (event) => - if event.key is "Enter" - if (@state.urlInputValue ? "").trim().length > 0 - @_saveUrl() - else - @_removeUrl() - - # We signify the removal of a url with an empty string. This protects us - # from the case where people delete the url text and hit save. In that - # case we also want to remove the link. - _removeUrl: => - @setState urlInputValue: "" - @props.onSaveUrl "", @props.linkToModify - @props.onDoneWithLink() - - _onFocus: => - @props.onChangeFocus(true) - - # Clicking the save or remove buttons will take precendent over simply - # bluring the field. - _onBlur: (event) => - targets = [] - if @refs["saveBtn"] - targets.push React.findDOMNode(@refs["saveBtn"]) - if @refs["removeBtn"] - targets.push React.findDOMNode(@refs["removeBtn"]) - - if event.relatedTarget in targets - event.preventDefault() - return - else - @_saveUrl() - @props.onChangeFocus(false) - - _saveUrl: => - if (@state.urlInputValue ? "").trim().length > 0 - @props.onSaveUrl @state.urlInputValue, @props.linkToModify - @props.onDoneWithLink() - _toolbarLeft: => - CONTENT_PADDING = @props.contentPadding ? 15 - max = @props.editAreaWidth - @_width() - CONTENT_PADDING - left = Math.min(Math.max(@props.left - @_width()/2, CONTENT_PADDING), max) + max = @state.editAreaWidth - @state.toolbarWidth - FloatingToolbar.CONTENT_PADDING + left = Math.min(Math.max(@state.toolbarLeft - @state.toolbarWidth/2, FloatingToolbar.CONTENT_PADDING), max) return left _toolbarPointerStyles: => - CONTENT_PADDING = @props.contentPadding ? 15 POINTER_WIDTH = 6 + 2 #2px of border-radius - max = @props.editAreaWidth - CONTENT_PADDING - min = CONTENT_PADDING - absoluteLeft = Math.max(Math.min(@props.left, max), min) + max = @state.editAreaWidth - FloatingToolbar.CONTENT_PADDING + min = FloatingToolbar.CONTENT_PADDING + absoluteLeft = Math.max(Math.min(@state.toolbarLeft, max), min) relativeLeft = absoluteLeft - @_toolbarLeft() - left = Math.max(Math.min(relativeLeft, @_width()-POINTER_WIDTH), POINTER_WIDTH) + left = Math.max(Math.min(relativeLeft, @state.toolbarWidth-POINTER_WIDTH), POINTER_WIDTH) styles = left: left return styles - _width: => - # We can't calculate the width of the floating toolbar declaratively - # because it hasn't been rendered yet. As such, we'll keep the width - # fixed to make it much eaier. - TOOLBAR_BUTTONS_WIDTH = 114#px - TOOLBAR_URL_WIDTH = 210#px - - # If we have a long link, we want to make a larger text area. It's not - # super important to get the length exactly so let's just get within - # the ballpark by guessing charcter lengths - WIDTH_PER_CHAR = 11 - max = @props.editAreaWidth - (@props.contentPadding ? 15)*2 - - if @props.mode is "buttons" - return TOOLBAR_BUTTONS_WIDTH - else if @props.mode is "edit-link" - url = @_initialUrl() - if url?.length > 0 - fullWidth = Math.max(Math.min(url.length * WIDTH_PER_CHAR, max), TOOLBAR_URL_WIDTH) - return fullWidth - else - return TOOLBAR_URL_WIDTH - else - return TOOLBAR_BUTTONS_WIDTH - module.exports = FloatingToolbar diff --git a/src/components/contenteditable/link-editor.cjsx b/src/components/contenteditable/link-editor.cjsx new file mode 100644 index 000000000..b7e31d5bd --- /dev/null +++ b/src/components/contenteditable/link-editor.cjsx @@ -0,0 +1,98 @@ +React = require 'react/addons' + +class LinkEditor extends React.Component + @displayName = "LinkEditor" + + @propTypes: + # A callback function we use to save the URL to the Contenteditable + onSaveUrl: React.PropTypes.func + + # The current DOM link we are modifying + linkToModify: React.PropTypes.object + + # 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 + + constructor: (@props) -> + @state = + urlInputValue: @_initialUrl() ? "" + + componentDidMount: -> + if @props.focusOnMount + React.findDOMNode(@refs["urlInput"]).focus() + + render: => + removeBtn = "" + withRemove = "" + if @_initialUrl() + withRemove = "with-remove" + removeBtn = + +
+ + + + {removeBtn} +
+ + # Clicking the save or remove buttons will take precendent over simply + # bluring the field. + _onBlur: (event) => + targets = [] + if @refs["saveBtn"] + targets.push React.findDOMNode(@refs["saveBtn"]) + if @refs["removeBtn"] + targets.push React.findDOMNode(@refs["removeBtn"]) + + if event.relatedTarget in targets + event.preventDefault() + return + else + @_saveUrl() + + _saveUrl: => + if @state.urlInputValue.trim().length > 0 + @props.onSaveUrl @state.urlInputValue, @props.linkToModify + @props.onDoneWithLink() + + _onInputChange: (event) => + @setState urlInputValue: event.target.value + + _detectEscape: (event) => + if event.key is "Escape" + @props.onDoneWithLink() + + _saveUrlOnEnter: (event) => + if event.key is "Enter" + if @state.urlInputValue.trim().length > 0 + @_saveUrl() + else + @_removeUrl() + + # We signify the removal of a url with an empty string. This protects us + # from the case where people delete the url text and hit save. In that + # case we also want to remove the link. + _removeUrl: => + @setState urlInputValue: "" + @props.onSaveUrl "", @props.linkToModify + @props.onDoneWithLink() + + _initialUrl: (props=@props) => + props.linkToModify?.getAttribute('href') + + +module.exports = LinkEditor diff --git a/src/components/contenteditable/link-manager.coffee b/src/components/contenteditable/link-manager.coffee new file mode 100644 index 000000000..1e59f7db1 --- /dev/null +++ b/src/components/contenteditable/link-manager.coffee @@ -0,0 +1,130 @@ +_ = require 'underscore' +{RegExpUtils, DOMUtils, ContenteditableExtension} = require 'nylas-exports' +LinkEditor = require './link-editor' + +class LinkManager extends ContenteditableExtension + @keyCommandHandlers: => + "contenteditable:insert-link": @_onInsertLink + + @toolbarButtons: => + [{ + className: "btn-link" + onClick: @_onInsertLink + tooltip: "Edit Link" + iconUrl: null # Defined in the css of btn-link + }] + + # By default, if you're typing next to an existing anchor tag, it won't + # continue the anchor text. This is important for us since we want you + # to be able to select and then override the existing anchor text with + # something new. + @onContentChanged: ({editor, mutations}) => + sel = editor.currentSelection() + if sel.isCollapsed + node = sel.anchorNode + sibling = node.previousSibling + + return if not sibling + return if sel.anchorOffset > 1 + return if node.nodeType isnt Node.TEXT_NODE + return if sibling.nodeName isnt "A" + return if /^\s+/.test(node.data) + return if RegExpUtils.punctuation(exclude: ['\\-', '_']).test(node.data[0]) + + sibling.appendChild(node) + sibling.normalize() + text = DOMUtils.findLastTextNode(sibling) + editor.select(text, text.length, text, text.length) + + @toolbarComponentConfig: ({toolbarState}) => + return null if toolbarState.dragging or toolbarState.doubleDown + + linkToModify = null + + if not linkToModify and toolbarState.selectionSnapshot + linkToModify = @_linkAtCursor(toolbarState) + + return null if not linkToModify + + return { + component: LinkEditor + props: + onSaveUrl: (url, linkToModify) => + toolbarState.atomicEdit(@_onSaveUrl, {url, linkToModify}) + onDoneWithLink: => toolbarState.atomicEdit(@_onDoneWithLink) + linkToModify: linkToModify + focusOnMount: @_shouldFocusOnMount(toolbarState) + locationRefNode: linkToModify + width: @_linkWidth(linkToModify) + } + + @_shouldFocusOnMount: (toolbarState) -> + not toolbarState.selectionSnapshot.isCollapsed + + @_linkWidth: (linkToModify) -> + href = linkToModify?.getAttribute?('href') ? "" + WIDTH_PER_CHAR = 11 + return Math.max(href.length * WIDTH_PER_CHAR, 210) + + @_linkAtCursor: (toolbarState) -> + if toolbarState.selectionSnapshot.isCollapsed + anchor = toolbarState.selectionSnapshot.anchorNode + return DOMUtils.closest(anchor, 'a, n1-prompt-link') + else + anchor = toolbarState.selectionSnapshot.anchorNode + focus = toolbarState.selectionSnapshot.anchorNode + return DOMUtils.closest(anchor, 'n1-prompt-link') and DOMUtils.closest(focus, 'n1-prompt-link') + + ## TODO FIXME: Unfortunately, the keyCommandHandler fires before the + # Contentedtiable onKeyDown. + # + # Normally this wouldn't matter, but when `_onInsertLink` runs it will + # focus on the input box of the link editor. + # + # If onKeyDown in the Contenteditable runs after this, then + # `atomicUpdate` will reset the selection back to the Contenteditable. + # This process blurs the link input, which causes the LinkInput to close + # and attempt to set or clear the link. The net effect is that the link + # insertion appears to not work via keyboard commands. + # + # This would not be a problem if the rendering of the Toolbar happened + # at the same time as the Contenteditable's render cycle. Unfortunatley + # since the Contenteditable shouldn't re-render on all Selection + # changes, while the Toolbar should, these are out of sync. + # + # The temporary fix is adding a _.defer block to change the ordering of + # these keyboard events. + @_onInsertLink: ({editor, event}) -> _.defer -> + if editor.currentSelection().isCollapsed + html = "link text" + editor.insertHTML(html, selectInsertion: true) + else + editor.wrapSelection("n1-prompt-link") + + @_onDoneWithLink: ({editor}) -> + for node in editor.rootNode.querySelectorAll("n1-prompt-link") + editor.unwrapNodeAndSelectAll(node) + + @_onSaveUrl: ({editor, url, linkToModify}) -> + if linkToModify? + equivalentNode = DOMUtils.findSimilarNodesAtIndex(editor.rootNode, linkToModify, 0)?[0] + return unless equivalentNode? + equivalentLinkText = DOMUtils.findFirstTextNode(equivalentNode) + return if linkToModify.getAttribute?('href')?.trim() is url.trim() + toSelect = equivalentLinkText + else + # When atomicEdit gets run, the exportedSelection is already restored to + # the last saved exportedSelection state. Any operation we perform will + # apply to the last saved exportedSelection state. + toSelect = null + + if url.trim().length is 0 + if toSelect then editor.select(toSelect).unlink() + else editor.unlink() + else + if toSelect then editor.select(toSelect).createLink(url) + else editor.createLink(url) + for node in editor.rootNode.querySelectorAll("n1-prompt-link") + editor.unwrapNodeAndSelectAll(node) + +module.exports = LinkManager diff --git a/src/components/contenteditable/list-manager.coffee b/src/components/contenteditable/list-manager.coffee index 80bd9ce7a..0cdd78b41 100644 --- a/src/components/contenteditable/list-manager.coffee +++ b/src/components/contenteditable/list-manager.coffee @@ -2,6 +2,10 @@ _str = require 'underscore.string' {DOMUtils, ContenteditableExtension} = require 'nylas-exports' class ListManager extends ContenteditableExtension + @keyCommandHandlers: => + "contenteditable:numbered-list": @_insertNumberedList + "contenteditable:bulleted-list": @_insertBulletedList + @onContentChanged: ({editor, mutations}) -> if @_spaceEntered and @hasListStartSignature(editor.currentSelection()) @createList(editor) @@ -98,6 +102,8 @@ class ListManager extends ContenteditableExtension editor.insertOrderedList() if ordered is true editor.insertUnorderedList() if not ordered + @_insertNumberedList: ({editor}) -> editor.insertOrderedList() + @_insertBulletedList: ({editor}) -> editor.insertUnorderedList() @outdentListItem: (editor) -> if @originalInput diff --git a/src/components/contenteditable/mouse-service.coffee b/src/components/contenteditable/mouse-service.coffee index 3ba35c2b2..0af3000c6 100644 --- a/src/components/contenteditable/mouse-service.coffee +++ b/src/components/contenteditable/mouse-service.coffee @@ -5,10 +5,16 @@ ContenteditableService = require './contenteditable-service' class MouseService extends ContenteditableService constructor: -> super + @HOVER_DEBOUNCE = 250 @setup() + @timer = null + @_inFrame = true eventHandlers: -> onClick: @_onClick + onMouseEnter: (event) => @_inFrame = true + onMouseLeave: (event) => @_inFrame = false + onMouseOver: @_onMouseOver _onClick: (event) -> # We handle mouseDown, mouseMove, mouseUp, but we want to stop propagation @@ -80,5 +86,10 @@ class MouseService extends ContenteditableService @setInnerState dragging: false return event + # Floating toolbar plugins need to know what we're currently hovering + # over. We take care of debouncing the event handlers here to prevent + # flooding plugins with events. + _onMouseOver: (event) => + # @setInnerState hoveringOver: event.target module.exports = MouseService diff --git a/src/components/contenteditable/paragraph-formatting-extension.coffee b/src/components/contenteditable/paragraph-formatting-extension.coffee new file mode 100644 index 000000000..3e888c643 --- /dev/null +++ b/src/components/contenteditable/paragraph-formatting-extension.coffee @@ -0,0 +1,27 @@ +{ContenteditableExtension} = require 'nylas-exports' + +# This provides the default baisc formatting options for the +# Contenteditable using the declarative extension API. +# +# NOTE: Blockquotes get their own formatting in `BlockquoteManager` +class ParagraphFormattingExtension extends ContenteditableExtension + @keyCommandHandlers: => + "contenteditable:indent": @_onIndent + "contenteditable:outdent": @_onOutdent + + @toolbarButtons: => [] + + @_onIndent: ({editor, event}) -> editor.indent() + + @_onOutdent: ({editor, event}) -> editor.outdent() + + # None of the paragraph formatting buttons need a custom component. + # + # They use the default component via the + # `toolbarButtons` extension API. + # + # We can either return `null` or return the requsted object with no + # component. + @toolbarComponentConfig: => null + +module.exports = ParagraphFormattingExtension diff --git a/src/components/contenteditable/toolbar-button-manager.coffee b/src/components/contenteditable/toolbar-button-manager.coffee new file mode 100644 index 000000000..f1c97cc37 --- /dev/null +++ b/src/components/contenteditable/toolbar-button-manager.coffee @@ -0,0 +1,46 @@ +{DOMUtils, ContenteditableExtension} = require 'nylas-exports' +ToolbarButtons = require './toolbar-buttons' + +# This contains the logic to declaratively render the core +# component in a +class ToolbarButtonManager extends ContenteditableExtension + + # See the {EmphasisFormattingExtension} and {LinkManager} and other + # extensions for toolbarButtons. + @toolbarButtons: => [] + + @toolbarComponentConfig: ({toolbarState}) => + return null if toolbarState.dragging or toolbarState.doubleDown + return null unless toolbarState.selectionSnapshot + return null if toolbarState.selectionSnapshot.isCollapsed + + buttonConfigs = @_toolbarButtonConfigs(toolbarState) + + return { + component: ToolbarButtons + props: + buttonConfigs: buttonConfigs + locationRefNode: DOMUtils.getRangeInScope(toolbarState.editableNode) + width: buttonConfigs.length * 28.5 + } + + @_toolbarButtonConfigs: (toolbarState) -> + {extensions, atomicEdit} = toolbarState + buttonConfigs = [] + + for extension in extensions + try + extensionConfigs = extension.toolbarButtons?({toolbarState}) ? [] + continue if extensionConfigs.length is 0 + extensionConfigs.map (config) -> + fn = config.onClick ? -> + config.onClick = (event) -> atomicEdit(fn, {event}) + return config + buttonConfigs = buttonConfigs.concat(extensionConfigs) + catch error + NylasEnv.emitError(error) + + return buttonConfigs + + +module.exports = ToolbarButtonManager diff --git a/src/components/contenteditable/toolbar-buttons.cjsx b/src/components/contenteditable/toolbar-buttons.cjsx new file mode 100644 index 000000000..757a628ca --- /dev/null +++ b/src/components/contenteditable/toolbar-buttons.cjsx @@ -0,0 +1,38 @@ +React = require 'react/addons' +{RetinaImg} = require 'nylas-component-kit' + +# This component renders buttons and is the default view in the +# FloatingToolbar. +# +# Extensions that implement `toolbarButtons` can get their buttons added +# in. +# +# The {EmphasisFormattingExtension} extension is an example of one that +# implements this spec. +class ToolbarButtons extends React.Component + @displayName = "ToolbarButtons" + + @propTypes: + # Declares what buttons should appear in the toolbar. An array of + # config objects. + buttonConfigs: React.PropTypes.array + + @defaultProps: + buttonConfigs: [] + + render: -> +
{@_renderToolbarButtons()}
+ + _renderToolbarButtons: -> + @props.buttonConfigs.map (config, i) -> + if (config.iconUrl ? "").length > 0 + icon = + else icon = "" + + + +module.exports = ToolbarButtons diff --git a/src/components/flexbox.cjsx b/src/components/flexbox.cjsx index 09f86a782..aa160d70b 100644 --- a/src/components/flexbox.cjsx +++ b/src/components/flexbox.cjsx @@ -23,7 +23,7 @@ class Flexbox extends React.Component style: React.PropTypes.object render: -> - style = _.extend (@props.style || {}), + style = _.extend {}, (@props.style || {}), 'flexDirection': @props.direction, 'position':'relative' 'display': 'flex' diff --git a/src/components/spinner.cjsx b/src/components/spinner.cjsx index 8a62fd492..aab8a9f96 100644 --- a/src/components/spinner.cjsx +++ b/src/components/spinner.cjsx @@ -76,7 +76,7 @@ class Spinner extends React.Component "spinner-cover": true "hidden": @state.hidden - style = _.extend @props.style ? {}, + style = _.extend {}, (@props.style ? {}), 'position':'absolute' 'display': if @state.hidden then "none" else "block" 'top': '0' @@ -96,7 +96,7 @@ class Spinner extends React.Component 'hidden': @state.hidden 'paused': @state.paused - style = _.extend @props.style ? {}, + style = _.extend {}, (@props.style ? {}), 'position':'absolute' 'left': '50%' 'top': '50%' diff --git a/src/components/tokenizing-text-field.cjsx b/src/components/tokenizing-text-field.cjsx index d83735f57..4bf086493 100644 --- a/src/components/tokenizing-text-field.cjsx +++ b/src/components/tokenizing-text-field.cjsx @@ -268,7 +268,7 @@ class TokenizingTextField extends React.Component render: => {Menu} = require 'nylas-component-kit' - classes = classNames _.extend (@props.menuClassSet ? {}), + classes = classNames _.extend {}, (@props.menuClassSet ? {}), "tokenizing-field": true "native-key-bindings": true "focused": @state.focus diff --git a/src/components/unsafe-component.cjsx b/src/components/unsafe-component.cjsx index 154f6d192..213d4b8d6 100644 --- a/src/components/unsafe-component.cjsx +++ b/src/components/unsafe-component.cjsx @@ -1,4 +1,5 @@ React = require 'react' +{Utils} = require 'nylas-exports' _ = require 'underscore' ### @@ -42,6 +43,10 @@ class UnsafeComponent extends React.Component componentDidMount: => @renderInjected() + shouldComponentUpdate: (nextProps, nextState) => + not Utils.isEqualReact(nextProps, @props) or + not Utils.isEqualReact(nextState, @state) + componentDidUpdate: => @renderInjected() diff --git a/src/dom-utils.coffee b/src/dom-utils.coffee index 7a6bdfa67..6be50c197 100644 --- a/src/dom-utils.coffee +++ b/src/dom-utils.coffee @@ -316,6 +316,14 @@ DOMUtils = else continue return null + # Only looks down node trees with one child for a text node. + # Returns null if there's no single text node + findOnlyChildTextNode: (node) -> + return null unless node + return node if node.nodeType is Node.TEXT_NODE + return null if node.childNodes.length > 1 + return DOMUtils.findOnlyChildTextNode(node.childNodes[0]) + findFirstTextNode: (node) -> return null unless node return node if node.nodeType is Node.TEXT_NODE @@ -623,12 +631,24 @@ DOMUtils = # Modifies the DOM to "unwrap" a given node, replacing that node with its contents. # This may break selections containing the affected nodes. + # We don't use `document.createFragment` because the returned `fragment` + # would be empty and useless after its children get replaced. unwrapNode: (node) -> - fragment = document.createDocumentFragment() - while (child = node.firstChild) - fragment.appendChild(child) - node.parentNode.replaceChild(fragment, node) - return fragment + return node if node.childNodes.length is 0 + replacedNodes = [] + parent = node.parentNode + return node if not parent? + + lastChild = _.last(node.childNodes) + replacedNodes.unshift(lastChild) + parent.replaceChild(lastChild, node) + + while child = _.last(node.childNodes) + replacedNodes.unshift(child) + parent.insertBefore(child, lastChild) + lastChild = child + + return replacedNodes isDescendantOf: (node, matcher = -> false) -> parent = node?.parentElement diff --git a/src/extensions/composer-extension.coffee b/src/extensions/composer-extension.coffee index 3ce365b60..b4fdb9471 100644 --- a/src/extensions/composer-extension.coffee +++ b/src/extensions/composer-extension.coffee @@ -50,30 +50,30 @@ class ComposerExtension extends ContenteditableExtension @warningsForSending: ({draft}) -> [] - ### - Public: declare an icon to be displayed in the composer's toolbar (where - bold, italic, underline, etc are). - - You must return an object that contains the following properties: - - - `mutator`: A function that's called when your toolbar button is - 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} - ### - @composerToolbar: -> - return + # ### + # Public: declare an icon to be displayed in the composer's toolbar (where + # bold, italic, underline, etc are). + # + # You must return an object that contains the following properties: + # + # - `mutator`: A function that's called when your toolbar button is + # 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} + # ### + # @composerToolbar: -> + # return ### Public: Override prepareNewDraft to modify a brand new draft before it diff --git a/src/extensions/contenteditable-extension.coffee b/src/extensions/contenteditable-extension.coffee index 09e947754..2a2058226 100644 --- a/src/extensions/contenteditable-extension.coffee +++ b/src/extensions/contenteditable-extension.coffee @@ -148,4 +148,73 @@ class ContenteditableExtension ### @onShowContextMenu: ({editor, event, menu}) -> + ### + Public: Override `keyCommandHandlers` to declaratively map keyboard + commands to callbacks. + + Return an object keyed by the command name whose values are the + callbacks. + + Callbacks are automatically bound to the Contenteditable context and + passed `({editor, event})` as its argument. + + New commands are defined in keymap.cson files. + ### + @keyCommandHandlers: => + + ### + Public: Override `toolbarButtons` to declaratively add your own button + to the composer's toolbar. + + - toolbarState: The current state of the Toolbar and Composer. This is + Read only. + + Must return an array of objects obeying the following spec: + - className: A string class name + - onClick: Callback to fire when your button is clicked. The callback + is automatically bound to the editor and will get passed an single + object with the following args. + - editor - The {Editor} controller for manipulating the DOM + - event - The click Event object + - tooltip: A string to display when users hover over your button + - iconUrl: A url for the icon. + ### + @toolbarButtons: ({toolbarState}) -> + + ### + Public: Override `toolbarComponentConfig` to declaratively show your own + toolbar when certain conditions are met. + + If you want to hide your toolbar component, return null. + + If you want to display your toolbar, then return an object with the + signature indicated below. + + This methods gets called anytime the `toolbarState` changes. Since + `toolbarState` includes the current value of the Selection and any + objects a user is hovering over, you should expect it to change very + frequently. + + - toolbarState: The current state of the Toolbar and Composer. This is + Read only. + - dragging + - doubleDown + - hoveringOver + - editableNode + - exportedSelection + - extensions + - atomicEdit + + Must return an object with the following signature + - component: A React component or null. + - props: Props to be passed into your custom Component + - locationRefNode: Anything (usually a DOM Node) that responds to + `getBoundingClientRect`. This is used to determine where to display + your component. + - width: The width of your component. This is necessary because when + your component is displayed in the {FloatingToolbar}, the position is + pre-computed based on the absolute width of the item. + ### + @toolbarComponentConfig: ({toolbarState}) -> + module.exports = ContenteditableExtension diff --git a/src/nylas-env.coffee b/src/nylas-env.coffee index 8cb459721..9102cc929 100644 --- a/src/nylas-env.coffee +++ b/src/nylas-env.coffee @@ -270,7 +270,8 @@ class NylasEnvConstructor extends Model @emitError(error) emitError: (error) -> - console.error(error) unless @inSpecMode() + console.error(error.message) unless @inSpecMode() + console.error(error.stack) unless @inSpecMode() eventObject = {message: error.message, originalError: error} @emitter.emit('will-throw-error', eventObject) @emit('uncaught-error', error.message, null, null, null, error) diff --git a/src/regexp-utils.coffee b/src/regexp-utils.coffee index e38838838..2acf00ceb 100644 --- a/src/regexp-utils.coffee +++ b/src/regexp-utils.coffee @@ -1,3 +1,4 @@ +_ = require('underscore') RegExpUtils = # It's important that the regex be wrapped in parens, otherwise @@ -19,6 +20,14 @@ RegExpUtils = # https://regex101.com/r/zG7aW4/3 imageTagRegex: -> /]*src="([^"]*)"[^>]*>/g + punctuation: ({exclude}={}) -> + exclude ?= [] + punctuation = [ '.', ',', '\\/', '#', '!', '$', '%', '^', '&', '*', + ';', ':', '{', '}', '=', '\\-', '_', '`', '~', '(', ')', '@', '+', + '?', '>', '<', '\\[', '\\]', '+' ] + punctuation = _.difference(punctuation, exclude).join('') + return new RegExp("[#{punctuation}]", 'g') + # This tests for valid schemes as per RFC 3986 # We need both http: https: and mailto: and a variety of other schemes. # This does not check for invalid usage of the http: scheme. For diff --git a/static/components/contenteditable.less b/static/components/contenteditable.less index c7f29fddf..2aac6a11d 100644 --- a/static/components/contenteditable.less +++ b/static/components/contenteditable.less @@ -10,6 +10,10 @@ div[contenteditable], .contenteditable { flex: 1; + + a:hover { + cursor: text; + } } spelling.misspelled { @@ -30,9 +34,7 @@ transition-duration: .15s; transition-property: opacity, margin; - opacity: 0; - visibility: hidden; - margin-top: 3px; + margin-top: 0; &.toolbar-visible { opacity: 1;