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:
Evan Morikawa 2015-09-22 16:02:44 -07:00
parent 98ca7f15bd
commit b50d488f2e
12 changed files with 1172 additions and 749 deletions

View 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]|&#1[03];/g, "<br/>").
replace(/\s\s/g, " &nbsp;")
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

View file

@ -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">&bull;&bull;&bull;</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

View 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

View file

@ -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

View 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

View file

@ -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

View 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: " &nbsp;Hello &nbsp;World"
},
{
in: " Hello World"
sanitizedAsHTML: " Hello World"
# Preserving 3 spaces
sanitizedAsPlain: " &nbsp; Hello &nbsp; World"
},
{
in: " Hello World"
sanitizedAsHTML: " Hello World"
# Preserving 4 spaces
sanitizedAsPlain: " &nbsp; &nbsp;Hello &nbsp; &nbsp;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: "&lt;style&gt;Yo&lt;/style&gt; Foo Bar &lt;div&gt;Baz&lt;/div&gt;"
},
{
in: "<script>Bah</script> Yo < script>Boo! < / script >"
# Strip non white-list tags and encode malformed ones.
sanitizedAsHTML: " Yo &lt; script&gt;Boo! &lt; / script &gt;"
# HTML encode tags for literal display
sanitizedAsPlain: "&lt;script&gt;Bah&lt;/script&gt; Yo &lt; script&gt;Boo! &lt; / script &gt;"
},
{
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: "&lt;!DOCTYPE html PUBLIC &#34;-//W3C//DTD HTML 4.01//EN&#34; &#34;http://www.w3.org/TR/html4/strict.dtd&#34;&gt;<br/>&lt;html&gt;<br/>&lt;head&gt;<br/>&lt;meta http-equiv=&#34;Content-Type&#34; content=&#34;text/html; charset=UTF-8&#34;&gt;<br/>&lt;meta http-equiv=&#34;Content-Style-Type&#34; content=&#34;text/css&#34;&gt;<br/>&lt;title&gt;&lt;/title&gt;<br/>&lt;meta name=&#34;Generator&#34; content=&#34;Cocoa HTML Writer&#34;&gt;<br/>&lt;meta name=&#34;CocoaVersion&#34; content=&#34;1265.21&#34;&gt;<br/>&lt;style type=&#34;text/css&#34;&gt;<br/>li.li1 {margin: 0.0px 0.0px 0.0px 0.0px; font: 12.0px Helvetica}<br/>ul.ul1 {list-style-type: disc}<br/>&lt;/style&gt;<br/>&lt;/head&gt;<br/>&lt;body&gt;<br/>&lt;ul class=&#34;ul1&#34;&gt;<br/>&lt;li class=&#34;li1&#34;&gt;&lt;b&gt;Packet pickup: &lt;/b&gt;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...&lt;/li&gt;<br/>&lt;/ul&gt;<br/>&lt;/body&gt;<br/>&lt;/html&gt;"
}
]
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

View file

@ -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: " &nbsp;Hello &nbsp;World"
},
{
in: " Hello World"
sanitizedAsHTML: " Hello World"
# Preserving 3 spaces
sanitizedAsPlain: " &nbsp; Hello &nbsp; World"
},
{
in: " Hello World"
sanitizedAsHTML: " Hello World"
# Preserving 4 spaces
sanitizedAsPlain: " &nbsp; &nbsp;Hello &nbsp; &nbsp;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: "&lt;style&gt;Yo&lt;/style&gt; Foo Bar &lt;div&gt;Baz&lt;/div&gt;"
},
{
in: "<script>Bah</script> Yo < script>Boo! < / script >"
# Strip non white-list tags and encode malformed ones.
sanitizedAsHTML: " Yo &lt; script&gt;Boo! &lt; / script &gt;"
# HTML encode tags for literal display
sanitizedAsPlain: "&lt;script&gt;Bah&lt;/script&gt; Yo &lt; script&gt;Boo! &lt; / script &gt;"
},
{
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: "&lt;!DOCTYPE html PUBLIC &#34;-//W3C//DTD HTML 4.01//EN&#34; &#34;http://www.w3.org/TR/html4/strict.dtd&#34;&gt;<br/>&lt;html&gt;<br/>&lt;head&gt;<br/>&lt;meta http-equiv=&#34;Content-Type&#34; content=&#34;text/html; charset=UTF-8&#34;&gt;<br/>&lt;meta http-equiv=&#34;Content-Style-Type&#34; content=&#34;text/css&#34;&gt;<br/>&lt;title&gt;&lt;/title&gt;<br/>&lt;meta name=&#34;Generator&#34; content=&#34;Cocoa HTML Writer&#34;&gt;<br/>&lt;meta name=&#34;CocoaVersion&#34; content=&#34;1265.21&#34;&gt;<br/>&lt;style type=&#34;text/css&#34;&gt;<br/>li.li1 {margin: 0.0px 0.0px 0.0px 0.0px; font: 12.0px Helvetica}<br/>ul.ul1 {list-style-type: disc}<br/>&lt;/style&gt;<br/>&lt;/head&gt;<br/>&lt;body&gt;<br/>&lt;ul class=&#34;ul1&#34;&gt;<br/>&lt;li class=&#34;li1&#34;&gt;&lt;b&gt;Packet pickup: &lt;/b&gt;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...&lt;/li&gt;<br/>&lt;/ul&gt;<br/>&lt;/body&gt;<br/>&lt;/html&gt;"
}
]
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

View file

@ -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))

View file

@ -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 &nbsp;
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 =
'&': '&amp;',
@ -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) ->

View file

@ -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: