diff --git a/internal_packages/composer/lib/clipboard-service.coffee b/internal_packages/composer/lib/clipboard-service.coffee new file mode 100644 index 000000000..b8c6a8288 --- /dev/null +++ b/internal_packages/composer/lib/clipboard-service.coffee @@ -0,0 +1,91 @@ +{Utils} = require 'nylas-exports' +sanitizeHtml = require 'sanitize-html' + +class ClipboardService + constructor: ({@onFilePaste}={}) -> + + onPaste: (evt) => + return if evt.clipboardData.items.length is 0 + evt.preventDefault() + + # If the pasteboard has a file on it, stream it to a teporary + # file and fire our `onFilePaste` event. + item = evt.clipboardData.items[0] + + if item.kind is 'file' + blob = item.getAsFile() + ext = {'image/png': '.png', 'image/jpg': '.jpg', 'image/tiff': '.tiff'}[item.type] ? '' + temp = require 'temp' + path = require 'path' + fs = require 'fs' + + reader = new FileReader() + reader.addEventListener 'loadend', => + buffer = new Buffer(new Uint8Array(reader.result)) + tmpFolder = temp.path('-nylas-attachment') + tmpPath = path.join(tmpFolder, "Pasted File#{ext}") + fs.mkdir tmpFolder, => + fs.writeFile tmpPath, buffer, (err) => + @onFilePaste?(tmpPath) + reader.readAsArrayBuffer(blob) + + else + # Look for text/html in any of the clipboard items and fall + # back to text/plain. + inputText = evt.clipboardData.getData("text/html") ? "" + type = "text/html" + if inputText.length is 0 + inputText = evt.clipboardData.getData("text/plain") ? "" + type = "text/plain" + + if inputText.length > 0 + cleanHtml = @_sanitizeInput(inputText, type) + document.execCommand("insertHTML", false, cleanHtml) + + return + + # This is used primarily when pasting text in + _sanitizeInput: (inputText="", type="text/html") => + if type is "text/plain" + inputText = Utils.encodeHTMLEntities(inputText) + inputText = inputText.replace(/[\r\n]|[03];/g, "
"). + replace(/\s\s/g, "  ") + else + inputText = sanitizeHtml inputText.replace(/[\n\r]/g, "
"), + allowedTags: ['p', 'b', 'i', 'em', 'strong', 'a', 'br', 'img', 'ul', 'ol', 'li', 'strike'] + allowedAttributes: + a: ['href', 'name'] + img: ['src', 'alt'] + transformTags: + h1: "p" + h2: "p" + h3: "p" + h4: "p" + h5: "p" + h6: "p" + div: "p" + pre: "p" + blockquote: "p" + table: "p" + + # We sanitized everything and convert all whitespace-inducing + # elements into

tags. We want to de-wrap

tags and replace + # with two line breaks instead. + inputText = inputText.replace(//gim, ""). + replace(/<\/p>/gi, "
") + + # We never want more then 2 line breaks in a row. + # https://regex101.com/r/gF6bF4/4 + inputText = inputText.replace(/(\s*){3,}/g, "

") + + # We never want to keep leading and trailing , since the user + # would have started a new paragraph themselves if they wanted space + # before what they paste. + # BAD: "

begins at
12AM

" => "

begins at
12AM

" + # Better: "

begins at
12AM

" => "begins at
12" + inputText = inputText.replace(/^(
)+/, '') + inputText = inputText.replace(/(
)+$/, '') + + return inputText + +module.exports = ClipboardService diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx index 2952cf4fb..e1eb02fce 100644 --- a/internal_packages/composer/lib/composer-view.cjsx +++ b/internal_packages/composer/lib/composer-view.cjsx @@ -25,6 +25,7 @@ ImageFileUpload = require './image-file-upload' ExpandedParticipants = require './expanded-participants' CollapsedParticipants = require './collapsed-participants' +ContenteditableFilter = require './contenteditable-filter' ContenteditableComponent = require './contenteditable-component' Fields = require './fields' @@ -195,20 +196,20 @@ class ComposerView extends React.Component _renderContent: ->
{if @state.focusedField in Fields.ParticipantFields - + else - + } {@_renderSubject()} @@ -219,6 +220,9 @@ class ComposerView extends React.Component
+ _onPopoutComposer: => + Actions.composePopoutDraft @props.draftClientId + _onKeyDown: (event) => if event.key is "Tab" @_onTabDown(event) @@ -268,23 +272,55 @@ class ComposerView extends React.Component {@_renderAttachments()} - _renderBodyContenteditable: => - onScrollToBottom = null - if @props.onRequestScrollTo - onScrollToBottom = => - @props.onRequestScrollTo({clientId: @_proxy.draft().clientId}) + _renderBodyContenteditable: -> + @setState focusedField: Fields.Body} + filters={@_editableFilters()} + onChange={@_onChangeBody} + onScrollTo={@props.onRequestScrollTo} + onFilePaste={@_onFilePaste} + footerElements={@_editableFooterElements()} + onScrollToBottom={@_onScrollToBottom()} + initialSelectionSnapshot={@_recoveredSelection} /> - @setState focusedField: Fields.Body} - onChange={@_onChangeBody} - onFilePaste={@_onFilePaste} - style={@_precalcComposerCss} - initialSelectionSnapshot={@_recoveredSelection} - mode={{showQuotedText: @state.showQuotedText}} - onChangeMode={@_onChangeEditableMode} - onScrollTo={@props.onRequestScrollTo} - onScrollToBottom={onScrollToBottom} /> + _onScrollToBottom: -> + if @props.onRequestScrollTo + return => + @props.onRequestScrollTo({clientId: @_proxy.draft().clientId}) + else return null + + _editableFilters: -> + return [@_quotedTextFilter()] + + _quotedTextFilter: -> + filter = new ContenteditableFilter + filter.beforeDisplay = @_removeQuotedText + filter.afterDisplay = @_showQuotedText + return filter + + _editableFooterElements: -> + @_renderQuotedTextControl() + + _removeQuotedText: (html) => + if @state.showQuotedText then return html + else return QuotedHTMLParser.removeQuotedHTML(html) + + _showQuotedText: (html) => + if @state.showQuotedText then return html + else return QuotedHTMLParser.appendQuotedHTML(html, @state.body) + + _renderQuotedTextControl: -> + if QuotedHTMLParser.hasQuotedHTML(@state.body) + text = if @state.showQuotedText then "Hide" else "Show" + + •••{text} previous + + else return [] + + _onToggleQuotedText: => + @setState showQuotedText: not @state.showQuotedText _renderFooterRegions: => return
unless @props.draftClientId @@ -555,9 +591,6 @@ class ComposerView extends React.Component @_throttledTrigger() return - _onChangeEditableMode: ({showQuotedText}) => - @setState showQuotedText: showQuotedText - _addToProxy: (changes={}, source={}) => return unless @_proxy diff --git a/internal_packages/composer/lib/contenteditable-component.cjsx b/internal_packages/composer/lib/contenteditable-component.cjsx index 8dd792cb9..def8f1b90 100644 --- a/internal_packages/composer/lib/contenteditable-component.cjsx +++ b/internal_packages/composer/lib/contenteditable-component.cjsx @@ -1,60 +1,66 @@ _ = require 'underscore' React = require 'react' -classNames = require 'classnames' -sanitizeHtml = require 'sanitize-html' -{DOMUtils, Utils, QuotedHTMLParser, DraftStore} = require 'nylas-exports' -FloatingToolbar = require './floating-toolbar' -linkUUID = 0 -genLinkId = -> linkUUID += 1; return linkUUID +{Utils, + DOMUtils, + DraftStore} = require 'nylas-exports' + +ClipboardService = require './clipboard-service' +FloatingToolbarContainer = require './floating-toolbar-container' class ContenteditableComponent extends React.Component - @displayName = "Contenteditable" - @propTypes = + @displayName: "ContenteditableComponent" + @propTypes: html: React.PropTypes.string - style: React.PropTypes.object - tabIndex: React.PropTypes.string - onChange: React.PropTypes.func.isRequired - mode: React.PropTypes.object - onFilePaste: React.PropTypes.func - onChangeMode: React.PropTypes.func initialSelectionSnapshot: React.PropTypes.object + filters: React.PropTypes.object + footerElements: React.PropTypes.node + # Passes an absolute top coordinate to scroll to. + onChange: React.PropTypes.func.isRequired + onFilePaste: React.PropTypes.func onScrollTo: React.PropTypes.func onScrollToBottom: React.PropTypes.func + @defaultProps: + filters: [] + constructor: (@props) -> - @state = - toolbarTop: 0 - toolbarMode: "buttons" - toolbarLeft: 0 - toolbarPos: "above" - editAreaWidth: 9999 # This will get set on first selection - toolbarVisible: false + @innerState = {} + @_setupServices(@props) + + _setupServices: (props) -> + @clipboardService = new ClipboardService + onFilePaste: props.onFilePaste + + setInnerState: (innerState={}) -> + @innerState = _.extend @innerState, innerState + @refs["toolbarController"]?.componentWillReceiveInnerProps(innerState) componentDidMount: => @_editableNode().addEventListener('contextmenu', @_onShowContextualMenu) @_setupSelectionListeners() - @_setupLinkHoverListeners() @_setupGlobalMouseListener() @_disposable = atom.commands.add '.contenteditable-container *', { 'core:focus-next': (event) => editableNode = @_editableNode() - range = @_getRangeInScope() + range = DOMUtils.getRangeInScope(editableNode) for extension in DraftStore.extensions() extension.onFocusNext(editableNode, range, event) if extension.onFocusNext 'core:focus-previous': (event) => editableNode = @_editableNode() - range = @_getRangeInScope() + range = DOMUtils.getRangeInScope(editableNode) for extension in DraftStore.extensions() extension.onFocusPrevious(editableNode, range, event) if extension.onFocusPrevious } @_cleanHTML() + @setInnerState editableNode: @_editableNode() + shouldComponentUpdate: (nextProps, nextState) -> not Utils.isEqualReact(nextProps, @props) or not Utils.isEqualReact(nextState, @state) @@ -62,66 +68,48 @@ class ContenteditableComponent extends React.Component componentWillUnmount: => @_editableNode().removeEventListener('contextmenu', @_onShowContextualMenu) @_teardownSelectionListeners() - @_teardownLinkHoverListeners() @_teardownGlobalMouseListener() @_disposable.dispose() componentWillReceiveProps: (nextProps) => + @_setupServices(nextProps) if nextProps.initialSelectionSnapshot? @_setSelectionSnapshot(nextProps.initialSelectionSnapshot) - @_refreshToolbarState() - - componentWillUpdate: (nextProps, nextState) => - @_teardownLinkHoverListeners() componentDidUpdate: => @_cleanHTML() - @_setupLinkHoverListeners() + @_restoreSelection() editableNode = @_editableNode() for extension in DraftStore.extensions() - extension.onComponentDidUpdate(editableNode) if extension.onComponentDidUpdate + extension.onComponentDidUpdate(@_editableNode()) if extension.onComponentDidUpdate + + @setInnerState + links: editableNode.querySelectorAll("*[href]") + editableNode: editableNode render: =>
- + +
- {@_renderQuotedTextControl()} + {@props.footerElements}
- _renderQuotedTextControl: -> - if QuotedHTMLParser.hasQuotedHTML(@props.html) - text = if @props.mode?.showQuotedText then "Hide" else "Show" - - •••{text} previous - - else return null - focus: => @_editableNode().focus() @@ -134,6 +122,18 @@ class ContenteditableComponent extends React.Component selection.removeAllRanges() selection.addRange(range) + # When some other component (like the `FloatingToolbar` or some + # `DraftStoreExtension`) wants to mutate the DOM, it declares a + # `mutator` function. That mutator expects to be passed the latest DOM + # object (the `_editableNode()`) and will do mutations to it. Once those + # mutations are done, we need to be sure to notify that changes + # happened. + _onDomMutator: (mutator) => + @_teardownSelectionListeners() + mutator(@_editableNode()) + @_setupSelectionListeners() + @_onInput() + _onClick: (event) -> # We handle mouseDown, mouseMove, mouseUp, but we want to stop propagation # of `click` to make it clear that we've handled the event. @@ -143,30 +143,51 @@ class ContenteditableComponent extends React.Component _onKeyDown: (event) => if event.key is "Tab" @_onTabDown(event) + if event.key is "Backspace" + @_onBackspaceDown(event) + U = 85 + if event.which is U and (event.metaKey or event.ctrlKey) + event.preventDefault() + document.execCommand("underline") return - _onInput: => + _onInput: (event) => return if @_ignoreInputChanges @_ignoreInputChanges = true - @_dragging = false + + @_resetInnerStateOnInput() @_runCoreFilters() - for extension in DraftStore.extensions() - extension.onInput(@_editableNode(), event) if extension.onInput + @_runExtensionFilters(event) - @_prepareForReactContenteditable() + @_normalize() @_saveSelectionState() - html = @_unapplyHTMLDisplayFilters(@_editableNode().innerHTML) - @props.onChange(target: {value: html}) + @_saveNewHtml() + @_ignoreInputChanges = false return + _resetInnerStateOnInput: -> + @_justCreatedList = false + @setInnerState dragging: false if @innerState.dragging + @setInnerState doubleDown: false if @innerState.doubleDown + _runCoreFilters: -> @_createLists() + _runExtensionFilters: (event) -> + for extension in DraftStore.extensions() + extension.onInput(@_editableNode(), event) if extension.onInput + + _saveNewHtml: -> + html = @_editableNode().innerHTML + for filter in @props.filters + html = filter.afterDisplay(html) + @props.onChange(target: {value: html}) + # Determines if the user wants to add an ordered or unordered list. _createLists: -> # The `execCommand` will update the DOM and move the cursor. Since @@ -174,6 +195,10 @@ class ContenteditableComponent extends React.Component # the whole operation to look "atomic". As such we'll do any necessary # DOM cleanup and fire the `exec` command with the listeners off, then # re-enable at the end. + if @_resetListToText + @_resetListToText = false + return + updateDOM = (command) => @_teardownSelectionListeners() document.execCommand(command) @@ -183,10 +208,63 @@ class ContenteditableComponent extends React.Component text = @_textContentAtCursor() if (/^\d\.\s$/).test text + @_justCreatedList = text updateDOM("insertOrderedList") else if (/^[*-]\s$/).test text + @_justCreatedList = text updateDOM("insertUnorderedList") + _onBackspaceDown: (event) -> + if document.getSelection()?.isCollapsed + if @_atStartOfList() + li = @_closestAtCursor("li") + list = @_closestAtCursor("ul, ol") + return unless li and list + event.preventDefault() + if list.querySelectorAll('li')?[0] is li # We're in first li + if @_justCreatedList + @_resetListToText = true + @_replaceFirstListItem(li, @_justCreatedList) + else + @_replaceFirstListItem(li, "") + else + document.execCommand("outdent") + + _closestAtCursor: (selector) -> + selection = document.getSelection() + return unless selection?.isCollapsed + return selection.anchorNode?.closest(selector) + + _replaceFirstListItem: (li, replaceWith) -> + @_teardownSelectionListeners() + list = li.closest("ul, ol") + + if replaceWith.length is 0 + replaceWith = replaceWith.replace /\s/g, " " + text = document.createElement("div") + text.innerHTML = "
" + else + replaceWith = replaceWith.replace /\s/g, " " + text = document.createElement("span") + text.innerHTML = "#{replaceWith}" + + if list.querySelectorAll('li').length <= 1 + # Delete the whole list and replace with text + list.parentNode.replaceChild(text, list) + else + # Delete the list item and prepend the text before the rest of the + # list + li.parentNode.removeChild(li) + list.parentNode.insertBefore(text, list) + + child = text.childNodes[0] ? text + index = Math.max(replaceWith.length - 1, 0) + selection = document.getSelection() + selection.setBaseAndExtent(child, index, child, index) + + @_setupSelectionListeners() + @_onInput() + _onTabDown: (event) -> event.preventDefault() selection = document.getSelection() @@ -228,6 +306,16 @@ class ContenteditableComponent extends React.Component return selection.anchorNode.textContent[selection.anchorOffset - 1] is "\t" else return false + _atStartOfList: -> + selection = document.getSelection() + anchor = selection.anchorNode + return false if not selection.isCollapsed + return true if anchor?.nodeName is "LI" + return false if selection.anchorOffset > 0 + li = anchor.closest("li") + return unless li + return DOMUtils.isFirstChild(li, anchor) + _atBeginning: -> selection = document.getSelection() return false if not selection.isCollapsed @@ -261,7 +349,7 @@ class ContenteditableComponent extends React.Component # structures, a simple replacement of the DOM is not easy. There are a # variety of edge cases that we need to correct for and prepare both the # HTML and the selection to be serialized without error. - _prepareForReactContenteditable: -> + _normalize: -> @_cleanHTML() @_cleanSelection() @@ -281,6 +369,8 @@ class ContenteditableComponent extends React.Component # nodes. @_editableNode().normalize() + @_collapseAdjacentLists() + @_fixLeadingBRCondition() # An issue arises from
tags immediately inside of divs. In this @@ -308,6 +398,15 @@ class ContenteditableComponent extends React.Component childNodes = node.childNodes return childNodes.length >= 2 and childNodes[0].nodeName is "BR" + # If users ended up with two