From e7868df1ad99d762849e5af0c9a2ab808d9d7879 Mon Sep 17 00:00:00 2001 From: Evan Morikawa Date: Mon, 2 Mar 2015 15:33:58 -0800 Subject: [PATCH] feat(contenteditable): a React compatible contenteditable Summary: toolbar popup displays restore caret protection on contenteditable BAD - can't use cursor saving and restoring with react :( _findNode works saves and restores cursor state contenteditable fixes to support cursor comments on cursor initial undo manager extract undo manager and move up in stack make undo manager a mixin adding selection snapshots in composer fixes in undo manager selection saves selection states properly move UndoManager and fix draft selection state can now select backwards selection works backwards and click not overridden change bold class to allow for bolding and unbolding styling of hover component can set links in composer bold and italic clicking works. text seleciton works show link modal on hover selection fixes Test Plan: TODO Reviewers: bengotow Reviewed By: bengotow Differential Revision: https://review.inboxapp.com/D1249 --- exports/inbox-exports.coffee | 5 +- .../composer/lib/composer-view.cjsx | 97 +++- .../lib/contenteditable-component.cjsx | 520 ++++++++++++++++-- .../composer/lib/contenteditable-toolbar.cjsx | 73 +-- .../composer/lib/floating-toolbar.cjsx | 157 ++++++ .../spec/contenteditable-component-spec.cjsx | 30 +- .../composer/stylesheets/composer.less | 87 ++- .../inbox-light-ui/stylesheets/ui-mixins.less | 8 +- .../message-list/lib/message-item.cjsx | 11 +- .../message-list/spec/message-item-spec.cjsx | 8 +- keymaps/base.cson | 8 + keymaps/darwin.cson | 5 + keymaps/linux.cson | 5 + keymaps/win32.cson | 5 + src/flux/inbox-api.coffee | 3 +- src/flux/undo-manager.coffee | 34 ++ static/components/extra.less | 8 +- 17 files changed, 925 insertions(+), 139 deletions(-) create mode 100644 internal_packages/composer/lib/floating-toolbar.cjsx create mode 100644 src/flux/undo-manager.coffee 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 {