_ = 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 fromFoo
# 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, " begins at begins at
")
# 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
12AM
begins at
12AM
"
# Better: "
12AM
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