mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-02-03 22:11:57 +08:00
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
This commit is contained in:
parent
98ca7f15bd
commit
b50d488f2e
12 changed files with 1172 additions and 749 deletions
91
internal_packages/composer/lib/clipboard-service.coffee
Normal file
91
internal_packages/composer/lib/clipboard-service.coffee
Normal file
|
@ -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, "<br/>").
|
||||
replace(/\s\s/g, " ")
|
||||
else
|
||||
inputText = sanitizeHtml inputText.replace(/[\n\r]/g, "<br/>"),
|
||||
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 <p> tags. We want to de-wrap <p> tags and replace
|
||||
# with two line breaks instead.
|
||||
inputText = inputText.replace(/<p[\s\S]*?>/gim, "").
|
||||
replace(/<\/p>/gi, "<br/>")
|
||||
|
||||
# We never want more then 2 line breaks in a row.
|
||||
# https://regex101.com/r/gF6bF4/4
|
||||
inputText = inputText.replace(/(<br\s*\/?>\s*){3,}/g, "<br/><br/>")
|
||||
|
||||
# We never want to keep leading and trailing <brs>, since the user
|
||||
# would have started a new paragraph themselves if they wanted space
|
||||
# before what they paste.
|
||||
# BAD: "<p>begins at<br>12AM</p>" => "<br><br>begins at<br>12AM<br><br>"
|
||||
# Better: "<p>begins at<br>12AM</p>" => "begins at<br>12"
|
||||
inputText = inputText.replace(/^(<br ?\/>)+/, '')
|
||||
inputText = inputText.replace(/(<br ?\/>)+$/, '')
|
||||
|
||||
return inputText
|
||||
|
||||
module.exports = ClipboardService
|
|
@ -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: ->
|
||||
<div className="composer-centered">
|
||||
{if @state.focusedField in Fields.ParticipantFields
|
||||
<ExpandedParticipants to={@state.to} cc={@state.cc}
|
||||
bcc={@state.bcc}
|
||||
from={@state.from}
|
||||
ref="expandedParticipants"
|
||||
mode={@props.mode}
|
||||
focusedField={@state.focusedField}
|
||||
enabledFields={@state.enabledFields}
|
||||
onChangeParticipants={@_onChangeParticipants}
|
||||
onChangeEnabledFields={@_onChangeEnabledFields}
|
||||
/>
|
||||
<ExpandedParticipants
|
||||
to={@state.to} cc={@state.cc} bcc={@state.bcc}
|
||||
from={@state.from}
|
||||
ref="expandedParticipants"
|
||||
mode={@props.mode}
|
||||
focusedField={@state.focusedField}
|
||||
enabledFields={@state.enabledFields}
|
||||
onPopoutComposer={@_onPopoutComposer}
|
||||
onChangeParticipants={@_onChangeParticipants}
|
||||
onChangeEnabledFields={@_onChangeEnabledFields} />
|
||||
else
|
||||
<CollapsedParticipants to={@state.to} cc={@state.cc}
|
||||
bcc={@state.bcc}
|
||||
onClick={@_focusParticipantField} />
|
||||
<CollapsedParticipants
|
||||
to={@state.to} cc={@state.cc} bcc={@state.bcc}
|
||||
onClick={@_focusParticipantField} />
|
||||
}
|
||||
|
||||
{@_renderSubject()}
|
||||
|
@ -219,6 +220,9 @@ class ComposerView extends React.Component
|
|||
</div>
|
||||
</div>
|
||||
|
||||
_onPopoutComposer: =>
|
||||
Actions.composePopoutDraft @props.draftClientId
|
||||
|
||||
_onKeyDown: (event) =>
|
||||
if event.key is "Tab"
|
||||
@_onTabDown(event)
|
||||
|
@ -268,23 +272,55 @@ class ComposerView extends React.Component
|
|||
{@_renderAttachments()}
|
||||
</span>
|
||||
|
||||
_renderBodyContenteditable: =>
|
||||
onScrollToBottom = null
|
||||
if @props.onRequestScrollTo
|
||||
onScrollToBottom = =>
|
||||
@props.onRequestScrollTo({clientId: @_proxy.draft().clientId})
|
||||
_renderBodyContenteditable: ->
|
||||
<ContenteditableComponent
|
||||
ref={Fields.Body}
|
||||
html={@state.body}
|
||||
onFocus={ => @setState focusedField: Fields.Body}
|
||||
filters={@_editableFilters()}
|
||||
onChange={@_onChangeBody}
|
||||
onScrollTo={@props.onRequestScrollTo}
|
||||
onFilePaste={@_onFilePaste}
|
||||
footerElements={@_editableFooterElements()}
|
||||
onScrollToBottom={@_onScrollToBottom()}
|
||||
initialSelectionSnapshot={@_recoveredSelection} />
|
||||
|
||||
<ContenteditableComponent ref={Fields.Body}
|
||||
html={@state.body}
|
||||
onFocus={ => @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"
|
||||
<a className="quoted-text-control" onClick={@_onToggleQuotedText}>
|
||||
<span className="dots">•••</span>{text} previous
|
||||
</a>
|
||||
else return []
|
||||
|
||||
_onToggleQuotedText: =>
|
||||
@setState showQuotedText: not @state.showQuotedText
|
||||
|
||||
_renderFooterRegions: =>
|
||||
return <div></div> 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
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
11
internal_packages/composer/lib/contenteditable-filter.coffee
Normal file
11
internal_packages/composer/lib/contenteditable-filter.coffee
Normal file
|
@ -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
|
|
@ -90,7 +90,7 @@ class ExpandedParticipants extends React.Component
|
|||
<span className="header-action show-popout"
|
||||
data-tooltip="Popout composer"
|
||||
style={paddingLeft: "1.5em"}
|
||||
onClick={@_popoutComposer}>
|
||||
onClick={@props.onPopoutComposer}>
|
||||
<RetinaImg name="composer-popout.png"
|
||||
mode={RetinaImg.Mode.ContentIsMask}
|
||||
style={{position: "relative", top: "-2px"}}/>
|
||||
|
@ -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
|
||||
|
|
263
internal_packages/composer/lib/floating-toolbar-container.cjsx
Normal file
263
internal_packages/composer/lib/floating-toolbar-container.cjsx
Normal file
|
@ -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: ->
|
||||
<FloatingToolbar
|
||||
ref="floatingToolbar"
|
||||
top={@state.toolbarTop}
|
||||
left={@state.toolbarLeft}
|
||||
pos={@state.toolbarPos}
|
||||
mode={@state.toolbarMode}
|
||||
visible={true}
|
||||
onSaveUrl={@props.onSaveUrl}
|
||||
onMouseEnter={@_onEnterToolbar}
|
||||
onChangeMode={@_onChangeMode}
|
||||
onMouseLeave={@_onLeaveToolbar}
|
||||
onDomMutator={@props.onDomMutator}
|
||||
linkToModify={@state.linkToModify}
|
||||
onChangeFocus={@_onChangeFocus}
|
||||
contentPadding={@CONTENT_PADDING}
|
||||
editAreaWidth={@state.editAreaWidth} />
|
||||
|
||||
_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
|
|
@ -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 <div></div>
|
||||
|
||||
_renderButtons: =>
|
||||
|
@ -74,14 +88,30 @@ class FloatingToolbar extends React.Component
|
|||
<button className="btn btn-link toolbar-btn"
|
||||
onClick={@_showLink}
|
||||
data-command-name="link"></button>
|
||||
{@_toolbarExtensions()}
|
||||
</div>
|
||||
|
||||
_toolbarExtensions: ->
|
||||
buttons = []
|
||||
for extension in DraftStore.extensions()
|
||||
toolbarItem = extension.composerToolbar?()
|
||||
if toolbarItem
|
||||
buttons.push(
|
||||
<button className="btn btn-extension"
|
||||
onClick={ => @_extensionMutateDom(toolbarItem.mutator)}
|
||||
data-tooltip="#{toolbarItem.tooltip}"><RetinaImg mode={RetinaImg.Mode.ContentIsMask} url="#{toolbarItem.iconUrl}" /></button>)
|
||||
return buttons
|
||||
|
||||
_extensionMutateDom: (mutator) =>
|
||||
@props.onDomMutator(mutator)
|
||||
|
||||
_renderLink: =>
|
||||
removeBtn = ""
|
||||
withRemove = ""
|
||||
if @_initialUrl()
|
||||
withRemove = "with-remove"
|
||||
removeBtn = <button className="btn btn-icon"
|
||||
ref="removeBtn"
|
||||
onMouseDown={@_removeUrl}><i className="fa fa-times"></i></button>
|
||||
|
||||
<div className="toolbar-new-link"
|
||||
|
@ -91,13 +121,15 @@ class FloatingToolbar extends React.Component
|
|||
<input type="text"
|
||||
ref="urlInput"
|
||||
value={@state.urlInputValue}
|
||||
onBlur={@_saveUrl}
|
||||
onBlur={@_onBlur}
|
||||
onFocus={@_onFocus}
|
||||
onClick={@_onPreventToolbarClose}
|
||||
onKeyPress={@_saveUrlOnEnter}
|
||||
onChange={@_onInputChange}
|
||||
className="floating-toolbar-input #{withRemove}"
|
||||
placeholder="Paste or type a link" />
|
||||
<button className="btn btn-icon"
|
||||
ref="saveBtn"
|
||||
onKeyPress={@_saveUrlOnEnter}
|
||||
onMouseDown={@_saveUrl}><i className="fa fa-check"></i></button>
|
||||
{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
|
||||
|
|
167
internal_packages/composer/spec/clipboard-service-spec.coffee
Normal file
167
internal_packages/composer/spec/clipboard-service-spec.coffee
Normal file
|
@ -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 '<strong>This is text</strong>' if 'text/html'
|
||||
return 'This is plain text' if 'text/plain'
|
||||
return null
|
||||
items: [{
|
||||
kind: 'string'
|
||||
type: 'text/html'
|
||||
getAsString: -> '<strong>This is text</strong>'
|
||||
},{
|
||||
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('<strong>This is text</strong>', 'text/html')
|
||||
[command, a, html] = document.execCommand.mostRecentCall.args
|
||||
expect(command).toEqual('insertHTML')
|
||||
expect(html).toEqual('<strong>This is text</strong>')
|
||||
|
||||
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<br />World"
|
||||
# Convert newline to br
|
||||
sanitizedAsPlain: "Hello<br/>World"
|
||||
},
|
||||
{
|
||||
in: "Hello\rWorld"
|
||||
sanitizedAsHTML: "Hello<br />World"
|
||||
# Convert carriage return to br
|
||||
sanitizedAsPlain: "Hello<br/>World"
|
||||
},
|
||||
{
|
||||
in: "Hello\n\n\nWorld"
|
||||
# Never have more than 2 br's in a row
|
||||
sanitizedAsHTML: "Hello<br/><br/>World"
|
||||
# Convert multiple newlines to same number of brs
|
||||
sanitizedAsPlain: "Hello<br/><br/><br/>World"
|
||||
},
|
||||
{
|
||||
in: "<style>Yo</style> Foo Bar <div>Baz</div>"
|
||||
# Strip bad tags
|
||||
sanitizedAsHTML: " Foo Bar Baz"
|
||||
# HTML encode tags for literal display
|
||||
sanitizedAsPlain: "<style>Yo</style> Foo Bar <div>Baz</div>"
|
||||
},
|
||||
{
|
||||
in: "<script>Bah</script> 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: """
|
||||
<!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>"""
|
||||
# Strip non white-list tags and encode malformed ones.
|
||||
sanitizedAsHTML: "<ul><br /><li><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><br /></ul>"
|
||||
# HTML encode tags for literal display
|
||||
sanitizedAsPlain: "<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd"><br/><html><br/><head><br/><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><br/><meta http-equiv="Content-Style-Type" content="text/css"><br/><title></title><br/><meta name="Generator" content="Cocoa HTML Writer"><br/><meta name="CocoaVersion" content="1265.21"><br/><style type="text/css"><br/>li.li1 {margin: 0.0px 0.0px 0.0px 0.0px; font: 12.0px Helvetica}<br/>ul.ul1 {list-style-type: disc}<br/></style><br/></head><br/><body><br/><ul class="ul1"><br/><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><br/></ul><br/></body><br/></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
|
|
@ -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 '<strong>This is text</strong>' if 'text/html'
|
||||
return 'This is plain text' if 'text/plain'
|
||||
return null
|
||||
items: [{
|
||||
kind: 'string'
|
||||
type: 'text/html'
|
||||
getAsString: -> '<strong>This is text</strong>'
|
||||
},{
|
||||
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('<strong>This is text</strong>', 'text/html')
|
||||
[command, a, html] = document.execCommand.mostRecentCall.args
|
||||
expect(command).toEqual('insertHTML')
|
||||
expect(html).toEqual('<strong>This is text</strong>')
|
||||
|
||||
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<br />World"
|
||||
# Convert newline to br
|
||||
sanitizedAsPlain: "Hello<br/>World"
|
||||
},
|
||||
{
|
||||
in: "Hello\rWorld"
|
||||
sanitizedAsHTML: "Hello<br />World"
|
||||
# Convert carriage return to br
|
||||
sanitizedAsPlain: "Hello<br/>World"
|
||||
},
|
||||
{
|
||||
in: "Hello\n\n\nWorld"
|
||||
# Never have more than 2 br's in a row
|
||||
sanitizedAsHTML: "Hello<br/><br/>World"
|
||||
# Convert multiple newlines to same number of brs
|
||||
sanitizedAsPlain: "Hello<br/><br/><br/>World"
|
||||
},
|
||||
{
|
||||
in: "<style>Yo</style> Foo Bar <div>Baz</div>"
|
||||
# Strip bad tags
|
||||
sanitizedAsHTML: " Foo Bar Baz"
|
||||
# HTML encode tags for literal display
|
||||
sanitizedAsPlain: "<style>Yo</style> Foo Bar <div>Baz</div>"
|
||||
},
|
||||
{
|
||||
in: "<script>Bah</script> 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: """
|
||||
<!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>"""
|
||||
# Strip non white-list tags and encode malformed ones.
|
||||
sanitizedAsHTML: "<ul><br /><li><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><br /></ul>"
|
||||
# HTML encode tags for literal display
|
||||
sanitizedAsPlain: "<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd"><br/><html><br/><head><br/><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><br/><meta http-equiv="Content-Style-Type" content="text/css"><br/><title></title><br/><meta name="Generator" content="Cocoa HTML Writer"><br/><meta name="CocoaVersion" content="1265.21"><br/><style type="text/css"><br/>li.li1 {margin: 0.0px 0.0px 0.0px 0.0px; font: 12.0px Helvetica}<br/>ul.ul1 {list-style-type: disc}<br/></style><br/></head><br/><body><br/><ul class="ul1"><br/><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><br/></ul><br/></body><br/></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
|
||||
|
|
|
@ -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 <strong>HTML</strong><br>'
|
||||
@htmlWithQuote = 'Test <strong>HTML</strong><br><blockquote class="gmail_quote">QUOTE</blockquote>'
|
||||
|
||||
@composer = ReactTestUtils.renderIntoDocument(<Composer draftClientId="unused"/>)
|
||||
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(
|
||||
<ContenteditableComponent html={@htmlNoQuote}
|
||||
onChange={@onChange}
|
||||
mode={showQuotedText: true}/>)
|
||||
@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 <strong>TEXT</strong>!"
|
||||
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(
|
||||
<ContenteditableComponent html={@htmlWithQuote}
|
||||
onChange={@onChange}
|
||||
mode={showQuotedText: true}/>)
|
||||
@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 <strong>TEXT</strong>!"
|
||||
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 <strong>NEW 1 HTML</strong><blockquote class="gmail_quote">QUOTE CHANGED!!!</blockquote>'
|
||||
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(
|
||||
<ContenteditableComponent html={@htmlWithQuote}
|
||||
onChange={@onChange}
|
||||
mode={showQuotedText: false}/>)
|
||||
@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 <strong>TEXT</strong>!"
|
||||
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 <blockquote class=\"gmail_quote\">I'm a fake quote</blockquote>"
|
||||
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))
|
||||
|
|
|
@ -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:
|
||||
# <div>
|
||||
# <p>Foo</p>
|
||||
# </div>
|
||||
#
|
||||
# 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 <p></p>.
|
||||
#
|
||||
# However, The <p> 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) ->
|
||||
|
|
|
@ -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:
|
||||
|
||||
|
|
Loading…
Reference in a new issue