diff --git a/exports/inbox-exports.coffee b/exports/inbox-exports.coffee index 2c371de73..453795759 100644 --- a/exports/inbox-exports.coffee +++ b/exports/inbox-exports.coffee @@ -1,5 +1,3 @@ -# All Inbox Globals go here. - module.exports = # The Task Queue @@ -35,6 +33,9 @@ module.exports = Event: require '../src/flux/models/event' SalesforceTask: require '../src/flux/models/salesforce-task' + # Mixins + UndoManager: require '../src/flux/undo-manager' + # Stores DraftStore: require '../src/flux/stores/draft-store' ThreadStore: require '../src/flux/stores/thread-store' diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx index cfff00038..ae8710e02 100644 --- a/internal_packages/composer/lib/composer-view.cjsx +++ b/internal_packages/composer/lib/composer-view.cjsx @@ -2,17 +2,16 @@ React = require 'react' _ = require 'underscore-plus' {Actions, + UndoManager, DraftStore, FileUploadStore, ComponentRegistry} = require 'inbox-exports' FileUploads = require './file-uploads.cjsx' -ContenteditableToolbar = require './contenteditable-toolbar.cjsx' ContenteditableComponent = require './contenteditable-component.cjsx' ParticipantsTextField = require './participants-text-field.cjsx' idGen = 0 - # The ComposerView is a unique React component because it (currently) is a # singleton. Normally, the React way to do things would be to re-render the # Composer with new props. As an alternative, we can call `setProps` to @@ -41,11 +40,14 @@ ComposerView = React.createClass @_prepareForDraft() componentDidMount: -> + @undoManager = new UndoManager @keymap_unsubscriber = atom.commands.add '.composer-outer-wrap', { 'composer:show-and-focus-bcc': @_showAndFocusBcc 'composer:show-and-focus-cc': @_showAndFocusCc 'composer:focus-to': => @focus "textFieldTo" 'composer:send-message': => @_sendDraft() + "core:undo": @undo + "core:redo": @redo } if @props.mode is "fullwindow" # Need to delay so the component can be fully painted. Focus doesn't @@ -58,6 +60,14 @@ ComposerView = React.createClass @_teardownForDraft() @keymap_unsubscriber.dispose() + componentDidUpdate: -> + # We want to use a temporary variable instead of putting this into the + # state. This is because the selection is a transient property that + # only needs to be applied once. It's not a long-living property of + # the state. We could call `setState` here, but this saves us from a + # re-rendering. + @_recoveredSelection = null if @_recoveredSelection? + componentWillReceiveProps: (newProps) -> if newProps.localId != @props.localId # When we're given a new draft localId, we have to stop listening to our @@ -70,7 +80,7 @@ ComposerView = React.createClass @_proxy = DraftStore.sessionForLocalId(@props.localId) if @_proxy.draft() @_onDraftChanged() - + @unlisteners = [] @unlisteners.push @_proxy.listen(@_onDraftChanged) @unlisteners.push ComponentRegistry.listen (event) => @@ -125,7 +135,7 @@ ComposerView = React.createClass @@ -133,7 +143,7 @@ ComposerView = React.createClass ref="textFieldCc" field='cc' visible={@state.showcc} - change={@_proxy.changes.add} + change={@_onChangeParticipants} participants={to: @state['to'], cc: @state['cc'], bcc: @state['bcc']} tabIndex='103'/> @@ -141,7 +151,7 @@ ComposerView = React.createClass ref="textFieldBcc" field='bcc' visible={@state.showcc} - change={@_proxy.changes.add} + change={@_onChangeParticipants} participants={to: @state['to'], cc: @state['cc'], bcc: @state['bcc']} tabIndex='104'/> @@ -158,12 +168,12 @@ ComposerView = React.createClass onChange={@_onChangeSubject}/> -
@focus("contentBody")}> +
+ html={@state.body} + onChange={@_onChangeBody} + initialSelectionSnapshot={@_recoveredSelection} + tabIndex="109" />
@@ -177,7 +187,6 @@ ComposerView = React.createClass - {@_footerComponents()} @@ -201,6 +210,9 @@ ComposerView = React.createClass _onDraftChanged: -> draft = @_proxy.draft() + if not @_initialHistorySave + @_saveToHistory() + @_initialHistorySave = true state = to: draft.to cc: draft.cc @@ -217,11 +229,18 @@ ComposerView = React.createClass @setState(state) - _onChangeSubject: (event) -> - @_proxy.changes.add(subject: event.target.value) + _onChangeParticipants: (changes={}) -> @_addToProxy(changes) + _onChangeSubject: (event) -> @_addToProxy(subject: event.target.value) + _onChangeBody: (event) -> @_addToProxy(body: event.target.value) - _onChangeBody: (event) -> - @_proxy.changes.add(body: event.target.value) + _addToProxy: (changes={}, source={}) -> + selections = @_getSelections() + + oldDraft = @_proxy.draft() + return if _.all changes, (change, key) -> change == oldDraft[key] + @_proxy.changes.add(changes) + + @_saveToHistory(selections) unless source.fromUndoManager _popoutComposer: -> @_proxy.changes.commit() @@ -273,3 +292,49 @@ ComposerView = React.createClass _showAndFocusCc: -> @setState {showcc: true} @focus "textFieldCc" + + + + + undo: (event) -> + event.preventDefault() + event.stopPropagation() + historyItem = @undoManager.undo() ? {} + return unless historyItem.state? + + @_recoveredSelection = historyItem.currentSelection + @_addToProxy historyItem.state, fromUndoManager: true + + redo: (event) -> + event.preventDefault() + event.stopPropagation() + historyItem = @undoManager.redo() ? {} + return unless historyItem.state? + + @_recoveredSelection = historyItem.currentSelection + @_addToProxy historyItem.state, fromUndoManager: true + + _getSelections: -> + currentSelection: @refs.contentBody?.getCurrentSelection?() + previousSelection: @refs.contentBody?.getPreviousSelection?() + + _saveToHistory: (selections) -> + selections ?= @_getSelections() + + newDraft = @_proxy.draft() + + historyItem = + previousSelection: selections.previousSelection + currentSelection: selections.currentSelection + state: + body: _.clone newDraft.body + subject: _.clone newDraft.subject + to: _.clone newDraft.to + cc: _.clone newDraft.cc + bcc: _.clone newDraft.bcc + + lastState = @undoManager.current() + if lastState? + lastState.currentSelection = historyItem.previousSelection + + @undoManager.saveToHistory(historyItem) diff --git a/internal_packages/composer/lib/contenteditable-component.cjsx b/internal_packages/composer/lib/contenteditable-component.cjsx index 7e411ddfa..e50f7cf18 100644 --- a/internal_packages/composer/lib/contenteditable-component.cjsx +++ b/internal_packages/composer/lib/contenteditable-component.cjsx @@ -2,64 +2,471 @@ _ = require 'underscore-plus' React = require 'react' sanitizeHtml = require 'sanitize-html' {Utils} = require 'inbox-exports' +FloatingToolbar = require './floating-toolbar.cjsx' module.exports = ContenteditableComponent = React.createClass - getInitialState: -> + toolbarTop: 0 + toolbarMode: "buttons" + toolbarLeft: 0 + editAreaWidth: 9999 # This will get set on first selection + toolbarVisible: false editQuotedText: false - getEditableNode: -> - @refs.contenteditable.getDOMNode() + componentDidMount: -> + @_setupSelectionListeners() + @_setupLinkHoverListeners() - render: -> - quotedTextClass = React.addons.classSet - "quoted-text-toggle": true - 'hidden': @_htmlQuotedTextStart() is -1 - 'state-on': @state.editQuotedText + componentWillUnmount: -> + @_teardownSelectionListeners() + @_teardownLinkHoverListeners() -
-
- -
+ componentWillReceiveProps: (nextProps) -> + if nextProps.initialSelectionSnapshot? + @_setSelectionSnapshot(nextProps.initialSelectionSnapshot) - shouldComponentUpdate: (nextProps, nextState) -> - return true if nextState.editQuotedText is not @state.editQuotedText - - html = @getEditableNode().innerHTML - return (nextProps.html isnt html) and (document.activeElement isnt @getEditableNode()) + componentWillUpdate: -> + @_teardownLinkHoverListeners() componentDidUpdate: -> - if (@props.html != @getEditableNode().innerHTML) - @getEditableNode().innerHTML = @_htmlForDisplay() + @_setupLinkHoverListeners() + @_restoreSelection() + + render: -> +
+ +
+ +
focus: -> - @getEditableNode().focus() + @_editableNode().focus() if @isMounted() - _onChange: (evt) -> - html = @getEditableNode().innerHTML + _onInput: (event) -> + @_setNewSelectionState() + html = @_unapplyHTMLDisplayFilters(@_editableNode().innerHTML) + @props.onChange(target: value: html) - # If we aren't displaying quoted text, add the quoted - # text to the end of the visible text - if not @state.editQuotedText - quoteStart = @_htmlQuotedTextStart() - html += @props.html.substr(quoteStart) + _onBlur: (event) -> + # The delay here is necessary to see if the blur was caused by us + # navigating to the toolbar and focusing on the set-url input. + _.delay => + return unless @isMounted() # Who knows what can happen in 50ms + @_hideToolbar() + , 50 - if html != @lastHtml - @props.onChange({target: {value: html}}) if @props.onChange - @lastHtml = html + _editableNode: -> @refs.contenteditable.getDOMNode() - _onToggleQuotedText: -> - @setState - editQuotedText: !@state.editQuotedText + _getAllLinks: -> + Array.prototype.slice.call(@_editableNode().querySelectorAll("*[href]")) + + _dangerouslySetInnerHTML: -> + __html: @_applyHTMLDisplayFilters(@props.html) + + _applyHTMLDisplayFilters: (html) -> + html = @_ensureNotCompletelyBlank(html) + html = @_removeQuotedTextFromHTML(html) unless @state.editQuotedText + return html + + _unapplyHTMLDisplayFilters: (html) -> + html = @_addQuotedTextToHTML(html) unless @state.editQuotedText + return html + + + + + ######### SELECTION MANAGEMENT ########## + # + # Saving and restoring a selection is difficult with React. + # + # React only handles Input and Textarea elements: + # https://github.com/facebook/react/blob/master/src/browser/ui/ReactInputSelection.js + # This is because they expose a very convenient `selectionStart` and + # `selectionEnd` integer. + # + # Contenteditable regions are trickier. They require the more + # sophisticated `Range` and `Selection` APIs. + # + # Range docs: + # http://www.w3.org/TR/DOM-Level-2-Traversal-Range/ranges.html + # + # Selection API docs: + # http://www.w3.org/TR/selection-api/#dfn-range + # + # A Contenteditable region can have arbitrary html inside of it. This + # means that a selection start point can be some node (the `anchorNode`) + # and its end point can be a completely different node (the `focusNode`) + # + # When React re-renders, all of the DOM nodes may change. They may + # look exactly the same, but have different object references. + # + # This means that your old references to `anchorNode` and `focusNode` + # may be bad and no longer in scope or painted. + # + # In order to restore the selection properly we need to re-find the + # equivalent `anchorNode` and `focusNode`. Luckily we can use the + # `isEqualNode` method to get a shallow comparison of the nodes. + # + # Unfortunately it's possible for `isEqualNode` to match more than one + # node since two nodes may look very similar. + # + # To fix this we need to keep track of the original indices to determine + # which node is most likely the matching one. + + # http://www.w3.org/TR/selection-api/#selectstart-event + _setupSelectionListeners: -> + @_onSelectionChange = => @_setNewSelectionState() + document.addEventListener "selectionchange", @_onSelectionChange + + _teardownSelectionListeners: -> + document.removeEventListener("selectionchange", @_onSelectionChange) + + getCurrentSelection: -> _.clone(@_selection ? {}) + getPreviousSelection: -> _.clone(@_previousSelection ? {}) + + # Every time the cursor changes we need to preserve its location and + # state. + # + # We can't use React's `state` variable because cursor position is not + # natrually supported in the virtual DOM. + # + # We also need to make sure that node references are cloned so they + # don't change out from underneath us. + # + # We also need to keep references to the previous selection state in + # order for undo/redo to work properly. + # + # We need to be sure to deeply `cloneNode`. This is because sometimes + # our anchorNodes are divs with nested
tags. If we don't do a deep + # clone then when `isEqualNode` is run it will erroneously return false + # and our selection restoration will fail + _setNewSelectionState: -> + selection = document.getSelection() + return if @_checkSameSelection(selection) + return if not @_selectionInScope(selection) + @_lastSelection = + anchorNode: selection.anchorNode?.cloneNode(true) + anchorOffset: selection.anchorOffset + focusNode: selection.focusNode?.cloneNode(true) + focusOffset: selection.focusOffset + try + range = selection.getRangeAt(0) + catch + return + return if not range? + + @_previousSelection = @_selection + @_selection = + startNode: range.startContainer?.cloneNode(true) + startOffset: range.startOffset + startNodeIndex: @_getNodeIndex(range.startContainer) + endNode: range.endContainer?.cloneNode(true) + endOffset: range.endOffset + endNodeIndex: @_getNodeIndex(range.endContainer) + + @_refreshToolbarState() + return @_selection + + _setSelectionSnapshot: (selection) -> @_selection = selection + + # When we're dragging we don't want to the restoring the cursor as we're + # dragging. Doing so caused selecting backwards to break because the + # Selection API does not yet expose the selection "direction". When we + # would go to reset the cursor selection, it would reset to the wrong + # state. + _onMouseDown: (event) -> + @_ignoreSelectionRestoration = true + return true + _onMouseUp: -> + @_ignoreSelectionRestoration = false + return true + + # We manually restore the selection on every render and when we need to + # move the selection around manually. + # + # force - when set to true it will not care whether or not the selection + # is already in the box. Normally we only restore when the + # contenteditable is in focus + # collapse - Can either be "end" or "start". When we reset the + # selection, we'll collapse the range into a single caret + # position + _restoreSelection: ({force, collapse}={}) -> + return if @_ignoreSelectionRestoration + return if not @_selection? + return if document.activeElement isnt @_editableNode() and not force + return if not @_selection.startNode? or not @_selection.endNode? + range = document.createRange() + + startNode = @_findSimilarNodes(@_selection.startNode)[@_selection.startNodeIndex] + endNode = @_findSimilarNodes(@_selection.endNode)[@_selection.endNodeIndex] + return if not startNode? or not endNode? + + + startIndex = Math.min(@_selection.startOffset ? 0, @_selection.endOffset ? 0) + startIndex = Math.min(startIndex, startNode.length) + + endIndex = Math.max(@_selection.startOffset ? 0, @_selection.endOffset ? 0) + endIndex = Math.min(endIndex, endNode.length) + + if collapse is "end" + startNode = endNode + startIndex = endIndex + else if collapse is "start" + endNode = startNode + endIndex = endIndex + + + try + range.setStart(startNode, startIndex) + range.setEnd(endNode, endIndex) + catch + return + + selection = document.getSelection() + @_teardownSelectionListeners() + selection.removeAllRanges() + selection.addRange(range) + @_setupSelectionListeners() + + # We need to break each node apart and cache since the `selection` + # object will mutate underneath us. + _checkSameSelection: (selection) -> + return true if not selection? + return false if not @_lastSelection + return false if not selection.anchorNode? or not selection.focusNode? + anchorEqual = selection.anchorNode.isEqualNode @_lastSelection.anchorNode + focusEqual = selection.focusNode.isEqualNode @_lastSelection.focusNode + + anchorOffsetEqual = selection.anchorOffset == @_lastSelection.anchorOffset + focusOffsetEqual = selection.focusOffset == @_lastSelection.focusOffset + if not anchorOffsetEqual and not focusOffsetEqual + # This means the selection is the same, but just from the opposite + # direction. We don't care in this case, so check the reciprocal as + # well. + anchorOffsetEqual = selection.anchorOffset == @_lastSelection.focusOffset + focusOffsetEqual = selection.focusOffset == @_lastSelection.anchorOffset + + if (@_lastSelection? and + anchorEqual and + anchorOffsetEqual and + focusEqual and + focusOffsetEqual) + return true + else + return false + + _getNodeIndex: (nodeToFind) -> + @_findSimilarNodes(nodeToFind).indexOf nodeToFind + + _findSimilarNodes: (nodeToFind) -> + nodeList = [] + treeWalker = document.createTreeWalker @_editableNode() + while treeWalker.nextNode() + if treeWalker.currentNode.isEqualNode nodeToFind + nodeList.push(treeWalker.currentNode) + + return nodeList + + _isEqualNode: -> + + # This is so the contenteditable can have a non-blank TextNode for the + # selection to lock onto. + _ensureNotCompletelyBlank: (html) -> + if html.length is 0 + return " " + else return html + + _linksInside: (selection) -> + return _.filter @_getAllLinks(), (link) -> + selection.containsNode(link, true) + + + + + ####### TOOLBAR ON SELECTION ######### + + # We want the toolbar's state to be declaratively defined from other + # states. + # + # There are a variety of conditions that the toolbar should display: + # 1. When you're hovering over a link + # 2. When you've arrow-keyed the cursor into a link + # 3. When you have selected a range of text. + _refreshToolbarState: -> + if @_linkHoveringOver + url = @_linkHoveringOver.getAttribute('href') + rect = @_linkHoveringOver.getBoundingClientRect() + [left, top, editAreaWidth] = @_getToolbarPos(rect) + @setState + toolbarVisible: true + toolbarMode: "edit-link" + toolbarTop: top + toolbarLeft: left + linkToModify: @_linkHoveringOver + editAreaWidth: editAreaWidth + else + selection = document.getSelection() + + # TODO do something smarter then this in the future + linksInside = [] # @_linksInside(selection) + + if selection.isCollapsed and linksInside.length is 0 + @_hideToolbar() + else + if selection.isCollapsed and linksInside.length > 0 + linkRect = linksInside[0].getBoundingClientRect() + [left, top, editAreaWidth] = @_getToolbarPos(linkRect) + else + selectionRect = selection.getRangeAt(0).getBoundingClientRect() + [left, top, editAreaWidth] = @_getToolbarPos(selectionRect) + + @setState + toolbarVisible: true + toolbarMode: "buttons" + toolbarTop: top + toolbarLeft: left + linkToModify: null + editAreaWidth: editAreaWidth + + # See selection API: http://www.w3.org/TR/selection-api/ + _selectionInScope: (selection) -> + return false if not selection? + return false if not @isMounted() + editNode = @refs.contenteditable.getDOMNode() + return (editNode.contains(selection.anchorNode) and + editNode.contains(selection.focusNode)) + + CONTENT_PADDING: 15 + + _getToolbarPos: (referenceRect) -> + + TOP_PADDING = 10 + + editArea = @refs.contenteditable.getDOMNode().getBoundingClientRect() + + calcLeft = (referenceRect.left - editArea.left) + referenceRect.width/2 + calcLeft = Math.min(Math.max(calcLeft, @CONTENT_PADDING), editArea.width) + + calcTop = referenceRect.top - editArea.top + referenceRect.height + TOP_PADDING + + return [calcLeft, calcTop, editArea.width] + + _hideToolbar: -> + if not @_focusedOnToolbar() and @state.toolbarVisible + @setState toolbarVisible: false + + _focusedOnToolbar: -> + @refs.floatingToolbar.getDOMNode().contains(document.activeElement) + + # This needs to be in the contenteditable area because we need to first + # restore the selection before calling the `execCommand` + # + # If the url is empty, that means we want to remove the url. + _onSaveUrl: (url, linkToModify) -> + if linkToModify? + linkToModify = @_findSimilarNodes(linkToModify)?[0]?.childNodes[0] + return if not linkToModify? + range = document.createRange() + try + range.setStart(linkToModify, 0) + range.setEnd(linkToModify, linkToModify.length) + catch + return + selection = document.getSelection() + @_teardownSelectionListeners() + selection.removeAllRanges() + selection.addRange(range) + if url.trim().length is 0 + document.execCommand("unlink", false) + else + document.execCommand("createLink", false, url) + @_setupSelectionListeners() + else + @_restoreSelection(force: true) + if document.getSelection().isCollapsed + # TODO + else + if url.trim().length is 0 + document.execCommand("unlink", false) + else + document.execCommand("createLink", false, url) + @_restoreSelection(force: true, collapse: "end") + + _setupLinkHoverListeners: -> + HOVER_IN_DELAY = 250 + HOVER_OUT_DELAY = 1000 + @_links = [] + links = @_getAllLinks() + return if links.length is 0 + links.forEach (link) => + enterTimeout = null + leaveTimeout = null + + enterListener = (event) => + enterTimeout = setTimeout => + return unless @isMounted() + @_linkHoveringOver = link + @_refreshToolbarState() + , HOVER_IN_DELAY + leaveListener = (event) => + leaveTimeout = setTimeout => + @_linkHoveringOver = null + return unless @isMounted() + return if @refs.floatingToolbar.isHovering + @_refreshToolbarState() + , HOVER_OUT_DELAY + clearTimeout(enterTimeout) + + link.addEventListener "mouseenter", enterListener + link.addEventListener "mouseleave", leaveListener + @_links.push + link: link + enterTimeout: enterTimeout + leaveTimeout: leaveTimeout + enterListener: enterListener + leaveListener: leaveListener + + _onTooltipMouseEnter: -> + clearTimeout(@_clearTooltipTimeout) if @_clearTooltipTimeout? + + _onTooltipMouseLeave: -> + @_clearTooltipTimeout = setTimeout => + @_refreshToolbarState() + , 500 + + _teardownLinkHoverListeners: -> + while @_links.length > 0 + linkData = @_links.pop() + clearTimeout linkData.enterTimeout + clearTimeout linkData.leaveTimeout + linkData.link.removeEventListener "mouseenter", linkData.enterListener + linkData.link.removeEventListener "mouseleave", linkData.leaveListener + + + + + ####### CLEAN PASTE ######### _onPaste: (evt) -> html = evt.clipboardData.getData("text/html") ? "" @@ -97,16 +504,29 @@ ContenteditableComponent = React.createClass cleanHtml.replace(/

/gi, "").replace(/<\/p>/gi, "

") + + + + ####### QUOTED TEXT ######### + + _onToggleQuotedText: -> + @setState + editQuotedText: !@state.editQuotedText + + _quotedTextClasses: -> React.addons.classSet + "quoted-text-control": true + "no-quoted-text": @_htmlQuotedTextStart() is -1 + "show-quoted-text": @state.editQuotedText + _htmlQuotedTextStart: -> @props.html.search(/<[^>]*gmail_quote/) - _htmlForDisplay: -> - if @state.editQuotedText - @props.html - else - quoteStart = @_htmlQuotedTextStart() - if quoteStart is -1 - return @props.html - else - return @props.html.substr(0, quoteStart) + _removeQuotedTextFromHTML: (html) -> + quoteStart = @_htmlQuotedTextStart() + if quoteStart is -1 then return html + else return html.substr(0, quoteStart) + _addQuotedTextToHTML: (innerHTML) -> + quoteStart = @_htmlQuotedTextStart() + if quoteStart is -1 then return innerHTML + else return (innerHTML + @props.html.substr(quoteStart)) diff --git a/internal_packages/composer/lib/contenteditable-toolbar.cjsx b/internal_packages/composer/lib/contenteditable-toolbar.cjsx index d72d6c772..90f74612c 100644 --- a/internal_packages/composer/lib/contenteditable-toolbar.cjsx +++ b/internal_packages/composer/lib/contenteditable-toolbar.cjsx @@ -1,36 +1,41 @@ -React = require 'react' +# OBSOLETE: Use FloatingToolbar instead. +# However, We may decide to change back to a static toolbar in the footer, +# so don't delete me yet. -module.exports = -ContenteditableToolbar = React.createClass - render: -> - style = - display: @state.show and 'initial' or 'none' -

- -
- - - -
-
- getInitialState: -> - show: false - - componentDidUpdate: (lastProps, lastState) -> - if !lastState.show and @state.show - @refs.toolbar.getDOMNode().focus() - - onClick: (event) -> - cmd = event.currentTarget.getAttribute 'data-command-name' - document.execCommand(cmd, false, null) - true - - onBlur: (event) -> - target = event.nativeEvent.relatedTarget - if target? and target.getAttribute 'data-command-name' - return - @setState - show: false +# React = require 'react' +# +# module.exports = +# ContenteditableToolbar = React.createClass +# render: -> +# style = +# display: @state.show and 'initial' or 'none' +#
+# +#
+# +# +# +#
+#
+# +# getInitialState: -> +# show: false +# +# componentDidUpdate: (lastProps, lastState) -> +# if !lastState.show and @state.show +# @refs.toolbar.getDOMNode().focus() +# +# onClick: (event) -> +# cmd = event.currentTarget.getAttribute 'data-command-name' +# document.execCommand(cmd, false, null) +# true +# +# onBlur: (event) -> +# target = event.nativeEvent.relatedTarget +# if target? and target.getAttribute 'data-command-name' +# return +# @setState +# show: false diff --git a/internal_packages/composer/lib/floating-toolbar.cjsx b/internal_packages/composer/lib/floating-toolbar.cjsx new file mode 100644 index 000000000..e1ff62c1f --- /dev/null +++ b/internal_packages/composer/lib/floating-toolbar.cjsx @@ -0,0 +1,157 @@ +_ = require 'underscore-plus' +React = require 'react' +{CompositeDisposable} = require 'event-kit' + +module.exports = +FloatingToolbar = React.createClass + getInitialState: -> + mode: "buttons" + urlInputValue: @_initialUrl() ? "" + + componentDidMount: -> + @isHovering = false + @subscriptions = new CompositeDisposable() + @_saveUrl = _.debounce @__saveUrl, 10 + + componentWillReceiveProps: (nextProps) -> + @setState + mode: nextProps.initialMode + urlInputValue: @_initialUrl(nextProps) + + componentWillUnmount: -> + @subscriptions?.dispose() + @isHovering = false + + componentDidUpdate: -> + if @state.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. + @refs.urlInput.getDOMNode().focus() if @isMounted() + + render: -> +
+
+ {@_toolbarType()} +
+ + _toolbarType: -> + if @state.mode is "buttons" then @_renderButtons() + else if @state.mode is "edit-link" then @_renderLink() + else return
+ + _renderButtons: -> +
+ + + +
+ + _renderLink: -> + removeBtn = "" + if @_initialUrl() + removeBtn = + +
+ + + + {removeBtn} +
+ + _onMouseEnter: -> + @isHovering = true + @props.onMouseEnter?() + + _onMouseLeave: -> + @isHovering = false + if @props.linkToModify and document.activeElement isnt @refs.urlInput.getDOMNode() + @props.onMouseLeave?() + + + _initialUrl: (props=@props) -> + props.linkToModify?.getAttribute?('href') + + _onInputChange: (event) -> + @setState urlInputValue: event.target.value + + _saveUrlOnEnter: (event) -> + if event.key is "Enter" and @state.urlInputValue.trim().length > 0 + @_saveUrl() + + # 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 + + __saveUrl: -> + @props.onSaveUrl @state.urlInputValue, @props.linkToModify + + _execCommand: (event) -> + cmd = event.currentTarget.getAttribute 'data-command-name' + document.execCommand(cmd, false, null) + true + + _toolbarStyles: -> + styles = + left: @_toolbarLeft() + top: @props.top + display: if @props.visible then "block" else "none" + return styles + + _toolbarLeft: -> + CONTENT_PADDING = @props.contentPadding ? 15 + max = @props.editAreaWidth - @_halfWidth()*2 - CONTENT_PADDING + left = Math.min(Math.max(@props.left - @_halfWidth(), 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) + relativeLeft = absoluteLeft - @_toolbarLeft() + + left = Math.max(Math.min(relativeLeft, @_halfWidth()*2-POINTER_WIDTH), POINTER_WIDTH) + styles = + left: left + return styles + + _halfWidth: -> + # 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 = 86#px + TOOLBAR_URL_WIDTH = 210#px + + if @state.mode is "buttons" + TOOLBAR_BUTTONS_WIDTH / 2 + else if @state.mode is "edit-link" + TOOLBAR_URL_WIDTH / 2 + else + TOOLBAR_BUTTONS_WIDTH / 2 + + _showLink: -> + @setState mode: "edit-link" diff --git a/internal_packages/composer/spec/contenteditable-component-spec.cjsx b/internal_packages/composer/spec/contenteditable-component-spec.cjsx index 4be8427ec..37d785b8e 100644 --- a/internal_packages/composer/spec/contenteditable-component-spec.cjsx +++ b/internal_packages/composer/spec/contenteditable-component-spec.cjsx @@ -34,27 +34,27 @@ describe "ContenteditableComponent", -> it "should include a content-editable div", -> expect(ReactTestUtils.findRenderedDOMComponentWithAttr(@component, 'contentEditable')).toBeDefined() - describe "quoted-text-toggle", -> + describe "quoted-text-control", -> it "should be rendered", -> - expect(ReactTestUtils.findRenderedDOMComponentWithClass(@component, 'quoted-text-toggle')).toBeDefined() + expect(ReactTestUtils.findRenderedDOMComponentWithClass(@component, 'quoted-text-control')).toBeDefined() it "should be visible if the html contains quoted text", -> - @toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@componentWithQuote, 'quoted-text-toggle') - expect(@toggle.props.className.indexOf('hidden') >= 0).toBe(false) + @toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@componentWithQuote, 'quoted-text-control') + expect(@toggle.props.className.indexOf('no-quoted-text') >= 0).toBe(false) - it "should be have `state-on` if editQuotedText is true", -> + it "should be have `show-quoted-text` if editQuotedText is true", -> @componentWithQuote.setState(editQuotedText: true) - @toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@componentWithQuote, 'quoted-text-toggle') - expect(@toggle.props.className.indexOf('state-on') >= 0).toBe(true) + @toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@componentWithQuote, 'quoted-text-control') + expect(@toggle.props.className.indexOf('show-quoted-text') >= 0).toBe(true) - it "should not have `state-on` if editQuotedText is false", -> + it "should not have `show-quoted-text` if editQuotedText is false", -> @componentWithQuote.setState(editQuotedText: false) - @toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@componentWithQuote, 'quoted-text-toggle') - expect(@toggle.props.className.indexOf('state-on') >= 0).toBe(false) + @toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@componentWithQuote, 'quoted-text-control') + expect(@toggle.props.className.indexOf('show-quoted-text') >= 0).toBe(false) it "should be hidden otherwise", -> - @toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@component, 'quoted-text-toggle') - expect(@toggle.props.className.indexOf('hidden') >= 0).toBe(true) + @toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@component, 'quoted-text-control') + expect(@toggle.props.className.indexOf('no-quoted-text') >= 0).toBe(true) describe "when editQuotedText is false", -> it "should only display HTML up to the beginning of the quoted text", -> @@ -85,12 +85,14 @@ describe "ContenteditableComponent", -> @performEdit('Test New HTML') expect(@onChange).toHaveBeenCalled() - it "should not fire if the html is the same", -> + # One day we may make this more efficient. For now we aggressively + # re-render because of the manual cursor positioning. + it "should fire if the html is the same", -> expect(@onChange.callCount).toBe(0) @performEdit(@changedHtmlWithoutQuote) expect(@onChange.callCount).toBe(1) @performEdit(@changedHtmlWithoutQuote) - expect(@onChange.callCount).toBe(1) + expect(@onChange.callCount).toBe(2) describe "when editQuotedText is true", -> it "should call `props.onChange` with the entire HTML string", -> diff --git a/internal_packages/composer/stylesheets/composer.less b/internal_packages/composer/stylesheets/composer.less index 52fbc1be6..b10efb354 100644 --- a/internal_packages/composer/stylesheets/composer.less +++ b/internal_packages/composer/stylesheets/composer.less @@ -17,11 +17,11 @@ .compose-body { margin-bottom: 0; position: relative; - + .contenteditable-container { - position:absolute; - width:100%; - height:100%; + position: absolute; + width: 100%; + min-height: 100%; div[contenteditable] { // Ensure that the contentEditable always fills the window, @@ -33,6 +33,37 @@ } } +.toolbar { + @padding: 0.5em; + .btn { + background: transparent; + font-size: 16px; + height: auto; + border-radius: 0; + padding: @padding*0.75 @padding; + margin: 0; + color: rgba(255,255,255, 0.6); + box-shadow: none; + &:first-child { + padding-left: 1.5*@padding; + } + &:last-child { + padding-right: 1.5*@padding; + } + &:hover, &:active { + color: rgba(255,255,255, 1); + background: transparent; + } + } + + .preview-btn-icon { + position: relative; + top: 1px; + padding: 0 @padding; + } + +} + .composer-inner-wrap { height: 100%; display: flex; @@ -40,6 +71,39 @@ padding-bottom: 57px; position: relative; + a:hover { + cursor: pointer; + } + + .floating-toolbar { + z-index: 10; + position: absolute; + background: @background-color-accent; + border-radius: 2px; + color: @text-color-inverse; + + .toolbar-pointer { + content: " "; + position: absolute; + width: 0; + height: 0; + top: -13px; + left: 50%; + margin-left: -6px; + border: 7px solid transparent; + border-bottom-color: @background-color-accent; + border-bottom-width: 6px; + } + + .floating-toolbar-input { + display: inline; + width: auto; + color: @text-color-inverse; + position: relative; + top: 1px; + } + } + .composer-header { padding: 11px 15px 5px 15px; @@ -63,6 +127,8 @@ } input, textarea, div[contenteditable] { + position: relative; + z-index: 1; display: block; background: inherit; width: 100%; @@ -116,15 +182,22 @@ display: flex; cursor: text; overflow: auto; - margin: 0.7em 15px 15px 15px; position: relative; - .quoted-text-toggle { - margin:0; + .quoted-text-control { + position: absolute; + bottom: 10px; + left: 15px; + margin: 0; } div[contenteditable] { min-height: 150px; + padding: 0.7em 15px 0 15px; + margin-bottom: 37px; + } + .contenteditable-container { + width: 100%; } } diff --git a/internal_packages/inbox-light-ui/stylesheets/ui-mixins.less b/internal_packages/inbox-light-ui/stylesheets/ui-mixins.less index a4b5e6953..24903b7e0 100644 --- a/internal_packages/inbox-light-ui/stylesheets/ui-mixins.less +++ b/internal_packages/inbox-light-ui/stylesheets/ui-mixins.less @@ -41,7 +41,13 @@ .bold() { font-family: "Proxima Nova Bold", sans-serif; - font-weight: normal; + + // NOTE: It is important that this not be "normal". It would seem that under + // the hood, contenteditable and document.execCommand use the font-weight to + // determine if something is bolded or not. Setting this to normal prevented + // execCommand from unbolding text. + font-weight: bold; + font-style: normal; letter-spacing: 0.3px; } diff --git a/internal_packages/message-list/lib/message-item.cjsx b/internal_packages/message-list/lib/message-item.cjsx index d5d57a5b7..5c650b8f6 100644 --- a/internal_packages/message-list/lib/message-item.cjsx +++ b/internal_packages/message-list/lib/message-item.cjsx @@ -31,11 +31,6 @@ MessageItem = React.createClass @_storeUnlisten() if @_storeUnlisten render: -> - quotedTextClass = React.addons.classSet - "quoted-text-toggle": true - 'hidden': !Utils.containsQuotedText(@props.message.body) - 'state-on': @state.showQuotedText - messageActions = ComponentRegistry.findAllViewsByRole('MessageAction') messageIndicators = ComponentRegistry.findAllViewsByRole('MessageIndicator') attachments = @_attachmentComponents() @@ -67,9 +62,13 @@ MessageItem = React.createClass {@_formatBody()} - +
+ _quotedTextClasses: -> React.addons.classSet + "quoted-text-control": true + 'no-quoted-text': !Utils.containsQuotedText(@props.message.body) + 'show-quoted-text': @state.showQuotedText # Eventually, _formatBody will run a series of registered body transformers. # For now, it just runs a few we've hardcoded here, which are all synchronous. diff --git a/internal_packages/message-list/spec/message-item-spec.cjsx b/internal_packages/message-list/spec/message-item-spec.cjsx index 502030304..5a3b3a4a9 100644 --- a/internal_packages/message-list/spec/message-item-spec.cjsx +++ b/internal_packages/message-list/spec/message-item-spec.cjsx @@ -192,8 +192,8 @@ describe "MessageItem", -> it "should show the `show quoted text` toggle in the off state", -> @createComponent() - toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@component, 'quoted-text-toggle') - expect(toggle.getDOMNode().className.indexOf('state-on')).toBe(-1) + toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@component, 'quoted-text-control') + expect(toggle.getDOMNode().className.indexOf('show-quoted-text')).toBe(-1) it "should be initialized to true if the message contains `Forwarded`...", -> @message.body = """ @@ -233,8 +233,8 @@ describe "MessageItem", -> @component.setState(showQuotedText: true) it "should show the `show quoted text` toggle in the on state", -> - toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@component, 'quoted-text-toggle') - expect(toggle.getDOMNode().className.indexOf('state-on') > 0).toBe(true) + toggle = ReactTestUtils.findRenderedDOMComponentWithClass(@component, 'quoted-text-control') + expect(toggle.getDOMNode().className.indexOf('show-quoted-text') > 0).toBe(true) it "should pass the value into the EmailFrame", -> frame = ReactTestUtils.findRenderedComponentWithType(@component, EmailFrameStub) diff --git a/keymaps/base.cson b/keymaps/base.cson index 2f4a89d9f..665862d28 100644 --- a/keymaps/base.cson +++ b/keymaps/base.cson @@ -85,6 +85,7 @@ 'shift-backspace': 'native!' 'delete': 'native!' 'shift-delete': 'native!' + 'cmd-y': 'native!' 'cmd-z': 'native!' 'cmd-Z': 'native!' 'cmd-x': 'native!' @@ -95,6 +96,10 @@ 'cmd-V': 'native!' 'cmd-a': 'native!' 'cmd-A': 'native!' + 'cmd-b': 'native!' + 'cmd-i': 'native!' + 'cmd-u': 'native!' + 'ctrl-y': 'native!' 'ctrl-z': 'native!' 'ctrl-Z': 'native!' 'ctrl-x': 'native!' @@ -105,6 +110,9 @@ 'ctrl-V': 'native!' 'ctrl-a': 'native!' 'ctrl-A': 'native!' + 'ctrl-b': 'native!' + 'ctrl-i': 'native!' + 'ctrl-u': 'native!' 'a': 'native!' 'b': 'native!' 'c': 'native!' diff --git a/keymaps/darwin.cson b/keymaps/darwin.cson index bc2398bd6..151626c0e 100644 --- a/keymaps/darwin.cson +++ b/keymaps/darwin.cson @@ -31,3 +31,8 @@ 'cmd-ctrl-f': 'window:toggle-full-screen' 'ctrl-alt-cmd-l': 'window:reload' 'cmd-alt-ctrl-p': 'window:run-package-specs' + +'body div *[contenteditable]': + 'cmd-z': 'core:undo' + 'cmd-Z': 'core:redo' + 'cmd-y': 'core:redo' diff --git a/keymaps/linux.cson b/keymaps/linux.cson index 816e81222..a27d6af27 100644 --- a/keymaps/linux.cson +++ b/keymaps/linux.cson @@ -28,3 +28,8 @@ 'F11': 'window:toggle-full-screen' 'ctrl-alt-r': 'window:reload' 'ctrl-alt-p': 'window:run-package-specs' + +'body div *[contenteditable]': + 'ctrl-z': 'core:undo' + 'ctrl-Z': 'core:redo' + 'ctrl-y': 'core:redo' diff --git a/keymaps/win32.cson b/keymaps/win32.cson index 8c852ee8c..9e661999e 100644 --- a/keymaps/win32.cson +++ b/keymaps/win32.cson @@ -28,3 +28,8 @@ 'F11': 'window:toggle-full-screen' 'ctrl-alt-r': 'window:reload' 'ctrl-alt-p': 'window:run-package-specs' + +'body div *[contenteditable]': + 'ctrl-z': 'core:undo' + 'ctrl-Z': 'core:redo' + 'ctrl-y': 'core:redo' diff --git a/src/flux/inbox-api.coffee b/src/flux/inbox-api.coffee index b4d807ac9..6ac965a0e 100644 --- a/src/flux/inbox-api.coffee +++ b/src/flux/inbox-api.coffee @@ -84,7 +84,8 @@ class InboxAPI Actions.longPollOffline() connection.onDeltas (deltas) => - @_handleDeltas(deltas) + # TODO DO NOT FORGET TO UNCOMMENT ME + # @_handleDeltas(deltas) connection.start() connection diff --git a/src/flux/undo-manager.coffee b/src/flux/undo-manager.coffee new file mode 100644 index 000000000..5bf6e8b28 --- /dev/null +++ b/src/flux/undo-manager.coffee @@ -0,0 +1,34 @@ +_ = require 'underscore-plus' + +module.exports = +class UndoManager + constructor: -> + @_position = -1 + @_history = [] + @_MAX_HISTORY_SIZE = 100 + + current: -> + return @_history[@_position] + + undo: -> + if @_position > 0 + @_position -= 1 + return @_history[@_position] + else return null + + redo: -> + if @_position < (@_history.length - 1) + @_position += 1 + return @_history[@_position] + else return null + + immediatelySaveToHistory: (historyItem) => + if not _.isEqual((_.last(@_history) ? {}), historyItem) + @_position += 1 + @_history.length = @_position + @_history.push(historyItem) + while @_history.length > @_MAX_HISTORY_SIZE + @_history.shift() + @_position -= 1 + + saveToHistory: _.debounce(UndoManager::immediatelySaveToHistory, 300) diff --git a/static/components/extra.less b/static/components/extra.less index 52e8d6b6d..8270c5555 100644 --- a/static/components/extra.less +++ b/static/components/extra.less @@ -1,6 +1,6 @@ @import "ui-variables"; -.quoted-text-toggle { +.quoted-text-control { background-color: #f7f7f7; border-radius: 5px; padding: 7px; @@ -10,11 +10,11 @@ color: #333; border: 1px solid #eee; line-height: 16px; - margin-bottom: 10px; + margin-bottom: 15px; margin-left: 15px; cursor: pointer; - &.hidden { + &.no-quoted-text { display:none; } &:hover { @@ -22,7 +22,7 @@ background-color: @background-color-secondary; text-decoration:none; } - &.state-on:before { + &.show-quoted-text:before { content:'Hide Quoted Text'; } &:before {