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;