_ = require 'underscore' React = require 'react' classNames = require 'classnames' sanitizeHtml = require 'sanitize-html' {Utils, QuotedHTMLParser, DraftStore} = require 'nylas-exports' FloatingToolbar = require './floating-toolbar' linkUUID = 0 genLinkId = -> linkUUID += 1; return linkUUID class ContenteditableComponent extends React.Component @displayName = "Contenteditable" @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 # Passes an absolute top coordinate to scroll to. onScrollTo: React.PropTypes.func onScrollToBottom: React.PropTypes.func constructor: (@props) -> @state = toolbarTop: 0 toolbarMode: "buttons" toolbarLeft: 0 toolbarPos: "above" editAreaWidth: 9999 # This will get set on first selection toolbarVisible: false componentDidMount: => @_editableNode().addEventListener('contextmenu', @_onShowContextualMenu) @_setupSelectionListeners() @_setupLinkHoverListeners() @_setupGlobalMouseListener() @_disposable = atom.commands.add '.contenteditable-container *', { 'core:focus-next': (event) => editableNode = @_editableNode() range = @_getRangeInScope() for extension in DraftStore.extensions() extension.onFocusNext(editableNode, range, event) if extension.onFocusNext 'core:focus-previous': (event) => editableNode = @_editableNode() range = @_getRangeInScope() for extension in DraftStore.extensions() extension.onFocusPrevious(editableNode, range, event) if extension.onFocusPrevious } @_cleanHTML() shouldComponentUpdate: (nextProps, nextState) -> not Utils.isEqualReact(nextProps, @props) or not Utils.isEqualReact(nextState, @state) componentWillUnmount: => @_editableNode().removeEventListener('contextmenu', @_onShowContextualMenu) @_teardownSelectionListeners() @_teardownLinkHoverListeners() @_teardownGlobalMouseListener() @_disposable.dispose() componentWillReceiveProps: (nextProps) => if nextProps.initialSelectionSnapshot? @_setSelectionSnapshot(nextProps.initialSelectionSnapshot) @_refreshToolbarState() componentWillUpdate: (nextProps, nextState) => @_teardownLinkHoverListeners() componentDidUpdate: => @_cleanHTML() @_setupLinkHoverListeners() @_restoreSelection() render: =>
focus: => @_editableNode().focus() selectEnd: => range = document.createRange() range.selectNodeContents(@_editableNode()) range.collapse(false) @_editableNode().focus() selection = window.getSelection() selection.removeAllRanges() selection.addRange(range) _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. # Note: Related to composer-view#_onClickComposeBody event.stopPropagation() _onKeyDown: (event) => if event.key is "Tab" @_onTabDown(event) return _onInput: => return if @_ignoreInputChanges @_ignoreInputChanges = true @_dragging = false @_runCoreFilters() @_runExtensionFilters() @_prepareForReactContenteditable() @_saveSelectionState() html = @_unapplyHTMLDisplayFilters(@_editableNode().innerHTML) @props.onChange(target: {value: html}) @_ignoreInputChanges = false return _runCoreFilters: -> @_createLists() # Determines if the user wants to add an ordered or unordered list. _createLists: -> # The `execCommand` will update the DOM and move the cursor. Since # this is happening in the middle of an `_onInput` callback, we want # 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. updateDOM = (command) => @_teardownSelectionListeners() @_ignoreInputChanges = true document.execCommand(command) selection = document.getSelection() selection.anchorNode.parentElement.innerHTML = "" @_ignoreInputChanges = false @_setupSelectionListeners() text = @_textContentAtCursor() if (/^\d\.\s$/).test text updateDOM("insertOrderedList") else if (/^-\s$/).test text updateDOM("insertUnorderedList") _onTabDown: (event) -> event.preventDefault() selection = document.getSelection() if selection?.isCollapsed # https://developer.mozilla.org/en-US/docs/Web/API/Element/closest # Some nodes anchorNodes might not have a `closest` method. if selection.anchorNode?.closest?("li") if event.shiftKey document.execCommand("outdent") else document.execCommand("indent") return document.execCommand("insertText", false, "\t") _textContentAtCursor: -> selection = document.getSelection() if selection.isCollapsed return selection.anchorNode?.textContent else return null _runExtensionFilters: -> for extension in DraftStore.extensions() extension.onInput(@_editableNode(), event) if extension.onInput # This component works by re-rendering on every change and restoring the # selection. This is also how standard React controlled inputs work too. # # Since the contets of the contenteditable are complex, nested DOM # 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: -> @_cleanHTML() @_cleanSelection() # We need to clean the HTML on input to fix several edge cases that # arise when we go to save the selection state and restore it on the # next render. _cleanHTML: -> return unless @_editableNode() # One issue is that we need to pre-normalize the HTML so it looks the # same after it gets re-inserted. If we key selection markers off of an # non normalized DOM, then they won't match up when the HTML gets reset. # # The Node.normalize() method puts the specified node and all of its # sub-tree into a "normalized" form. In a normalized sub-tree, no text # nodes in the sub-tree are empty and there are no adjacent text # nodes. @_editableNode().normalize() @_fixLeadingBRCondition() # An issue arises from
tags immediately inside of divs. In this # case the cursor's anchor node will not be the
tag, but rather # the entire enclosing element. Sometimes, that enclosing element is the # container wrapping all of the content. The browser has a native # built-in feature that will automatically scroll the page to the bottom # of the current element that the cursor is in if the cursor is off the # screen. In the given case, that element is the whole div. The net # effect is that the browser will scroll erroneously to the bottom of # the whole content div, which is likely NOT where the cursor is or the # user wants. The solution to this is to replace this particular case # with tags and place the cursor in there. _fixLeadingBRCondition: -> treeWalker = document.createTreeWalker @_editableNode() while treeWalker.nextNode() currentNode = treeWalker.currentNode if @_hasLeadingBRCondition(currentNode) newNode = document.createElement("div") newNode.appendChild(document.createElement("br")) currentNode.replaceChild(newNode, currentNode.childNodes[0]) return _hasLeadingBRCondition: (node) -> childNodes = node.childNodes return childNodes.length >= 2 and childNodes[0].nodeName is "BR" # 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 # each of those scenarios before we save and later restore the # selection. _cleanSelection: -> selection = document.getSelection() return unless selection.anchorNode? and selection.focusNode? # The _unselectableNode case only is valid when it's at the very top # (offset 0) of the node. If the offsets are > 0 that means we're # trying to select somewhere within some sort of containing element. # This is okay to do. The odd case only arises at the top of # unselectable elements. return if selection.anchorOffset > 0 or selection.focusOffset > 0 if selection.isCollapsed and @_unselectableNode(selection.focusNode) @_teardownSelectionListeners() treeWalker = document.createTreeWalker(selection.focusNode) while treeWalker.nextNode() currentNode = treeWalker.currentNode if @_unselectableNode(currentNode) selection.setBaseAndExtent(currentNode, 0, currentNode, 0) break @_setupSelectionListeners() return _unselectableNode: (node) -> return true if not node if node.nodeType is Node.TEXT_NODE and @_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)) 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 # 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() , 50 _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) ######### 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: => document.addEventListener("selectionchange", @_saveSelectionState) _teardownSelectionListeners: => document.removeEventListener("selectionchange", @_saveSelectionState) 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. # # 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. # # The Selection API has the concept of an `anchorNode` and a # `focusNode`. The `anchorNode` is where the selection started from and # does not move. The `focusNode` is where the end of the selection # currently is and may move. A "caret" is simply a selection whose # anchorNode == focusNode and anchorOffset == focusOffset. # # An `anchorNode` is also known as a `startNode`, or `baseNode`. We use # the alias `startNode` since I think it makes more intuitive sense. # # A `focusNode` is also known as an `endNode` or `focusNode`. I use the # `endNode` alias since it makes more inuitive sense. # # When we restore the selection later, we need to find a node that looks # the same as the one we saved (since they're different object # references). Unfortunately there many be many nodes that "look" the # same (match the `isEqualNode`) test. For example, say I have a bunch # of lines with the TEXT_NODE "Foo". All of those will match # `isEqualNode`. To fix this we assume there will be multiple matches # 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. _saveSelectionState: => selection = document.getSelection() return if @_checkSameSelection(selection) return unless selection.anchorNode? and selection.focusNode? return unless @_selectionInScope(selection) @_previousSelection = @_selection @_selection = startNode: selection.anchorNode.cloneNode(true) startOffset: selection.anchorOffset startNodeIndex: @_getNodeIndex(selection.anchorNode) endNode: selection.focusNode.cloneNode(true) endOffset: selection.focusOffset endNodeIndex: @_getNodeIndex(selection.focusNode) isCollapsed: selection.isCollapsed @_ensureSelectionVisible(selection) @_refreshToolbarState() 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 # 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. # # Unfortunately, we can't use the native `scrollIntoView` because it # naively scrolls the whole window and doesn't know not to scroll if # it's already in view. There's a new native method called # `scrollIntoViewIfNeeded`, but this only works when the scroll # container is a direct parent of the requested element. In this case # 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) @props.onScrollToBottom() # Don't bother computing client rects if no scroll method has been provided else if @props.onScrollTo rangeInScope = @_getRangeInScope() return unless rangeInScope rect = rangeInScope.getBoundingClientRect() if @_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 _getSelectionRectFromDOM: (selection) -> node = selection.anchorNode if node.nodeType is Node.TEXT_NODE r = document.createRange() r.selectNodeContents(node) return r.getBoundingClientRect() else if node.nodeType is Node.ELEMENT_NODE return node.getBoundingClientRect() else return null # We use global listeners to determine whether or not dragging is # happening. This is because dragging may stop outside the scope of # this element. Note that the `dragstart` and `dragend` events don't # detect text selection. They are for drag & drop. _setupGlobalMouseListener: => @__onMouseDown = _.bind(@_onMouseDown, @) @__onMouseMove = _.bind(@_onMouseMove, @) @__onMouseUp = _.bind(@_onMouseUp, @) window.addEventListener("mousedown", @__onMouseDown) window.addEventListener("mouseup", @__onMouseUp) _teardownGlobalMouseListener: => window.removeEventListener("mousedown", @__onMouseDown) window.removeEventListener("mouseup", @__onMouseUp) _onShowContextualMenu: (event) => @_hideToolbar() event.preventDefault() selection = document.getSelection() range = selection.getRangeAt(0) text = range.toString() remote = require('remote') clipboard = require('clipboard') Menu = remote.require('menu') MenuItem = remote.require('menu-item') spellchecker = require('spellchecker') apply = (newtext) => range.deleteContents() node = document.createTextNode(newtext) range.insertNode(node) range.selectNode(node) selection.removeAllRanges() selection.addRange(range) cut = => clipboard.writeText(text) apply('') copy = => clipboard.writeText(text) paste = => apply(clipboard.readText()) menu = new Menu() if spellchecker.isMisspelled(text) corrections = spellchecker.getCorrectionsForMisspelling(text) if corrections.length > 0 corrections.forEach (correction) -> menu.append(new MenuItem({ label: correction, click:( -> apply(correction))})) menu.append(new MenuItem({ type: 'separator' })) menu.append(new MenuItem({ label: 'Learn Spelling', click:( -> spellchecker.add(text))})) menu.append(new MenuItem({ type: 'separator' })) menu.append(new MenuItem({ label: 'Cut', click:cut})) menu.append(new MenuItem({ label: 'Copy', click:copy})) menu.append(new MenuItem({ label: 'Paste', click:paste})) menu.popup(remote.getCurrentWindow()) _onMouseDown: (event) => @_mouseDownEvent = event @_mouseHasMoved = false window.addEventListener("mousemove", @__onMouseMove) # We can't use the native double click event because that only fires # on the second up-stroke if Date.now() - (@_lastMouseDown ? 0) < 250 @_onDoubleDown(event) @_lastMouseDown = 0 # to prevent triple down else @_lastMouseDown = Date.now() _onDoubleDown: (event) => editable = @_editableNode() return unless editable? if editable is event.target or editable.contains(event.target) @_doubleDown = true _onMouseMove: (event) => if not @_mouseHasMoved @_onDragStart(@_mouseDownEvent) @_mouseHasMoved = true _onMouseUp: (event) => window.removeEventListener("mousemove", @__onMouseMove) if @_doubleDown @_doubleDown = false @_refreshToolbarState() if @_mouseHasMoved @_mouseHasMoved = false @_onDragEnd(event) editableNode = @_editableNode() selection = document.getSelection() return event unless @_selectionInScope(selection) range = @_getRangeInScope() if range try for extension in DraftStore.extensions() extension.onMouseUp(editableNode, range, event) if extension.onMouseUp catch e console.log('DraftStore extension raised an error: '+e.toString()) event _onDragStart: (event) => editable = @_editableNode() return unless editable? if editable is event.target or editable.contains(event.target) @_dragging = true _onDragEnd: (event) => if @_dragging @_dragging = false @_refreshToolbarState() return event # We restore the Selection via the `setBaseAndExtent` property of the # `Selection` API # # See http://w3c.github.io/selection-api/#widl-Selection-setBaseAndExtent-void-Node-anchorNode-unsigned-long-anchorOffset-Node-focusNode-unsigned-long-focusOffset # # Since the last time we saved the `@_selection`, the DOM may have # completely changed due to a re-render. To the user it may look # identical, but the newly rendered region may be comprised of # completely new DOM nodes. Our old node references may not exist # anymore. As such, we have the task of re-finding the nodes again and # creating a new selection that matches as accurately as possible. # # There are multiple ways of setting a new selection with the Selection # API. One very common one is to create a new Range object and then call # `addRange` on a selection instance. This does NOT work for us because # `Range` objects are direction-less. A Selection's start node (aka # anchor node aka base node) can be "after" a selection's end node (aka # focus node aka extent node). # # 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 @_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] return if not newStartNode? or not newEndNode? @_teardownSelectionListeners() selection = document.getSelection() selection.setBaseAndExtent(newStartNode, @_selection.startOffset, newEndNode, @_selection.endOffset) @_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) # 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 => @_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 "no-quoted-text": not QuotedHTMLParser.hasQuotedHTML(@props.html) "show-quoted-text": @props.mode?.showQuotedText module.exports = ContenteditableComponent