_ = require 'underscore-plus' React = require 'react' sanitizeHtml = require 'sanitize-html' {Utils} = require 'inbox-exports' FloatingToolbar = require './floating-toolbar.cjsx' linkUUID = 0 genLinkId = -> linkUUID += 1; return linkUUID module.exports = ContenteditableComponent = React.createClass propTypes: html: React.PropTypes.string style: React.PropTypes.object tabIndex: React.PropTypes.string onChange: React.PropTypes.func.isRequired mode: React.PropTypes.object onChangeMode: React.PropTypes.func initialSelectionSnapshot: React.PropTypes.object getInitialState: -> toolbarTop: 0 toolbarMode: "buttons" toolbarLeft: 0 toolbarPos: "above" editAreaWidth: 9999 # This will get set on first selection toolbarVisible: false componentDidMount: -> @_setupSelectionListeners() @_setupLinkHoverListeners() componentWillUnmount: -> @_teardownSelectionListeners() @_teardownLinkHoverListeners() componentWillReceiveProps: (nextProps) -> if nextProps.initialSelectionSnapshot? @_setSelectionSnapshot(nextProps.initialSelectionSnapshot) @_refreshToolbarState() componentWillUpdate: -> @_teardownLinkHoverListeners() componentDidUpdate: -> @_setupLinkHoverListeners() @_restoreSelection() render: ->
focus: -> @_editableNode().focus() if @isMounted() _onInput: (event) -> @_editableNode().normalize() @_setNewSelectionState() html = @_unapplyHTMLDisplayFilters(@_editableNode().innerHTML) @props.onChange(target: value: html) _onBlur: (event) -> # The delay here is necessary to see if the blur was caused by us # navigating to the toolbar and focusing on the set-url input. _.delay => return unless @isMounted() # Who knows what can happen in 50ms @_hideToolbar() , 50 _editableNode: -> @refs.contenteditable.getDOMNode() _getAllLinks: -> Array.prototype.slice.call(@_editableNode().querySelectorAll("*[href]")) _dangerouslySetInnerHTML: -> __html: @_applyHTMLDisplayFilters(@props.html) _applyHTMLDisplayFilters: (html) -> html = @_removeQuotedTextFromHTML(html) unless @props.mode?.showQuotedText return html _unapplyHTMLDisplayFilters: (html) -> html = @_addQuotedTextToHTML(html) unless @props.mode?.showQuotedText return html ######### SELECTION MANAGEMENT ########## # # Saving and restoring a selection is difficult with React. # # React only handles Input and Textarea elements: # https://github.com/facebook/react/blob/master/src/browser/ui/ReactInputSelection.js # This is because they expose a very convenient `selectionStart` and # `selectionEnd` integer. # # Contenteditable regions are trickier. They require the more # sophisticated `Range` and `Selection` APIs. # # Range docs: # http://www.w3.org/TR/DOM-Level-2-Traversal-Range/ranges.html # # Selection API docs: # http://www.w3.org/TR/selection-api/#dfn-range # # A Contenteditable region can have arbitrary html inside of it. This # means that a selection start point can be some node (the `anchorNode`) # and its end point can be a completely different node (the `focusNode`) # # When React re-renders, all of the DOM nodes may change. They may # look exactly the same, but have different object references. # # This means that your old references to `anchorNode` and `focusNode` # may be bad and no longer in scope or painted. # # In order to restore the selection properly we need to re-find the # equivalent `anchorNode` and `focusNode`. Luckily we can use the # `isEqualNode` method to get a shallow comparison of the nodes. # # Unfortunately it's possible for `isEqualNode` to match more than one # node since two nodes may look very similar. # # To fix this we need to keep track of the original indices to determine # which node is most likely the matching one. # http://www.w3.org/TR/selection-api/#selectstart-event _setupSelectionListeners: -> @_onSelectionChange = => @_setNewSelectionState() document.addEventListener "selectionchange", @_onSelectionChange _teardownSelectionListeners: -> document.removeEventListener("selectionchange", @_onSelectionChange) getCurrentSelection: -> _.clone(@_selection ? {}) getPreviousSelection: -> _.clone(@_previousSelection ? {}) # Every time the cursor changes we need to preserve its location and # state. # # We can't use React's `state` variable because cursor position is not # naturally supported in the virtual DOM. # # We also need to make sure that node references are cloned so they # don't change out from underneath us. # # We also need to keep references to the previous selection state in # order for undo/redo to work properly. # # We need to be sure to deeply `cloneNode`. This is because sometimes # our anchorNodes are divs with nested
tags. If we don't do a deep # clone then when `isEqualNode` is run it will erroneously return false # and our selection restoration will fail _setNewSelectionState: -> selection = document.getSelection() return if not @_selectionInScope(selection) return if @_checkSameSelection(selection) try range = selection.getRangeAt(0) catch return return if not range? @_previousSelection = @_selection if selection.isCollapsed selectionRect = null else selectionRect = range.getBoundingClientRect() @_selection = startNode: range.startContainer?.cloneNode(true) startOffset: range.startOffset startNodeIndex: @_getNodeIndex(range.startContainer) endNode: range.endContainer?.cloneNode(true) endOffset: range.endOffset endNodeIndex: @_getNodeIndex(range.endContainer) isCollapsed: selection.isCollapsed selectionRect: selectionRect @_refreshToolbarState() return @_selection _setSelectionSnapshot: (selection) -> @_previousSelection = @_selection @_selection = selection # When we're dragging we don't want to the restoring the cursor as we're # dragging. Doing so caused selecting backwards to break because the # Selection API does not yet expose the selection "direction". When we # would go to reset the cursor selection, it would reset to the wrong # state. _onMouseDown: (event) -> @_ignoreSelectionRestoration = true return event _onMouseUp: (event) -> @_ignoreSelectionRestoration = false return event # We manually restore the selection on every render and when we need to # move the selection around manually. # # force - when set to true it will not care whether or not the selection # is already in the box. Normally we only restore when the # contenteditable is in focus # collapse - Can either be "end" or "start". When we reset the # selection, we'll collapse the range into a single caret # position _restoreSelection: ({force, collapse}={}) -> return if @_ignoreSelectionRestoration return if not @_selection? return if document.activeElement isnt @_editableNode() and not force return if not @_selection.startNode? or not @_selection.endNode? range = document.createRange() startNode = @_findSimilarNodes(@_selection.startNode)[@_selection.startNodeIndex] endNode = @_findSimilarNodes(@_selection.endNode)[@_selection.endNodeIndex] return if not startNode? or not endNode? # We want to not care about the selection direction. # Selecting from index 1 to index 5 is the same as selecting from # index 5 to index 1. However, this only works if the nodes we are # grabbing the index from are the same. If they are different, then we # can no longer make this gaurantee and have to grab their listed # offsets. if startNode is endNode startIndex = Math.min(@_selection.startOffset ? 0, @_selection.endOffset ? 0) startIndex = Math.min(startIndex, startNode.length) endIndex = Math.max(@_selection.startOffset ? 0, @_selection.endOffset ? 0) endIndex = Math.min(endIndex, endNode.length) else startIndex = @_selection.startOffset endIndex = @_selection.endOffset if collapse is "end" startNode = endNode startIndex = endIndex else if collapse is "start" endNode = startNode endIndex = endIndex try range.setStart(startNode, startIndex) range.setEnd(endNode, endIndex) catch return selection = document.getSelection() @_teardownSelectionListeners() selection.removeAllRanges() selection.addRange(range) @_setupSelectionListeners() # We need to break each node apart and cache since the `selection` # object will mutate underneath us. _checkSameSelection: (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 = [] 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: -> 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 if @_selection.isCollapsed linkRect = linksInside[0].getBoundingClientRect() [left, top, editAreaWidth, toolbarPos] = @_getToolbarPos(linkRect) else selectionRect = @_selection.selectionRect [left, top, editAreaWidth, toolbarPos] = @_getToolbarPos(selectionRect) @setState toolbarVisible: true toolbarMode: "buttons" toolbarTop: top toolbarLeft: left toolbarPos: toolbarPos linkToModify: null editAreaWidth: editAreaWidth # See selection API: http://www.w3.org/TR/selection-api/ _selectionInScope: (selection) -> return false if not selection? return false if not @isMounted() editNode = @refs.contenteditable.getDOMNode() return (editNode.contains(selection.anchorNode) and editNode.contains(selection.focusNode)) CONTENT_PADDING: 15 _getToolbarPos: (referenceRect) -> TOP_PADDING = 10 BORDER_RADIUS_PADDING = 15 editArea = @refs.contenteditable.getDOMNode().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: -> @refs.floatingToolbar.getDOMNode().contains(document.activeElement) # This needs to be in the contenteditable area because we need to first # restore the selection before calling the `execCommand` # # If the url is empty, that means we want to remove the url. _onSaveUrl: (url, linkToModify) -> if linkToModify? linkToModify = @_findSimilarNodes(linkToModify)?[0]?.childNodes[0] return if not linkToModify? range = document.createRange() try range.setStart(linkToModify, 0) range.setEnd(linkToModify, linkToModify.length) catch return selection = document.getSelection() @_teardownSelectionListeners() selection.removeAllRanges() selection.addRange(range) if url.trim().length is 0 document.execCommand("unlink", false) else document.execCommand("createLink", false, url) @_setupSelectionListeners() else @_restoreSelection(force: true) if document.getSelection().isCollapsed # TODO else if url.trim().length is 0 document.execCommand("unlink", false) else document.execCommand("createLink", false, url) @_restoreSelection(force: true, collapse: "end") _setupLinkHoverListeners: -> HOVER_IN_DELAY = 250 HOVER_OUT_DELAY = 1000 @_links = {} links = @_getAllLinks() return if links.length is 0 links.forEach (link) => link.hoverId = genLinkId() @_links[link.hoverId] = {} enterListener = (event) => @_clearLinkTimeouts() @_linkHoveringOver = link @_links[link.hoverId].enterTimeout = setTimeout => return unless @isMounted() @_refreshToolbarState() , HOVER_IN_DELAY leaveListener = (event) => @_clearLinkTimeouts() @_linkHoveringOver = null @_links[link.hoverId].leaveTimeout = setTimeout => return unless @isMounted() 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) -> html = evt.clipboardData.getData("text/html") ? "" if html.length is 0 text = evt.clipboardData.getData("text/plain") ? "" if text.length > 0 evt.preventDefault() cleanHtml = text else else evt.preventDefault() cleanHtml = @_sanitizeHtml(html) document.execCommand("insertHTML", false, cleanHtml) return false # This is used primarily when pasting text in _sanitizeHtml: (html) -> cleanHtml = sanitizeHtml html.replace(/\n/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" cleanHtml.replace(/

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

") ####### QUOTED TEXT ######### _onToggleQuotedText: -> @props.onChangeMode?(showQuotedText: !@props.mode?.showQuotedText) _quotedTextClasses: -> React.addons.classSet "quoted-text-control": true "no-quoted-text": @_htmlQuotedTextStart() is -1 "show-quoted-text": @props.mode?.showQuotedText _htmlQuotedTextStart: -> @props.html.search(/<[^>]*gmail_quote/) _removeQuotedTextFromHTML: (html) -> quoteStart = @_htmlQuotedTextStart() if quoteStart is -1 then return html else return html.substr(0, quoteStart) _addQuotedTextToHTML: (innerHTML) -> quoteStart = @_htmlQuotedTextStart() if quoteStart is -1 then return innerHTML else return (innerHTML + @props.html.substr(quoteStart))