_ = require 'underscore' React = require 'react' {Utils, DOMUtils, DraftStore} = require 'nylas-exports' ClipboardService = require './clipboard-service' FloatingToolbarContainer = require './floating-toolbar-container' class ContenteditableComponent extends React.Component @displayName: "ContenteditableComponent" @propTypes: html: React.PropTypes.string initialSelectionSnapshot: React.PropTypes.object filters: React.PropTypes.array 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) -> @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() @_setupGlobalMouseListener() @_disposable = atom.commands.add '.contenteditable-container *', { 'core:focus-next': (event) => editableNode = @_editableNode() range = DOMUtils.getRangeInScope(editableNode) for extension in DraftStore.extensions() extension.onFocusNext(editableNode, range, event) if extension.onFocusNext 'core:focus-previous': (event) => editableNode = @_editableNode() 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) componentWillUnmount: => @_editableNode().removeEventListener('contextmenu', @_onShowContextualMenu) @_teardownSelectionListeners() @_teardownGlobalMouseListener() @_disposable.dispose() componentWillReceiveProps: (nextProps) => @_setupServices(nextProps) if nextProps.initialSelectionSnapshot? @_setSelectionSnapshot(nextProps.initialSelectionSnapshot) componentDidUpdate: => @_cleanHTML() @_restoreSelection() editableNode = @_editableNode() for extension in DraftStore.extensions() extension.onComponentDidUpdate(@_editableNode()) if extension.onComponentDidUpdate @setInnerState links: editableNode.querySelectorAll("*[href]") editableNode: editableNode render: =>
{@props.footerElements}
focus: => @_editableNode().focus() selectEnd: => range = document.createRange() range.selectNodeContents(@_editableNode()) range.collapse(false) @_editableNode().focus() selection = window.getSelection() 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. # Note: Related to composer-view#_onClickComposeBody event.stopPropagation() _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: (event) => return if @_ignoreInputChanges @_ignoreInputChanges = true @_resetInnerStateOnInput() @_runCoreFilters() @_runExtensionFilters(event) @_normalize() @_saveSelectionState() @_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 # 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. if @_resetListToText @_resetListToText = false return updateDOM = (command) => @_teardownSelectionListeners() document.execCommand(command) selection = document.getSelection() selection.anchorNode.parentElement.innerHTML = "" @_setupSelectionListeners() 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") # The native document.execCommand('outdent') _outdent: -> _closestAtCursor: (selector) -> selection = document.getSelection() return unless selection?.isCollapsed return @_closest(selection.anchorNode, selector) # https://developer.mozilla.org/en-US/docs/Web/API/Element/closest # Only Elements (not Text nodes) have the `closest` method _closest: (node, selector) -> el = if node instanceof HTMLElement then node else node.parentElement return el.closest(selector) _replaceFirstListItem: (li, replaceWith) -> @_teardownSelectionListeners() list = @_closest(li, "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() if selection?.isCollapsed # Only Elements (not Text nodes) have the `closest` method li = @_closestAtCursor("li") if li if event.shiftKey list = @_closestAtCursor("ul, ol") # BUG: As of 9/25/15 if you outdent the first item in a list, it # doesn't work :( if list.querySelectorAll('li')?[0] is li # We're in first li @_replaceFirstListItem(li, li.innerHTML) else document.execCommand("outdent") else document.execCommand("indent") else if event.shiftKey if @_atTabChar() @_removeLastCharacter() else if @_atBeginning() return # Don't stop propagation else document.execCommand("insertText", false, "\t") else if event.shiftKey document.execCommand("insertText", false, "") else document.execCommand("insertText", false, "\t") event.stopPropagation() _selectionInText: (selection) -> return false unless selection return selection.isCollapsed and selection.anchorNode.nodeType is Node.TEXT_NODE and selection.anchorOffset > 0 _atTabChar: -> selection = document.getSelection() if @_selectionInText(selection) 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 = @_closest(anchor, "li") return unless li return DOMUtils.isFirstChild(li, anchor) _atBeginning: -> selection = document.getSelection() return false if not selection.isCollapsed return false if selection.anchorOffset > 0 el = @_editableNode() return true if el.childNodes.length is 0 return true if selection.anchorNode is el firstChild = el.childNodes[0] return selection.anchorNode is firstChild _removeLastCharacter: -> selection = document.getSelection() if @_selectionInText(selection) node = selection.anchorNode offset = selection.anchorOffset @_teardownSelectionListeners() selection.setBaseAndExtent(node, offset - 1, node, offset) document.execCommand("delete") @_setupSelectionListeners() _textContentAtCursor: -> selection = document.getSelection() if selection.isCollapsed return selection.anchorNode?.textContent else return null # 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 contents 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. _normalize: -> @_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() @_collapseAdjacentLists() @_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" # If users ended up with two