From 3eba41b8d8c50728ae14ba597b3d200c5edc9914 Mon Sep 17 00:00:00 2001 From: Evan Morikawa Date: Tue, 22 Sep 2015 16:02:44 -0700 Subject: [PATCH] refactor(composer): update contenteditable functionality Summary: Fixes T3510 Fixes T3509 Fixes T3508 Fixes T3549 Extracted clipboard service Remove unused style prop Begin extracting quoted text from composer. Spec for clipboard service Fix contenteditable specs Begin to extract floating toolbar Extract out DOMUtils and further extract floating toolbar Further extracting domutils and floating toolbar composer floating toolbar extracted Fixes to hover and link states Collapse adjacent ul lists Fix outdent when deleting on a bulleted list Fix bullet controls Fixes to list creation and deletion Add underline keyboard shortcut Test Plan: manual :( Reviewers: dillon, bengotow Reviewed By: bengotow Maniphest Tasks: T3508, T3509, T3510, T3549 Differential Revision: https://phab.nylas.com/D2036 --- .../composer/lib/clipboard-service.coffee | 91 +++ .../composer/lib/composer-view.cjsx | 97 ++- .../lib/contenteditable-component.cjsx | 744 ++++++------------ .../lib/contenteditable-filter.coffee | 11 + .../composer/lib/expanded-participants.cjsx | 5 +- .../lib/floating-toolbar-container.cjsx | 263 +++++++ .../composer/lib/floating-toolbar.cjsx | 93 ++- .../spec/clipboard-service-spec.coffee | 167 ++++ .../spec/contenteditable-component-spec.cjsx | 160 ---- .../contenteditable-quoted-text-spec.cjsx | 44 +- src/dom-utils.coffee | 215 +++++ src/flux/stores/draft-store-extension.coffee | 31 +- 12 files changed, 1172 insertions(+), 749 deletions(-) create mode 100644 internal_packages/composer/lib/clipboard-service.coffee create mode 100644 internal_packages/composer/lib/contenteditable-filter.coffee create mode 100644 internal_packages/composer/lib/floating-toolbar-container.cjsx create mode 100644 internal_packages/composer/spec/clipboard-service-spec.coffee 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
    lists adjacent to each other, we + # collapse them into one. We leave adjacent
      lists intact in case + # the user wanted to restart the numbering sequence + _collapseAdjacentLists: -> + els = @_editableNode().querySelectorAll('ul') + + # This mutates the DOM in place. + DOMUtils.collapseAdjacentElements(els) + # After an input, the selection can sometimes get itself into a state # that either can't be restored properly, or will cause undersirable # native behavior. This method, in combination with `_cleanHTML`, fixes @@ -337,49 +436,38 @@ class ContenteditableComponent extends React.Component _unselectableNode: (node) -> return true if not node - if node.nodeType is Node.TEXT_NODE and @_isBlankTextNode(node) + if node.nodeType is Node.TEXT_NODE and DOMUtils.isBlankTextNode(node) return true else if node.nodeType is Node.ELEMENT_NODE child = node.firstChild return true if not child - hasText = (child.nodeType is Node.TEXT_NODE and not @_isBlankTextNode(node)) + hasText = (child.nodeType is Node.TEXT_NODE and not DOMUtils.isBlankTextNode(node)) hasBr = (child.nodeType is Node.ELEMENT_NODE and node.nodeName is "BR") return not hasText and not hasBr else return false _onBlur: (event) => - @_dragging = false + # console.log "On Blur Contenteditable" + @setInnerState dragging: false # 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 => - @_hideToolbar() + @setInnerState editableFocused: false , 50 _onFocus: (event) => + @setInnerState editableFocused: true @props.onFocus?(event) _editableNode: => React.findDOMNode(@refs.contenteditable) - _getAllLinks: => - Array.prototype.slice.call(@_editableNode().querySelectorAll("*[href]")) - _dangerouslySetInnerHTML: => - __html: @_applyHTMLDisplayFilters(@props.html) - - _applyHTMLDisplayFilters: (html) => - if @props.mode?.showQuotedText - return html - else - return QuotedHTMLParser.removeQuotedHTML(html) - - _unapplyHTMLDisplayFilters: (html) => - if @props.mode?.showQuotedText - return html - else - return QuotedHTMLParser.appendQuotedHTML(html, @props.html) - + html = @props.html + for filter in @props.filters + html = filter.beforeDisplay(html) + return __html: html ######### SELECTION MANAGEMENT ########## # @@ -431,16 +519,6 @@ class ContenteditableComponent extends React.Component getCurrentSelection: => _.clone(@_selection ? {}) getPreviousSelection: => _.clone(@_previousSelection ? {}) - _getRangeInScope: => - selection = document.getSelection() - return null if not @_selectionInScope(selection) - try - range = selection.getRangeAt(0) - catch - console.warn "Selection is not returning a range" - return document.createRange() - range - # Every time the cursor changes we need to preserve its location and # state. # @@ -479,109 +557,39 @@ class ContenteditableComponent extends React.Component # and keep track of the index of the match. e.g. all "Foo" TEXT_NODEs # may look alike, but I know I want the Nth "Foo" TEXT_NODE. We store # this information in the `startNodeIndex` and `endNodeIndex` fields via - # the `getNodeIndex` method. + # the `DOMUtils.getNodeIndex` method. _saveSelectionState: => selection = document.getSelection() - return if @_checkSameSelection(selection) + context = @_editableNode() + return if DOMUtils.isSameSelection(selection, @_selection, context) return unless selection.anchorNode? and selection.focusNode? - return unless @_selectionInScope(selection) + return unless DOMUtils.selectionInScope(selection, context) @_previousSelection = @_selection @_selection = startNode: selection.anchorNode.cloneNode(true) startOffset: selection.anchorOffset - startNodeIndex: @_getNodeIndex(selection.anchorNode) + startNodeIndex: DOMUtils.getNodeIndex(context, selection.anchorNode) endNode: selection.focusNode.cloneNode(true) endOffset: selection.focusOffset - endNodeIndex: @_getNodeIndex(selection.focusNode) + endNodeIndex: DOMUtils.getNodeIndex(context, selection.focusNode) isCollapsed: selection.isCollapsed @_ensureSelectionVisible(selection) - @_refreshToolbarState() + + @setInnerState + selection: @_selection + editableFocused: true + return @_selection - # Determines whether the current (cursor) selection is at the end of the - # content. - # - # This must be run before a re-render since we use a strict object - # identity comparison instead of an equivalent `isEqualNode` comparison. - _atEndOfContent: (selection, containerScope=@_editableNode()) => - if selection.isCollapsed - - # We need to use `lastChild` instead of `lastElementChild` because - # we need to eventually check if the `selection.focusNode`, which is - # usually a TEXT node, is equal to the returned `lastChild`. - # `lastElementChild` will not return TEXT nodes. - # - # Unfortunately, `lastChild` can sometime return COMMENT nodes and - # other blank TEXT nodes that we don't want to compare to. - # - # For example, if you have the structure: - #
      - #

      Foo

      - #
      - # - # The div may have 2 childNodes and 1 childElementNode. The 2nd - # hidden childNode is a TEXT node with a data of "\n". I actually - # want to return the

      . - # - # However, The

      element may have 1 childNode and 0 - # childElementNodes. In that case I DO want to return the TEXT node - # that has the data of "foo" - lastChild = @_lastNonBlankChildNode(containerScope) - - # Special case for a completely empty contenteditable. - # In this case `lastChild` will be null, but we are definitely at - # the end of the content. - if containerScope is @_editableNode() - return true if containerScope.childNodes.length is 0 - - return false unless lastChild - - # NOTE: `.contains` returns true if `lastChild` is equal to - # `selection.focusNode` - # - # See: http://ejohn.org/blog/comparing-document-position/ - inLastChild = lastChild.contains(selection.focusNode) - - # We should do true object identity here instead of `.isEqualNode` - isLastChild = lastChild is selection.focusNode - - if isLastChild - if selection.focusNode?.length - atEndIndex = selection.focusOffset is selection.focusNode.length - else - atEndIndex = selection.focusOffset is 0 - return atEndIndex - else if inLastChild - @_atEndOfContent(selection, lastChild) - else return false - - else return false - - _lastNonBlankChildNode: (node) -> - lastNode = null - for childNode in node.childNodes by -1 - if childNode.nodeType is Node.TEXT_NODE - if @_isBlankTextNode(childNode) - continue - else - return childNode - else if childNode.nodeType is Node.ELEMENT_NODE - return childNode - else continue - return lastNode - - _isBlankTextNode: (node) -> - return if not node?.data - # \u00a0 is   - node.data.replace(/\u00a0/g, "x").trim().length is 0 - _setSelectionSnapshot: (selection) => @_previousSelection = @_selection @_selection = selection - + @setInnerState + selection: @_selection + editableFocused: true # When the selectionState gets set by a parent (e.g. undo-ing and # redo-ing) we need to make sure it's visible to the user. @@ -594,25 +602,23 @@ class ContenteditableComponent extends React.Component # the scroll container may be many levels up. _ensureSelectionVisible: (selection) -> # If our parent supports scroll to bottom, check for that - if @props.onScrollToBottom and @_atEndOfContent(selection) + if @props.onScrollToBottom and DOMUtils.atEndOfContent(selection, @_editableNode()) @props.onScrollToBottom() # Don't bother computing client rects if no scroll method has been provided else if @props.onScrollTo - rangeInScope = @_getRangeInScope() + rangeInScope = DOMUtils.getRangeInScope(@_editableNode()) return unless rangeInScope rect = rangeInScope.getBoundingClientRect() - if @_isEmptyBoudingRect(rect) + if DOMUtils.isEmptyBoudingRect(rect) rect = @_getSelectionRectFromDOM(selection) if rect @props.onScrollTo({rect}) - @_refreshToolbarState() - - _isEmptyBoudingRect: (rect) -> - rect.top is 0 and rect.bottom is 0 and rect.left is 0 and rect.right is 0 + # The bounding client rect has changed + @setInnerState editableNode: @_editableNode() _getSelectionRectFromDOM: (selection) -> node = selection.anchorNode @@ -641,7 +647,7 @@ class ContenteditableComponent extends React.Component window.removeEventListener("mouseup", @__onMouseUp) _onShowContextualMenu: (event) => - @_hideToolbar() + @refs["toolbarController"]?.forceClose() event.preventDefault() selection = document.getSelection() @@ -724,7 +730,7 @@ class ContenteditableComponent extends React.Component editable = @_editableNode() return unless editable? if editable is event.target or editable.contains(event.target) - @_doubleDown = true + @setInnerState doubleDown: true _onMouseMove: (event) => if not @_mouseHasMoved @@ -734,9 +740,8 @@ class ContenteditableComponent extends React.Component _onMouseUp: (event) => window.removeEventListener("mousemove", @__onMouseMove) - if @_doubleDown - @_doubleDown = false - @_refreshToolbarState() + if @innerState.doubleDown + @setInnerState doubleDown: false if @_mouseHasMoved @_mouseHasMoved = false @@ -744,9 +749,9 @@ class ContenteditableComponent extends React.Component editableNode = @_editableNode() selection = document.getSelection() - return event unless @_selectionInScope(selection) + return event unless DOMUtils.selectionInScope(selection, editableNode) - range = @_getRangeInScope() + range = DOMUtils.getRangeInScope(editableNode) if range try for extension in DraftStore.extensions() @@ -760,12 +765,11 @@ class ContenteditableComponent extends React.Component editable = @_editableNode() return unless editable? if editable is event.target or editable.contains(event.target) - @_dragging = true + @setInnerState dragging: true _onDragEnd: (event) => - if @_dragging - @_dragging = false - @_refreshToolbarState() + if @innerState.dragging + @setInnerState dragging: false return event # We restore the Selection via the `setBaseAndExtent` property of the @@ -794,13 +798,14 @@ class ContenteditableComponent extends React.Component # selection, we'll collapse the range into a single caret # position _restoreSelection: ({force, collapse}={}) => - return if @_dragging + return if @innerState.dragging return if not @_selection? return if document.activeElement isnt @_editableNode() and not force return if not @_selection.startNode? or not @_selection.endNode? - newStartNode = @_findSimilarNodes(@_selection.startNode)[@_selection.startNodeIndex] - newEndNode = @_findSimilarNodes(@_selection.endNode)[@_selection.endNodeIndex] + editable = @_editableNode() + newStartNode = DOMUtils.findSimilarNodes(editable, @_selection.startNode)[@_selection.startNodeIndex] + newEndNode = DOMUtils.findSimilarNodes(editable, @_selection.endNode)[@_selection.endNodeIndex] return if not newStartNode? or not newEndNode? @_teardownSelectionListeners() @@ -813,149 +818,8 @@ class ContenteditableComponent extends React.Component @_ensureSelectionVisible(selection) @_setupSelectionListeners() - # We need to break each node apart and cache since the `selection` - # object will mutate underneath us. - _checkSameSelection: (newSelection) => - return true if not newSelection? - return false if not @_selection - return false if not newSelection.anchorNode? or not newSelection.focusNode? - - anchorIndex = @_getNodeIndex(newSelection.anchorNode) - focusIndex = @_getNodeIndex(newSelection.focusNode) - - anchorEqual = newSelection.anchorNode.isEqualNode @_selection.startNode - anchorIndexEqual = anchorIndex is @_selection.startNodeIndex - focusEqual = newSelection.focusNode.isEqualNode @_selection.endNode - focusIndexEqual = focusIndex is @_selection.endNodeIndex - if not anchorEqual and not focusEqual - # This means the newSelection is the same, but just from the opposite - # direction. We don't care in this case, so check the reciprocal as - # well. - anchorEqual = newSelection.anchorNode.isEqualNode @_selection.endNode - anchorIndexEqual = anchorIndex is @_selection.endNodeIndex - focusEqual = newSelection.focusNode.isEqualNode @_selection.startNode - focusIndexEqual = focusIndex is @_selection.startndNodeIndex - - anchorOffsetEqual = newSelection.anchorOffset == @_selection.startOffset - focusOffsetEqual = newSelection.focusOffset == @_selection.endOffset - if not anchorOffsetEqual and not focusOffsetEqual - # This means the newSelection is the same, but just from the opposite - # direction. We don't care in this case, so check the reciprocal as - # well. - anchorOffsetEqual = newSelection.anchorOffset == @_selection.focusOffset - focusOffsetEqual = newSelection.focusOffset == @_selection.anchorOffset - - if (anchorEqual and - anchorIndexEqual and - anchorOffsetEqual and - focusEqual and - focusIndexEqual and - focusOffsetEqual) - return true - else - return false - _getNodeIndex: (nodeToFind) => - @_findSimilarNodes(nodeToFind).indexOf nodeToFind - - _findSimilarNodes: (nodeToFind) => - nodeList = [] - editableNode = @_editableNode() - if nodeToFind.isEqualNode(editableNode) - nodeList.push(editableNode) - return nodeList - treeWalker = document.createTreeWalker editableNode - while treeWalker.nextNode() - if treeWalker.currentNode.isEqualNode nodeToFind - nodeList.push(treeWalker.currentNode) - - return nodeList - - _isEqualNode: => - - _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: => - return if @_dragging or (@_doubleDown and not @state.toolbarVisible) - if @_linkHoveringOver - url = @_linkHoveringOver.getAttribute('href') - rect = @_linkHoveringOver.getBoundingClientRect() - [left, top, editAreaWidth, toolbarPos] = @_getToolbarPos(rect) - @setState - toolbarVisible: true - toolbarMode: "edit-link" - toolbarTop: top - toolbarLeft: left - toolbarPos: toolbarPos - linkToModify: @_linkHoveringOver - editAreaWidth: editAreaWidth - - else if not @_selection? or @_selection.isCollapsed - @_hideToolbar() - - else - rect = @_getRangeInScope()?.getBoundingClientRect() - if not rect or @_isEmptyBoudingRect(rect) - @_hideToolbar() - else - [left, top, editAreaWidth, toolbarPos] = @_getToolbarPos(rect) - @setState - toolbarVisible: true - toolbarMode: "buttons" - toolbarTop: top - toolbarLeft: left - toolbarPos: toolbarPos - linkToModify: null - editAreaWidth: editAreaWidth - - _selectionInScope: (selection) => - return false if not selection? - editable = @_editableNode() - return false if not editable? - return (editable.contains(selection.anchorNode) and - editable.contains(selection.focusNode)) - - CONTENT_PADDING: 15 - - _getToolbarPos: (referenceRect) => - - TOP_PADDING = 10 - - BORDER_RADIUS_PADDING = 15 - - editArea = @_editableNode().getBoundingClientRect() - - calcLeft = (referenceRect.left - editArea.left) + referenceRect.width/2 - calcLeft = Math.min(Math.max(calcLeft, @CONTENT_PADDING+BORDER_RADIUS_PADDING), editArea.width - BORDER_RADIUS_PADDING) - - calcTop = referenceRect.top - editArea.top - 48 - toolbarPos = "above" - if calcTop < TOP_PADDING - calcTop = referenceRect.top - editArea.top + referenceRect.height + TOP_PADDING + 4 - toolbarPos = "below" - - return [calcLeft, calcTop, editArea.width, toolbarPos] - - _hideToolbar: => - if not @_focusedOnToolbar() and @state.toolbarVisible - @setState toolbarVisible: false - - _focusedOnToolbar: => - React.findDOMNode(@refs.floatingToolbar)?.contains(document.activeElement) + DOMUtils.findSimilarNodes(@_editableNode(), nodeToFind).indexOf nodeToFind # This needs to be in the contenteditable area because we need to first # restore the selection before calling the `execCommand` @@ -963,181 +827,37 @@ class ContenteditableComponent extends React.Component # 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) + linkToModify = DOMUtils.findSimilarNodes(@_editableNode(), linkToModify)?[0]?.childNodes[0] + + return unless linkToModify? + return if linkToModify.getAttribute?('href').trim() is url.trim() + + range = + anchorNode: linkToModify + anchorOffset: 0 + focusNode: linkToModify + focusOffset: linkToModify.length + if url.trim().length is 0 - document.execCommand("unlink", false) - else - document.execCommand("createLink", false, url) - @_setupSelectionListeners() + @_execCommand ["unlink", false], range + else @_execCommand ["createLink", false, url], range + else @_restoreSelection(force: true) - if document.getSelection().isCollapsed - # TODO - else + if not document.getSelection().isCollapsed if url.trim().length is 0 - document.execCommand("unlink", false) - else - document.execCommand("createLink", false, url) + @_execCommand ["unlink", false] + else @_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) => - link.hoverId = genLinkId() - @_links[link.hoverId] = {} - - enterListener = (event) => - @_clearLinkTimeouts() - @_linkHoveringOver = link - @_links[link.hoverId].enterTimeout = setTimeout => - @_refreshToolbarState() - , HOVER_IN_DELAY - - leaveListener = (event) => - @_clearLinkTimeouts() - @_linkHoveringOver = null - @_links[link.hoverId].leaveTimeout = setTimeout => - return if @refs.floatingToolbar.isHovering - @_refreshToolbarState() - , HOVER_OUT_DELAY - - link.addEventListener "mouseenter", enterListener - link.addEventListener "mouseleave", leaveListener - @_links[link.hoverId].link = link - @_links[link.hoverId].enterListener = enterListener - @_links[link.hoverId].leaveListener = leaveListener - - _clearLinkTimeouts: => - for hoverId, linkData of @_links - clearTimeout(linkData.enterTimeout) if linkData.enterTimeout? - clearTimeout(linkData.leaveTimeout) if linkData.leaveTimeout? - - _onTooltipMouseEnter: => - clearTimeout(@_clearTooltipTimeout) if @_clearTooltipTimeout? - - _onTooltipMouseLeave: => - @_clearTooltipTimeout = setTimeout => - @_refreshToolbarState() - , 500 - - _teardownLinkHoverListeners: => - for hoverId, linkData of @_links - clearTimeout linkData.enterTimeout - clearTimeout linkData.leaveTimeout - linkData.link.removeEventListener "mouseenter", linkData.enterListener - linkData.link.removeEventListener "mouseleave", linkData.leaveListener - @_links = {} - - - - ####### CLEAN PASTE ######### - - _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' and @props.onFilePaste - 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) => - @props.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 - - - ####### QUOTED TEXT ######### - - _onToggleQuotedText: => - @props.onChangeMode?(showQuotedText: !@props.mode?.showQuotedText) - - _quotedTextClasses: => classNames - "quoted-text-control": true + _execCommand: (commandArgs=[], selectionRange={}) => + {anchorNode, anchorOffset, focusNode, focusOffset} = selectionRange + @_teardownSelectionListeners() + if anchorNode and focusNode + selection = document.getSelection() + selection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset) + document.execCommand.apply(document, commandArgs) + @_setupSelectionListeners() + @_onInput() module.exports = ContenteditableComponent diff --git a/internal_packages/composer/lib/contenteditable-filter.coffee b/internal_packages/composer/lib/contenteditable-filter.coffee new file mode 100644 index 000000000..610eaa904 --- /dev/null +++ b/internal_packages/composer/lib/contenteditable-filter.coffee @@ -0,0 +1,11 @@ +class ContenteditableFilter + # Gets called immediately before insert the HTML into the DOM. This is + # useful for modifying what the user sees compared to the data we're + # storing. + beforeDisplay: -> + + # Gets called just after the content has changed but just before we save + # out the new HTML. The inverse of `beforeDisplay` + afterDisplay: -> + +module.exports = ContenteditableFilter diff --git a/internal_packages/composer/lib/expanded-participants.cjsx b/internal_packages/composer/lib/expanded-participants.cjsx index f818004b8..b44dd70f2 100644 --- a/internal_packages/composer/lib/expanded-participants.cjsx +++ b/internal_packages/composer/lib/expanded-participants.cjsx @@ -90,7 +90,7 @@ class ExpandedParticipants extends React.Component + onClick={@props.onPopoutComposer}> @@ -161,9 +161,6 @@ class ExpandedParticipants extends React.Component hide: [Fields.Cc] focus: Fields.To - _popoutComposer: => - Actions.composePopoutDraft @props.draftClientId - _onEmptyBcc: => if Fields.Cc in @props.enabledFields focus = Fields.Cc diff --git a/internal_packages/composer/lib/floating-toolbar-container.cjsx b/internal_packages/composer/lib/floating-toolbar-container.cjsx new file mode 100644 index 000000000..098867299 --- /dev/null +++ b/internal_packages/composer/lib/floating-toolbar-container.cjsx @@ -0,0 +1,263 @@ +_ = require 'underscore' +React = require 'react' + +{Utils, DOMUtils} = require 'nylas-exports' + +FloatingToolbar = require './floating-toolbar' + +# This is responsible for the logic required to position a floating +# toolbar +class FloatingToolbarContainer extends React.Component + @displayName: "FloatingToolbarContainer" + + @propTypes: + # A function we call when we would like to request to change the + # current selection + onSaveUrl: React.PropTypes.func + + # When an extension wants to mutate the DOM, it passes `onDomMutator` + # a callback function. That callback is expecting to be passed the + # latest DOM object and may modify it in place. + onDomMutator: React.PropTypes.func + + @innerPropTypes: + links: React.PropTypes.array + dragging: React.PropTypes.bool + selection: React.PropTypes.object + doubleDown: React.PropTypes.bool + editableNode: React.PropTypes.object + editableFocused: React.PropTypes.bool + + constructor: (@props) -> + @state = + toolbarTop: 0 + toolbarMode: "buttons" + toolbarLeft: 0 + toolbarPos: "above" + editAreaWidth: 9999 # This will get set on first selection + toolbarVisible: false + linkHoveringOver: null + @_setToolbarState = _.debounce(@_setToolbarState, 10) + @innerProps = + links: [] + dragging: false + selection: null + doubleDown: false + editableNode: null + toolbarFocus: false + editableFocused: null + + shouldComponentUpdate: (nextProps, nextState) -> + not Utils.isEqualReact(nextProps, @props) or + not Utils.isEqualReact(nextState, @state) + + # Some properties (like whether we're dragging or clicking the mouse) + # should in a strict-sense be props, but update in a way that's not + # performant to got through the full React re-rendering cycle, + # especially given the complexity of the composer component. + # + # We call these performance-optimized props & state innerProps and + # innerState. + componentWillReceiveInnerProps: (nextInnerProps) => + @innerProps = _.extend @innerProps, nextInnerProps + @fullProps = _.extend(@innerProps, @props) + if "links" of nextInnerProps + @_refreshLinkHoverListeners() + @_setToolbarState() + + componentWillReceiveProps: (nextProps) => + @fullProps = _.extend(@innerProps, nextProps) + @_setToolbarState() + + # The context menu, when activated, needs to make sure that the toolbar + # is closed. Unfortunately, since there's no onClose callback for the + # context menu, we can't hook up a reliable declarative state to the + # menu. We break our declarative pattern in this one case. + forceClose: -> + @setState toolbarVisible: false + + render: -> + + + _onChangeFocus: (focus) => + @componentWillReceiveInnerProps toolbarFocus: focus + + _onChangeMode: (mode) => + if mode is "buttons" + @componentWillReceiveInnerProps linkHoveringOver: null + @setState + toolbarMode: mode + toolbarVisible: false + else + @setState toolbarMode: mode + + # We want the toolbar's state to be declaratively defined from other + # states. + _setToolbarState: => + props = @fullProps ? {} + + return if props.dragging or (props.doubleDown and not @state.toolbarVisible) + + if props.toolbarFocus + @setState toolbarVisible: true + return + + if @_shouldHideToolbar(props) + @setState + toolbarVisible: false + toolbarMode: "buttons" + return + + if props.linkHoveringOver + url = props.linkHoveringOver.getAttribute('href') + rect = props.linkHoveringOver.getBoundingClientRect() + [left, top, editAreaWidth, toolbarPos] = @_getToolbarPos(rect) + @setState + toolbarVisible: true + toolbarMode: "edit-link" + toolbarTop: top + toolbarLeft: left + toolbarPos: toolbarPos + linkToModify: props.linkHoveringOver + editAreaWidth: editAreaWidth + else + # return if @state.toolbarMode is "edit-link" + rect = DOMUtils.getRangeInScope(props.editableNode)?.getBoundingClientRect() + if not rect or DOMUtils.isEmptyBoudingRect(rect) + @setState + toolbarVisible: false + toolbarMode: "buttons" + else + [left, top, editAreaWidth, toolbarPos] = @_getToolbarPos(rect) + @setState + toolbarVisible: true + toolbarTop: top + toolbarLeft: left + toolbarPos: toolbarPos + linkToModify: null + editAreaWidth: editAreaWidth + + _shouldHideToolbar: (props) -> + return false if @state.toolbarMode is "edit-link" + return false if props.linkHoveringOver + return not props.editableFocused or + not props.selection or + props.selection.isCollapsed + + _refreshLinkHoverListeners: -> + @_teardownLinkHoverListeners() + @_links = {} + links = Array.prototype.slice.call(@innerProps.links) + links.forEach (link) => + link.hoverId = Utils.generateTempId() + @_links[link.hoverId] = {} + + context = this + enterListener = (event) -> + link = this + context._onEnterLink.call(context, link, event) + leaveListener = (event) -> + link = this + context._onLeaveLink.call(context, link, event) + + link.addEventListener "mouseenter", enterListener + link.addEventListener "mouseleave", leaveListener + @_links[link.hoverId].link = link + @_links[link.hoverId].enterListener = enterListener + @_links[link.hoverId].leaveListener = leaveListener + + _onEnterLink: (link, event) => + HOVER_IN_DELAY = 250 + @_clearLinkTimeouts() + @_links[link.hoverId].enterTimeout = setTimeout => + @componentWillReceiveInnerProps linkHoveringOver: link + , HOVER_IN_DELAY + + _onLeaveLink: (link, event) => + HOVER_OUT_DELAY = 500 + @_clearLinkTimeouts() + @_links[link.hoverId].leaveTimeout = setTimeout => + @componentWillReceiveInnerProps linkHoveringOver: null + , HOVER_OUT_DELAY + + _onEnterToolbar: (event) => + clearTimeout(@_clearTooltipTimeout) if @_clearTooltipTimeout? + + # 1. Hover over a link until the toolbar appears. + # 2. The toolbar's link input will be UNfocused + # 3. Moving the mouse off the link and over the toolbar will cause + # _onLinkLeave to fire. Before the `leaveTimeout` fires, clear it + # since our mouse has safely made it to the tooltip. + @_clearLinkTimeouts() + + # Called when the mouse leaves the "edit-link" mode toolbar. + # + # NOTE: The leave callback does NOT get called if the user has the input + # field focused. We don't want the make the box dissapear under the user + # when they're typing. + _onLeaveToolbar: (event) => + HOVER_OUT_DELAY = 250 + @_clearTooltipTimeout = setTimeout => + # If we've hovered over a link until the toolbar appeared, then + # `linkHoverOver` will be set to that link. When we move the mouse + # onto the toolbar, `_onEnterToolbar` will make sure that + # `linkHoveringOver` doesn't get cleared. If we then move our mouse + # off of the toolbar, we need to remember to clear the hovering + # link. + @componentWillReceiveInnerProps linkHoveringOver: null + , 250 + + _clearLinkTimeouts: -> + for hoverId, linkData of @_links + clearTimeout(linkData.enterTimeout) if linkData.enterTimeout? + clearTimeout(linkData.leaveTimeout) if linkData.leaveTimeout? + + _teardownLinkHoverListeners: => + for hoverId, linkData of @_links + clearTimeout linkData.enterTimeout + clearTimeout linkData.leaveTimeout + linkData.link.removeEventListener "mouseenter", linkData.enterListener + linkData.link.removeEventListener "mouseleave", linkData.leaveListener + @_links = {} + + CONTENT_PADDING: 15 + + _getToolbarPos: (referenceRect) => + return [0,0,0,0] unless @innerProps.editableNode + + TOP_PADDING = 10 + + BORDER_RADIUS_PADDING = 15 + + editArea = @innerProps.editableNode.getBoundingClientRect() + + calcLeft = (referenceRect.left - editArea.left) + referenceRect.width/2 + calcLeft = Math.min(Math.max(calcLeft, @CONTENT_PADDING+BORDER_RADIUS_PADDING), editArea.width - BORDER_RADIUS_PADDING) + + calcTop = referenceRect.top - editArea.top - 48 + toolbarPos = "above" + if calcTop < TOP_PADDING + calcTop = referenceRect.top - editArea.top + referenceRect.height + TOP_PADDING + 4 + toolbarPos = "below" + + return [calcLeft, calcTop, editArea.width, toolbarPos] + + _focusedOnToolbar: => + React.findDOMNode(@refs.floatingToolbar)?.contains(document.activeElement) + +module.exports = FloatingToolbarContainer diff --git a/internal_packages/composer/lib/floating-toolbar.cjsx b/internal_packages/composer/lib/floating-toolbar.cjsx index 000ab6ece..8db6f2036 100644 --- a/internal_packages/composer/lib/floating-toolbar.cjsx +++ b/internal_packages/composer/lib/floating-toolbar.cjsx @@ -3,31 +3,45 @@ React = require 'react/addons' classNames = require 'classnames' {CompositeDisposable} = require 'event-kit' {RetinaImg} = require 'nylas-component-kit' +{DraftStore} = require 'nylas-exports' class FloatingToolbar extends React.Component @displayName = "FloatingToolbar" + @propTypes: + top: React.PropTypes.number + left: React.PropTypes.number + mode: React.PropTypes.string + onMouseEnter: React.PropTypes.func + onMouseLeave: React.PropTypes.func + + # When an extension wants to mutate the DOM, it passes `onDomMutator` + # a mutator function. That mutator is expecting to be passed the + # latest DOM object and may modify it in place. + onDomMutator: React.PropTypes.func + + @defaultProps: + mode: "buttons" + onMouseEnter: -> + onMouseLeave: -> + constructor: (@props) -> @state = - mode: "buttons" urlInputValue: @_initialUrl() ? "" + componentWidth: 0 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 + if @props.mode is "edit-link" and not @props.linkToModify # Note, it's important that we're focused on the urlInput because # the parent of this component needs to know to not hide us on their # onBlur method. @@ -56,8 +70,8 @@ class FloatingToolbar extends React.Component return styles _toolbarType: => - if @state.mode is "buttons" then @_renderButtons() - else if @state.mode is "edit-link" then @_renderLink() + if @props.mode is "buttons" then @_renderButtons() + else if @props.mode is "edit-link" then @_renderLink() else return
      _renderButtons: => @@ -74,14 +88,30 @@ class FloatingToolbar extends React.Component + {@_toolbarExtensions()} + _toolbarExtensions: -> + buttons = [] + for extension in DraftStore.extensions() + toolbarItem = extension.composerToolbar?() + if toolbarItem + buttons.push( + ) + return buttons + + _extensionMutateDom: (mutator) => + @props.onDomMutator(mutator) + _renderLink: => removeBtn = "" withRemove = "" if @_initialUrl() withRemove = "with-remove" removeBtn =
      {removeBtn} @@ -107,15 +139,12 @@ class FloatingToolbar extends React.Component event.stopPropagation() _onMouseEnter: => - @isHovering = true @props.onMouseEnter?() _onMouseLeave: => - @isHovering = false if @props.linkToModify and document.activeElement isnt React.findDOMNode(@refs.urlInput) @props.onMouseLeave?() - _initialUrl: (props=@props) => props.linkToModify?.getAttribute?('href') @@ -123,8 +152,11 @@ class FloatingToolbar extends React.Component @setState urlInputValue: event.target.value _saveUrlOnEnter: (event) => - if event.key is "Enter" and @state.urlInputValue.trim().length > 0 - @_saveUrl() + if event.key is "Enter" + if (@state.urlInputValue ? "").trim().length > 0 + @_saveUrl() + else + @_removeUrl() # We signify the removal of a url with an empty string. This protects us # from the case where people delete the url text and hit save. In that @@ -132,10 +164,31 @@ class FloatingToolbar extends React.Component _removeUrl: => @setState urlInputValue: "" @props.onSaveUrl "", @props.linkToModify + @props.onChangeMode("buttons") - __saveUrl: => - return unless @state.urlInputValue? - @props.onSaveUrl @state.urlInputValue, @props.linkToModify + _onFocus: => + @props.onChangeFocus(true) + + # Clicking the save or remove buttons will take precendent over simply + # bluring the field. + _onBlur: (event) => + targets = [] + if @refs["saveBtn"] + targets.push React.findDOMNode(@refs["saveBtn"]) + if @refs["removeBtn"] + targets.push React.findDOMNode(@refs["removeBtn"]) + + if event.relatedTarget in targets + event.preventDefault() + return + else + @_saveUrl() + @props.onChangeFocus(false) + + _saveUrl: => + if (@state.urlInputValue ? "").trim().length > 0 + @props.onSaveUrl @state.urlInputValue, @props.linkToModify + @props.onChangeMode("buttons") _execCommand: (event) => cmd = event.currentTarget.getAttribute 'data-command-name' @@ -174,9 +227,9 @@ class FloatingToolbar extends React.Component WIDTH_PER_CHAR = 11 max = @props.editAreaWidth - (@props.contentPadding ? 15)*2 - if @state.mode is "buttons" + if @props.mode is "buttons" return TOOLBAR_BUTTONS_WIDTH - else if @state.mode is "edit-link" + else if @props.mode is "edit-link" url = @_initialUrl() if url?.length > 0 fullWidth = Math.max(Math.min(url.length * WIDTH_PER_CHAR, max), TOOLBAR_URL_WIDTH) @@ -187,6 +240,6 @@ class FloatingToolbar extends React.Component return TOOLBAR_BUTTONS_WIDTH _showLink: => - @setState mode: "edit-link" + @props.onChangeMode("edit-link") module.exports = FloatingToolbar diff --git a/internal_packages/composer/spec/clipboard-service-spec.coffee b/internal_packages/composer/spec/clipboard-service-spec.coffee new file mode 100644 index 000000000..8dd1932bb --- /dev/null +++ b/internal_packages/composer/spec/clipboard-service-spec.coffee @@ -0,0 +1,167 @@ +ClipboardService = require '../lib/clipboard-service' + +describe "ClipboardService", -> + beforeEach -> + @onFilePaste = jasmine.createSpy('onFilePaste') + @clipboardService = new ClipboardService + + describe "when html and plain text parts are present", -> + beforeEach -> + @mockEvent = + preventDefault: jasmine.createSpy('preventDefault') + clipboardData: + getData: -> + return 'This is text' if 'text/html' + return 'This is plain text' if 'text/plain' + return null + items: [{ + kind: 'string' + type: 'text/html' + getAsString: -> 'This is text' + },{ + kind: 'string' + type: 'text/plain' + getAsString: -> 'This is plain text' + }] + + it "should sanitize the HTML string and call insertHTML", -> + spyOn(document, 'execCommand') + spyOn(@clipboardService, '_sanitizeInput').andCallThrough() + + runs -> + @clipboardService.onPaste(@mockEvent) + waitsFor -> + document.execCommand.callCount > 0 + runs -> + expect(@clipboardService._sanitizeInput).toHaveBeenCalledWith('This is text', 'text/html') + [command, a, html] = document.execCommand.mostRecentCall.args + expect(command).toEqual('insertHTML') + expect(html).toEqual('This is text') + + describe "when html and plain text parts are present", -> + beforeEach -> + @mockEvent = + preventDefault: jasmine.createSpy('preventDefault') + clipboardData: + getData: -> + return 'This is plain text' if 'text/plain' + return null + items: [{ + kind: 'string' + type: 'text/plain' + getAsString: -> 'This is plain text' + }] + + it "should sanitize the plain text string and call insertHTML", -> + spyOn(document, 'execCommand') + spyOn(@clipboardService, '_sanitizeInput').andCallThrough() + + runs -> + @clipboardService.onPaste(@mockEvent) + waitsFor -> + document.execCommand.callCount > 0 + runs -> + expect(@clipboardService._sanitizeInput).toHaveBeenCalledWith('This is plain text', 'text/html') + [command, a, html] = document.execCommand.mostRecentCall.args + expect(command).toEqual('insertHTML') + expect(html).toEqual('This is plain text') + + describe "sanitization", -> + tests = [ + { + in: "" + sanitizedAsHTML: "" + sanitizedAsPlain: "" + }, + { + in: "Hello World" + sanitizedAsHTML: "Hello World" + sanitizedAsPlain: "Hello World" + }, + { + in: " Hello World" + # Should collapse to 1 space when rendered + sanitizedAsHTML: " Hello World" + # Preserving 2 spaces + sanitizedAsPlain: "  Hello  World" + }, + { + in: " Hello World" + sanitizedAsHTML: " Hello World" + # Preserving 3 spaces + sanitizedAsPlain: "   Hello   World" + }, + { + in: " Hello World" + sanitizedAsHTML: " Hello World" + # Preserving 4 spaces + sanitizedAsPlain: "    Hello    World" + }, + { + in: "Hello\nWorld" + sanitizedAsHTML: "Hello
      World" + # Convert newline to br + sanitizedAsPlain: "Hello
      World" + }, + { + in: "Hello\rWorld" + sanitizedAsHTML: "Hello
      World" + # Convert carriage return to br + sanitizedAsPlain: "Hello
      World" + }, + { + in: "Hello\n\n\nWorld" + # Never have more than 2 br's in a row + sanitizedAsHTML: "Hello

      World" + # Convert multiple newlines to same number of brs + sanitizedAsPlain: "Hello


      World" + }, + { + in: " Foo Bar
      Baz
      " + # Strip bad tags + sanitizedAsHTML: " Foo Bar Baz" + # HTML encode tags for literal display + sanitizedAsPlain: "<style>Yo</style> Foo Bar <div>Baz</div>" + }, + { + in: " Yo < script>Boo! < / script >" + # Strip non white-list tags and encode malformed ones. + sanitizedAsHTML: " Yo < script>Boo! < / script >" + # HTML encode tags for literal display + sanitizedAsPlain: "<script>Bah</script> Yo < script>Boo! < / script >" + }, + { + in: """ + + + + + + + + + + + +
        +
      • Packet pickup: I'll pick up my packet at some point on Saturday at Fort Mason. Let me know if you'd like me to get yours. I'll need a photo of your ID and your confirmation number. Also, shirt color preference, I believe. Gray or black? Can't remember...
      • +
      + + """ + # Strip non white-list tags and encode malformed ones. + sanitizedAsHTML: "

      • Packet pickup: I'll pick up my packet at some point on Saturday at Fort Mason. Let me know if you'd like me to get yours. I'll need a photo of your ID and your confirmation number. Also, shirt color preference, I believe. Gray or black? Can't remember...

      " + # HTML encode tags for literal display + sanitizedAsPlain: "<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
      <html>
      <head>
      <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
      <meta http-equiv="Content-Style-Type" content="text/css">
      <title></title>
      <meta name="Generator" content="Cocoa HTML Writer">
      <meta name="CocoaVersion" content="1265.21">
      <style type="text/css">
      li.li1 {margin: 0.0px 0.0px 0.0px 0.0px; font: 12.0px Helvetica}
      ul.ul1 {list-style-type: disc}
      </style>
      </head>
      <body>
      <ul class="ul1">
      <li class="li1"><b>Packet pickup: </b>I'll pick up my packet at some point on Saturday at Fort Mason. Let me know if you'd like me to get yours. I'll need a photo of your ID and your confirmation number. Also, shirt color preference, I believe. Gray or black? Can't remember...</li>
      </ul>
      </body>
      </html>" + } + ] + + it "sanitizes plain text properly", -> + for test in tests + expect(@clipboardService._sanitizeInput(test.in, "text/plain")).toBe test.sanitizedAsPlain + + it "sanitizes html text properly", -> + for test in tests + expect(@clipboardService._sanitizeInput(test.in, "text/html")).toBe test.sanitizedAsHTML diff --git a/internal_packages/composer/spec/contenteditable-component-spec.cjsx b/internal_packages/composer/spec/contenteditable-component-spec.cjsx index 5c00d14a2..4d7417c46 100644 --- a/internal_packages/composer/spec/contenteditable-component-spec.cjsx +++ b/internal_packages/composer/spec/contenteditable-component-spec.cjsx @@ -76,163 +76,3 @@ describe "ContenteditableComponent", -> contents = fs.readFileSync(file) expect(contents.toString()).toEqual('12341352312411') - describe "when html and plain text parts are present", -> - beforeEach -> - @mockEvent = - preventDefault: jasmine.createSpy('preventDefault') - clipboardData: - getData: -> - return 'This is text' if 'text/html' - return 'This is plain text' if 'text/plain' - return null - items: [{ - kind: 'string' - type: 'text/html' - getAsString: -> 'This is text' - },{ - kind: 'string' - type: 'text/plain' - getAsString: -> 'This is plain text' - }] - - it "should sanitize the HTML string and call insertHTML", -> - spyOn(document, 'execCommand') - spyOn(@component, '_sanitizeInput').andCallThrough() - - runs -> - ReactTestUtils.Simulate.paste(@editableNode, @mockEvent) - waitsFor -> - document.execCommand.callCount > 0 - runs -> - expect(@component._sanitizeInput).toHaveBeenCalledWith('This is text', 'text/html') - [command, a, html] = document.execCommand.mostRecentCall.args - expect(command).toEqual('insertHTML') - expect(html).toEqual('This is text') - - describe "when html and plain text parts are present", -> - beforeEach -> - @mockEvent = - preventDefault: jasmine.createSpy('preventDefault') - clipboardData: - getData: -> - return 'This is plain text' if 'text/plain' - return null - items: [{ - kind: 'string' - type: 'text/plain' - getAsString: -> 'This is plain text' - }] - - it "should sanitize the plain text string and call insertHTML", -> - spyOn(document, 'execCommand') - spyOn(@component, '_sanitizeInput').andCallThrough() - - runs -> - ReactTestUtils.Simulate.paste(@editableNode, @mockEvent) - waitsFor -> - document.execCommand.callCount > 0 - runs -> - expect(@component._sanitizeInput).toHaveBeenCalledWith('This is plain text', 'text/html') - [command, a, html] = document.execCommand.mostRecentCall.args - expect(command).toEqual('insertHTML') - expect(html).toEqual('This is plain text') - - describe "sanitization", -> - tests = [ - { - in: "" - sanitizedAsHTML: "" - sanitizedAsPlain: "" - }, - { - in: "Hello World" - sanitizedAsHTML: "Hello World" - sanitizedAsPlain: "Hello World" - }, - { - in: " Hello World" - # Should collapse to 1 space when rendered - sanitizedAsHTML: " Hello World" - # Preserving 2 spaces - sanitizedAsPlain: "  Hello  World" - }, - { - in: " Hello World" - sanitizedAsHTML: " Hello World" - # Preserving 3 spaces - sanitizedAsPlain: "   Hello   World" - }, - { - in: " Hello World" - sanitizedAsHTML: " Hello World" - # Preserving 4 spaces - sanitizedAsPlain: "    Hello    World" - }, - { - in: "Hello\nWorld" - sanitizedAsHTML: "Hello
      World" - # Convert newline to br - sanitizedAsPlain: "Hello
      World" - }, - { - in: "Hello\rWorld" - sanitizedAsHTML: "Hello
      World" - # Convert carriage return to br - sanitizedAsPlain: "Hello
      World" - }, - { - in: "Hello\n\n\nWorld" - # Never have more than 2 br's in a row - sanitizedAsHTML: "Hello

      World" - # Convert multiple newlines to same number of brs - sanitizedAsPlain: "Hello


      World" - }, - { - in: " Foo Bar
      Baz
      " - # Strip bad tags - sanitizedAsHTML: " Foo Bar Baz" - # HTML encode tags for literal display - sanitizedAsPlain: "<style>Yo</style> Foo Bar <div>Baz</div>" - }, - { - in: " Yo < script>Boo! < / script >" - # Strip non white-list tags and encode malformed ones. - sanitizedAsHTML: " Yo < script>Boo! < / script >" - # HTML encode tags for literal display - sanitizedAsPlain: "<script>Bah</script> Yo < script>Boo! < / script >" - }, - { - in: """ - - - - - - - - - - - -
        -
      • Packet pickup: I'll pick up my packet at some point on Saturday at Fort Mason. Let me know if you'd like me to get yours. I'll need a photo of your ID and your confirmation number. Also, shirt color preference, I believe. Gray or black? Can't remember...
      • -
      - - """ - # Strip non white-list tags and encode malformed ones. - sanitizedAsHTML: "

      • Packet pickup: I'll pick up my packet at some point on Saturday at Fort Mason. Let me know if you'd like me to get yours. I'll need a photo of your ID and your confirmation number. Also, shirt color preference, I believe. Gray or black? Can't remember...

      " - # HTML encode tags for literal display - sanitizedAsPlain: "<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
      <html>
      <head>
      <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
      <meta http-equiv="Content-Style-Type" content="text/css">
      <title></title>
      <meta name="Generator" content="Cocoa HTML Writer">
      <meta name="CocoaVersion" content="1265.21">
      <style type="text/css">
      li.li1 {margin: 0.0px 0.0px 0.0px 0.0px; font: 12.0px Helvetica}
      ul.ul1 {list-style-type: disc}
      </style>
      </head>
      <body>
      <ul class="ul1">
      <li class="li1"><b>Packet pickup: </b>I'll pick up my packet at some point on Saturday at Fort Mason. Let me know if you'd like me to get yours. I'll need a photo of your ID and your confirmation number. Also, shirt color preference, I believe. Gray or black? Can't remember...</li>
      </ul>
      </body>
      </html>" - } - ] - - it "sanitizes plain text properly", -> - for test in tests - expect(@component._sanitizeInput(test.in, "text/plain")).toBe test.sanitizedAsPlain - - it "sanitizes html text properly", -> - for test in tests - expect(@component._sanitizeInput(test.in, "text/html")).toBe test.sanitizedAsHTML diff --git a/internal_packages/composer/spec/contenteditable-quoted-text-spec.cjsx b/internal_packages/composer/spec/contenteditable-quoted-text-spec.cjsx index bb98cf1b2..68a3e024d 100644 --- a/internal_packages/composer/spec/contenteditable-quoted-text-spec.cjsx +++ b/internal_packages/composer/spec/contenteditable-quoted-text-spec.cjsx @@ -6,6 +6,9 @@ _ = require "underscore" React = require "react/addons" ReactTestUtils = React.addons.TestUtils + +Fields = require '../lib/fields' +Composer = require "../lib/composer-view", ContenteditableComponent = require "../lib/contenteditable-component", describe "ContenteditableComponent", -> @@ -14,6 +17,9 @@ describe "ContenteditableComponent", -> @htmlNoQuote = 'Test HTML
      ' @htmlWithQuote = 'Test HTML
      QUOTE
      ' + @composer = ReactTestUtils.renderIntoDocument() + spyOn(@composer, "_onChangeBody") + # Must be called with the test's scope setHTML = (newHTML) -> @$contentEditable.innerHTML = newHTML @@ -23,10 +29,10 @@ describe "ContenteditableComponent", -> describe "when there's no quoted text", -> beforeEach -> - @contentEditable = ReactTestUtils.renderIntoDocument( - ) + @composer.setState + body: @htmlNoQuote + showQuotedText: true + @contentEditable = @composer.refs[Fields.Body] @$contentEditable = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@contentEditable, 'contentEditable')) it 'should not display any quoted text', -> @@ -36,7 +42,7 @@ describe "ContenteditableComponent", -> textToAdd = "MORE TEXT!" expect(@$contentEditable.innerHTML).toBe @htmlNoQuote setHTML.call(@, textToAdd + @htmlNoQuote) - ev = @onChange.mostRecentCall.args[0] + ev = @composer._onChangeBody.mostRecentCall.args[0] expect(ev.target.value).toEqual(textToAdd + @htmlNoQuote) it 'should not render the quoted-text-control toggle', -> @@ -46,27 +52,27 @@ describe "ContenteditableComponent", -> describe 'when showQuotedText is true', -> beforeEach -> - @contentEditable = ReactTestUtils.renderIntoDocument( - ) + @composer.setState + body: @htmlWithQuote + showQuotedText: true + @contentEditable = @composer.refs[Fields.Body] @$contentEditable = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@contentEditable, 'contentEditable')) it 'should display the quoted text', -> expect(@$contentEditable.innerHTML).toBe @htmlWithQuote - it "should call `props.onChange` with the entire HTML string", -> + it "should call `_onChangeBody` with the entire HTML string", -> textToAdd = "MORE TEXT!" expect(@$contentEditable.innerHTML).toBe @htmlWithQuote setHTML.call(@, textToAdd + @htmlWithQuote) - ev = @onChange.mostRecentCall.args[0] + ev = @composer._onChangeBody.mostRecentCall.args[0] expect(ev.target.value).toEqual(textToAdd + @htmlWithQuote) it "should allow the quoted text to be changed", -> newText = 'Test NEW 1 HTML
      QUOTE CHANGED!!!
      ' expect(@$contentEditable.innerHTML).toBe @htmlWithQuote setHTML.call(@, newText) - ev = @onChange.mostRecentCall.args[0] + ev = @composer._onChangeBody.mostRecentCall.args[0] expect(ev.target.value).toEqual(newText) describe 'quoted text control toggle button', -> @@ -81,10 +87,10 @@ describe "ContenteditableComponent", -> describe 'when showQuotedText is false', -> beforeEach -> - @contentEditable = ReactTestUtils.renderIntoDocument( - ) + @composer.setState + body: @htmlWithQuote + showQuotedText: false + @contentEditable = @composer.refs[Fields.Body] @$contentEditable = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@contentEditable, 'contentEditable')) # The quoted text dom parser wraps stuff inertly in body tags @@ -93,11 +99,11 @@ describe "ContenteditableComponent", -> it 'should not display any quoted text', -> expect(@$contentEditable.innerHTML).toBe @htmlNoQuote - it "should let you change the text, and then append the quoted text part to the end before firing `onChange`", -> + it "should let you change the text, and then append the quoted text part to the end before firing `_onChangeBody`", -> textToAdd = "MORE TEXT!" expect(@$contentEditable.innerHTML).toBe @htmlNoQuote setHTML.call(@, textToAdd + @htmlNoQuote) - ev = @onChange.mostRecentCall.args[0] + ev = @composer._onChangeBody.mostRecentCall.args[0] # Note that we expect the version WITH a quote while setting the # version withOUT a quote. expect(ev.target.value).toEqual(wrapBody(textToAdd + @htmlWithQuote)) @@ -106,7 +112,7 @@ describe "ContenteditableComponent", -> textToAdd = "Yo
      I'm a fake quote
      " expect(@$contentEditable.innerHTML).toBe @htmlNoQuote setHTML.call(@, textToAdd + @htmlNoQuote) - ev = @onChange.mostRecentCall.args[0] + ev = @composer._onChangeBody.mostRecentCall.args[0] # Note that we expect the version WITH a quote while setting the # version withOUT a quote. expect(ev.target.value).toEqual(wrapBody(textToAdd + @htmlWithQuote)) diff --git a/src/dom-utils.coffee b/src/dom-utils.coffee index 55bd8b720..c767a5b48 100644 --- a/src/dom-utils.coffee +++ b/src/dom-utils.coffee @@ -3,6 +3,212 @@ _s = require 'underscore.string' DOMUtils = + # Given a bunch of elements, it will go through and find all elements + # that are adjacent to that one of the same type. For each set of + # adjacent elements, it will put all children of those elements into the + # first one and delete the remaining elements. + # + # WARNING: This mutates the DOM in place! + collapseAdjacentElements: (els=[]) -> + return if els.length is 0 + els = Array::slice.call(els) + + seenEls = [] + toMerge = [] + + for el in els + continue if el in seenEls + adjacent = DOMUtils.collectAdjacent(el) + seenEls = seenEls.concat(adjacent) + continue if adjacent.length <= 1 + toMerge.push(adjacent) + + anchors = [] + for mergeSet in toMerge + anchor = mergeSet[0] + remaining = mergeSet[1..-1] + for el in remaining + while (el.childNodes.length > 0) + anchor.appendChild(el.childNodes[0]) + DOMUtils.removeElements(remaining) + anchors.push(anchor) + + return anchors + + # Returns an array of all immediately adjacent nodes of a particular + # nodeName relative to the root. Includes the root if it has the correct + # nodeName. + # + # nodName is optional. if left blank it'll be the nodeName of the root + collectAdjacent: (root, nodeName) -> + nodeName ?= root.nodeName + adjacent = [] + + node = root + while node.nextSibling?.nodeName is nodeName + adjacent.push(node.nextSibling) + node = node.nextSibling + + if root.nodeName is nodeName + adjacent.unshift(root) + + node = root + while node.previousSibling?.nodeName is nodeName + adjacent.unshift(node.previousSibling) + node = node.previousSibling + + return adjacent + + getNodeIndex: (context, nodeToFind) => + DOMUtils.findSimilarNodes(context, nodeToFind).indexOf nodeToFind + + # We need to break each node apart and cache since the `selection` + # object will mutate underneath us. + isSameSelection: (newSelection, oldSelection, context) => + return true if not newSelection? + return false if not oldSelection + return false if not newSelection.anchorNode? or not newSelection.focusNode? + + anchorIndex = DOMUtils.getNodeIndex(context, newSelection.anchorNode) + focusIndex = DOMUtils.getNodeIndex(context, newSelection.focusNode) + + anchorEqual = newSelection.anchorNode.isEqualNode oldSelection.startNode + anchorIndexEqual = anchorIndex is oldSelection.startNodeIndex + focusEqual = newSelection.focusNode.isEqualNode oldSelection.endNode + focusIndexEqual = focusIndex is oldSelection.endNodeIndex + if not anchorEqual and not focusEqual + # This means the newSelection is the same, but just from the opposite + # direction. We don't care in this case, so check the reciprocal as + # well. + anchorEqual = newSelection.anchorNode.isEqualNode oldSelection.endNode + anchorIndexEqual = anchorIndex is oldSelection.endNodeIndex + focusEqual = newSelection.focusNode.isEqualNode oldSelection.startNode + focusIndexEqual = focusIndex is oldSelection.startndNodeIndex + + anchorOffsetEqual = newSelection.anchorOffset == oldSelection.startOffset + focusOffsetEqual = newSelection.focusOffset == oldSelection.endOffset + if not anchorOffsetEqual and not focusOffsetEqual + # This means the newSelection is the same, but just from the opposite + # direction. We don't care in this case, so check the reciprocal as + # well. + anchorOffsetEqual = newSelection.anchorOffset == oldSelection.focusOffset + focusOffsetEqual = newSelection.focusOffset == oldSelection.anchorOffset + + if (anchorEqual and + anchorIndexEqual and + anchorOffsetEqual and + focusEqual and + focusIndexEqual and + focusOffsetEqual) + return true + else + return false + + + getRangeInScope: (scope) => + selection = document.getSelection() + return null if not DOMUtils.selectionInScope(selection, scope) + try + range = selection.getRangeAt(0) + catch + console.warn "Selection is not returning a range" + return document.createRange() + range + + selectionInScope: (selection, scope) -> + return false if not selection? + return false if not scope? + return (scope.contains(selection.anchorNode) and + scope.contains(selection.focusNode)) + + isEmptyBoudingRect: (rect) -> + rect.top is 0 and rect.bottom is 0 and rect.left is 0 and rect.right is 0 + + atEndOfContent: (selection, rootScope, containerScope) -> + containerScope ?= rootScope + if selection.isCollapsed + + # We need to use `lastChild` instead of `lastElementChild` because + # we need to eventually check if the `selection.focusNode`, which is + # usually a TEXT node, is equal to the returned `lastChild`. + # `lastElementChild` will not return TEXT nodes. + # + # Unfortunately, `lastChild` can sometime return COMMENT nodes and + # other blank TEXT nodes that we don't want to compare to. + # + # For example, if you have the structure: + #
      + #

      Foo

      + #
      + # + # The div may have 2 childNodes and 1 childElementNode. The 2nd + # hidden childNode is a TEXT node with a data of "\n". I actually + # want to return the

      . + # + # However, The

      element may have 1 childNode and 0 + # childElementNodes. In that case I DO want to return the TEXT node + # that has the data of "foo" + lastChild = DOMUtils.lastNonBlankChildNode(containerScope) + + # Special case for a completely empty contenteditable. + # In this case `lastChild` will be null, but we are definitely at + # the end of the content. + if containerScope is rootScope + return true if containerScope.childNodes.length is 0 + + return false unless lastChild + + # NOTE: `.contains` returns true if `lastChild` is equal to + # `selection.focusNode` + # + # See: http://ejohn.org/blog/comparing-document-position/ + inLastChild = lastChild.contains(selection.focusNode) + + # We should do true object identity here instead of `.isEqualNode` + isLastChild = lastChild is selection.focusNode + + if isLastChild + if selection.focusNode?.length + atEndIndex = selection.focusOffset is selection.focusNode.length + else + atEndIndex = selection.focusOffset is 0 + return atEndIndex + else if inLastChild + DOMUtils.atEndOfContent(selection, rootScope, lastChild) + else return false + + else return false + + lastNonBlankChildNode: (node) -> + lastNode = null + for childNode in node.childNodes by -1 + if childNode.nodeType is Node.TEXT_NODE + if DOMUtils.isBlankTextNode(childNode) + continue + else + return childNode + else if childNode.nodeType is Node.ELEMENT_NODE + return childNode + else continue + return lastNode + + isBlankTextNode: (node) -> + return if not node?.data + # \u00a0 is   + node.data.replace(/\u00a0/g, "x").trim().length is 0 + + findSimilarNodes: (context, nodeToFind) -> + nodeList = [] + if nodeToFind.isEqualNode(context) + nodeList.push(context) + return nodeList + treeWalker = document.createTreeWalker context + while treeWalker.nextNode() + if treeWalker.currentNode.isEqualNode nodeToFind + nodeList.push(treeWalker.currentNode) + + return nodeList + escapeHTMLCharacters: (text) -> map = '&': '&', @@ -86,6 +292,15 @@ DOMUtils = nodes.unshift(node) while node = node.parentNode return nodes + # Returns true if the node is the first child of the root, is the root, + # or is the first child of the first child of the root, etc. + isFirstChild: (root, node) -> + return false unless root and node + return true if root is node + return false unless root.childNodes[0] + return true if root.childNodes[0] is node + return DOMUtils.isFirstChild(root.childNodes[0], node) + # This method finds the bounding points of the word that the range # is currently within and selects that word. selectWordContainingRange: (range) -> diff --git a/src/flux/stores/draft-store-extension.coffee b/src/flux/stores/draft-store-extension.coffee index 7ccc7f953..f3f59032b 100644 --- a/src/flux/stores/draft-store-extension.coffee +++ b/src/flux/stores/draft-store-extension.coffee @@ -43,6 +43,29 @@ class DraftStoreExtension @warningsForSending: (draft) -> [] + ### + Public: declare an icon to be displayed in the composer's toolbar (where + bold, italic, underline, etc are). + + You must declare the following properties: + + - `mutator`: A function that's called when your toolbar button is + clicked. This mutator function will be passed as its only argument the + `dom`. The `dom` is the full {DOM} object of the current composer. You + may mutate this in place. We don't care about the mutator's return + value. + + - `tooltip`: A one or two word description of what your icon does + + - `iconUrl`: The url of your icon. It should be in the `nylas://` scheme. + For example: `nylas://your-package-name/assets/my-icon@2x.png`. Note, we + will downsample your image by 2x (for Retina screens), so make sure it's + twice the resolution. The icon should be black and white. We will + directly pass the `url` prop of a {RetinaImg} + ### + @composerToolbar: -> + return + ### Public: Override prepareNewDraft to modify a brand new draft before it is displayed in a composer. This is one of the only places in the application where it's safe @@ -129,8 +152,12 @@ class DraftStoreExtension return ### - Public: Override onInput in your DraftStoreExtension subclass to implement - custom behavior as the user types in the composer's contenteditable body field. + Public: Override onInput in your DraftStoreExtension subclass to + implement custom behavior as the user types in the composer's + contenteditable body field. + + As the first argument you are passed the entire DOM object of the + composer. You may mutate this object and edit it in place. Example: