refactor(toolbar): allow toolbar extensions in contenteditable

Summary:
This is a refactor of the toolbar in the contenteditable. Goals of this
are:

1. Allow developers to add new buttons to the toolbar
2. Allow developers to add other component types to the floating toolbar (like the LinkEditor)
3. Make the toolbar declaratively defined instead of imperatively set
4. Separate out logical units of the toolbar into individual sections
5. Clean up `innerState` of the Contenteditable

The Floating Toolbar used to be an imperative mess. Doing simple
functionality additions required re-understanding a very complex set of
logic to hide and show the toolbar and delecately manage focus states.
There also was no real capacity for any developer to extend the toolbar.

It also used to be completely outside of our `atomicEdit` system and was a
legacy of having raw access to contenteditable controls (since it all used
to be directly inside of the contenteditable)

Finally it was difficult to declaratively define things because the
`innerState` of the Contenteditable was inconsistently used and its
lifecycle not properly thought through. This fixed several lifecycle bugs
with that.

Along the way several of the DOMUtils methods were also subtly not
functional and fixed.

The Toolbar is now broken apart into separate logical units.

There are now `ContentedtiableExtension`s that declare what should be
displayed in the toolbar at any given moment.

They define a method called `toolbarComponentData`. This is a pure
function of the state of the `Contenteditable`. If selection and content
conditions look correct, then that method will return a component to
render. This is how we declaratively define whether a toolbar should be
visible or not instead of manually setting `hide` & `show` bits.

There is also a `toolbarButtons` method that declaratively defines buttons
that can go in the new `<ToolbarButtons>` component.

The `ToolbarButtonManager` takes care of extracting these and binding the
correct editorAPI context.

Now the `<LinkEditor>` is a separate component from the `<ToolbarButtons>`
instead of being smashed together.

The `LinkManager` takes care of declaring when the `LinkEditor` should be
displayed and has properly bound methods to update the `contenteditable`
through the standard `atomicEdit` interface.

If users have additional contenteditable popup plugins (like displaying
extra info on a name or some content in the composer), they can now
implement the `toolbarComponentData` api and declaratively define that
information based on the state of the contenteditable.

Test Plan: TODO

Reviewers: bengotow, juan

Reviewed By: bengotow

Differential Revision: https://phab.nylas.com/D2442
This commit is contained in:
Evan Morikawa 2016-01-20 14:35:20 -08:00
parent f99edda250
commit c2ceb6fd6c
26 changed files with 951 additions and 698 deletions

View file

@ -1,5 +1,5 @@
import React, {Component, PropTypes} from 'react';
import {ExtensionRegistry, DOMUtils} from 'nylas-exports';
import {ContenteditableExtension, ExtensionRegistry, DOMUtils} from 'nylas-exports';
import {ScrollRegion, Contenteditable} from 'nylas-component-kit';
/**
@ -26,6 +26,7 @@ import {ScrollRegion, Contenteditable} from 'nylas-component-kit';
* @param {props.onBodyChanged} props.onBodyChanged
* @class ComposerEditor
*/
class ComposerEditor extends Component {
static displayName = 'ComposerEditor'
@ -84,10 +85,17 @@ class ComposerEditor extends Component {
this.state = {
extensions: ExtensionRegistry.Composer.extensions(),
};
this._coreExtension = {
onFocus: props.onFocus,
onBlur: props.onBlur,
};
class ComposerFocusManager extends ContenteditableExtension {
static onFocus() {
return props.onFocus();
}
static onBlur() {
return props.onBlur();
}
}
this._coreExtension = ComposerFocusManager;
}
componentDidMount() {

View file

@ -82,7 +82,6 @@
### Advanced formatting commands ###
'cmdctrl-&': 'contenteditable:numbered-list'
'cmdctrl-#': 'contenteditable:numbered-list'
'cmdctrl-*': 'contenteditable:bulleted-list'
'cmdctrl-(': 'contenteditable:quote'

View file

@ -1,12 +1,18 @@
{DOMUtils, ContenteditableExtension} = require 'nylas-exports'
class BlockquoteManager extends ContenteditableExtension
@keyCommandHandlers: ->
"contenteditable:quote": @_onCreateBlockquote
@onKeyDown: ({editor, event}) ->
if event.key is "Backspace"
if @_isInBlockquote(editor) and @_isAtStartOfLine(editor)
editor.outdent()
event.preventDefault()
@_onCreateBlockquote: ({editor, event}) ->
editor.formatBlock("BLOCKQUOTE")
@_isInBlockquote: (editor) ->
sel = editor.currentSelection()
return unless sel.isCollapsed

View file

@ -3,17 +3,21 @@ React = require 'react'
{Utils, DOMUtils} = require 'nylas-exports'
{KeyCommandsRegion} = require 'nylas-component-kit'
FloatingToolbarContainer = require './floating-toolbar-container'
FloatingToolbar = require './floating-toolbar'
EditorAPI = require './editor-api'
ExtendedSelection = require './extended-selection'
TabManager = require './tab-manager'
LinkManager = require './link-manager'
ListManager = require './list-manager'
MouseService = require './mouse-service'
DOMNormalizer = require './dom-normalizer'
ClipboardService = require './clipboard-service'
BlockquoteManager = require './blockquote-manager'
ToolbarButtonManager = require './toolbar-button-manager'
EmphasisFormattingExtension = require './emphasis-formatting-extension'
ParagraphFormattingExtension = require './paragraph-formatting-extension'
###
Public: A modern React-compatible contenteditable
@ -63,12 +67,20 @@ class Contenteditable extends React.Component
coreServices: [MouseService, ClipboardService]
coreExtensions: [DOMNormalizer, ListManager, TabManager, BlockquoteManager]
coreExtensions: [
ToolbarButtonManager
ListManager
TabManager
EmphasisFormattingExtension
ParagraphFormattingExtension
LinkManager
BlockquoteManager
DOMNormalizer
]
########################################################################
########################### Public Methods #############################
########################################################################
######################################################################
########################### Public Methods ###########################
######################################################################
### Public: perform an editing operation on the Contenteditable
@ -91,28 +103,40 @@ class Contenteditable extends React.Component
editor.importSelection(@innerState.exportedSelection)
argsObj = _.extend(extraArgsObj, {editor})
editingFunction(argsObj)
try
editingFunction(argsObj)
catch error
NylasEnv.emitError(error)
@_setupListeners()
focus: => @_editableNode().focus()
########################################################################
########################### React Lifecycle ############################
########################################################################
######################################################################
########################## React Lifecycle ###########################
######################################################################
constructor: (@props) ->
@innerState = {}
@state = {}
@innerState = {
dragging: false
doubleDown: false
hoveringOver: false # see {MouseService}
editableNode: null
exportedSelection: null
previousExportedSelection: null
}
@_mutationObserver = new MutationObserver(@_onDOMMutated)
componentWillMount: =>
@_setupServices()
componentDidMount: =>
@setInnerState editableNode: @_editableNode()
@_setupListeners()
@_mutationObserver.observe(@_editableNode(), @_mutationConfig())
@setInnerState editableNode: @_editableNode()
# When we have a composition event in progress, we should not update
# because otherwise our composition event will be blown away.
@ -123,16 +147,16 @@ class Contenteditable extends React.Component
componentWillReceiveProps: (nextProps) =>
if nextProps.initialSelectionSnapshot?
@_saveExportedSelection(nextProps.initialSelectionSnapshot)
@setInnerState
exportedSelection: nextProps.initialSelectionSnapshot
previousExportedSelection: @innerState.exportedSelection
componentDidUpdate: =>
@_restoreSelection()
@_refreshServices()
@_mutationObserver.disconnect()
@_mutationObserver.observe(@_editableNode(), @_mutationConfig())
@setInnerState
links: @_editableNode().querySelectorAll("*[href]")
editableNode: @_editableNode()
@setInnerState editableNode: @_editableNode()
componentWillUnmount: =>
@_mutationObserver.disconnect()
@ -140,8 +164,10 @@ class Contenteditable extends React.Component
@_teardownServices()
setInnerState: (innerState={}) =>
return if _.isMatch(@innerState, innerState)
@innerState = _.extend @innerState, innerState
@refs["toolbarController"]?.componentWillReceiveInnerProps(innerState)
if @_broadcastInnerStateToToolbar
@refs["toolbarController"]?.componentWillReceiveInnerProps(@innerState)
@_refreshServices()
_setupServices: ->
@ -157,9 +183,9 @@ class Contenteditable extends React.Component
service.teardown() for service in @_services
########################################################################
############################### Render #################################
########################################################################
######################################################################
############################## Render ################################
######################################################################
render: =>
<KeyCommandsRegion className="contenteditable-container"
@ -176,20 +202,24 @@ class Contenteditable extends React.Component
_renderFloatingToolbar: ->
return unless @props.floatingToolbar
<FloatingToolbarContainer
ref="toolbarController" atomicEdit={@atomicEdit} />
<FloatingToolbar
ref="toolbarController"
atomicEdit={@atomicEdit}
extensions={@_extensions()} />
_editableNode: =>
React.findDOMNode(@refs.contenteditable)
########################################################################
############################ Listener Setup ############################
########################################################################
######################################################################
########################### Listener Setup ###########################
######################################################################
_eventHandlers: =>
handlers = {}
_.extend(handlers, service.eventHandlers()) for service in @_services
# NOTE: See {MouseService} for more handlers
handlers = _.extend handlers,
onBlur: @_onBlur
onFocus: @_onFocus
@ -198,29 +228,38 @@ class Contenteditable extends React.Component
onCompositionStart: @_onCompositionStart
return handlers
_keymapHandlers: ->
atomicEditWrap = (command) =>
(event) =>
@atomicEdit((({editor}) -> editor[command]()), event)
keymapHandlers = {
'contenteditable:bold': atomicEditWrap("bold")
'contenteditable:italic': atomicEditWrap("italic")
'contenteditable:indent': atomicEditWrap("indent")
'contenteditable:outdent': atomicEditWrap("outdent")
'contenteditable:underline': atomicEditWrap("underline")
'contenteditable:numbered-list': atomicEditWrap("insertOrderedList")
'contenteditable:bulleted-list': atomicEditWrap("insertUnorderedList")
}
# This extracts extensions keymap handlers and binds them to be called
# through `atomicEdit`. This exposes the `{editor, event}` props to any
# keyCommandHandlers callbacks.
_boundExtensionKeymapHandlers: ->
keymapHandlers = {}
@_extensions().forEach (extension) =>
return unless _.isFunction(extension.keyCommandHandlers)
try
extensionHandlers = extension.keyCommandHandlers.call(extension)
_.each extensionHandlers, (handler, command) =>
keymapHandlers[command] = (event) =>
@atomicEdit(handler, {event})
catch error
NylasEnv.emitError(error)
return keymapHandlers
# NOTE: Keymaps are now broken apart into individual extensions. See the
# `EmphasisFormattingExtension`, `ParagraphFormattingExtension`,
# `ListManager`, and `LinkManager` for examples of extensions listening
# to keymaps.
_keymapHandlers: ->
defaultKeymaps = {}
return _.extend(defaultKeymaps, @_boundExtensionKeymapHandlers())
_setupListeners: =>
document.addEventListener("selectionchange", @_onSelectionChange)
@_broadcastInnerStateToToolbar = true
document.addEventListener("selectionchange", @_saveSelection)
@_editableNode().addEventListener('contextmenu', @_onShowContextMenu)
_teardownListeners: =>
document.removeEventListener("selectionchange", @_onSelectionChange)
@_broadcastInnerStateToToolbar = false
document.removeEventListener("selectionchange", @_saveSelection)
@_editableNode().removeEventListener('contextmenu', @_onShowContextMenu)
# https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
@ -233,9 +272,9 @@ class Contenteditable extends React.Component
characterDataOldValue: true
########################################################################
############################ Event Handlers ############################
########################################################################
######################################################################
########################### Event Handlers ###########################
######################################################################
# Every time the contents of the contenteditable DOM node change, the
# `_onDOMMutated` event gets fired.
@ -251,15 +290,22 @@ class Contenteditable extends React.Component
@_mutationObserver.disconnect()
@setInnerState dragging: false if @innerState.dragging
@setInnerState doubleDown: false if @innerState.doubleDown
@_broadcastInnerStateToToolbar = false
@_runCallbackOnExtensions("onContentChanged", {mutations})
selection = new ExtendedSelection(@_editableNode())
if selection?.isInScope()
@_saveExportedSelection(selection.exportSelection())
# NOTE: The DOMNormalizer should be the last extension to run. This
# will ensure that when we extract our innerHTML and re-set it during
# the next render the contents should look identical.
#
# Also, remember that our selection listeners have been turned off.
# It's very likely that one of our callbacks mutated the DOM and the
# selection. We need to be sure to re-save the selection.
@_saveSelection()
@props.onChange(target: {value: @_editableNode().innerHTML})
@_broadcastInnerStateToToolbar = true
@_mutationObserver.observe(@_editableNode(), @_mutationConfig())
return
@ -267,10 +313,8 @@ class Contenteditable extends React.Component
@setInnerState dragging: false
return if @_editableNode().parentElement.contains event.relatedTarget
@dispatchEventToExtensions("onBlur", event)
@setInnerState editableFocused: false
_onFocus: (event) =>
@setInnerState editableFocused: true
@dispatchEventToExtensions("onFocus", event)
_onKeyDown: (event) =>
@ -317,12 +361,15 @@ class Contenteditable extends React.Component
menu.popup(remote.getCurrentWindow())
########################################################################
############################# Extensions ###############################
########################################################################
######################################################################
############################ Extensions ##############################
######################################################################
_extensions: ->
@props.extensions.concat(@coreExtensions)
_runCallbackOnExtensions: (method, argsObj={}) =>
for extension in @props.extensions.concat(@coreExtensions)
for extension in @_extensions()
@_runExtensionMethod(extension, method, argsObj)
# Will execute the event handlers on each of the registerd and core
@ -352,9 +399,9 @@ class Contenteditable extends React.Component
@atomicEdit(editingFunction, argsObj)
########################################################################
############################## Selection ###############################
########################################################################
######################################################################
############################# Selection ##############################
######################################################################
# Saving and restoring a selection is difficult with React.
#
# React only handles Input and Textarea elements:
@ -396,50 +443,62 @@ class Contenteditable extends React.Component
#
# http://www.w3.org/TR/selection-api/#selectstart-event
getCurrentSelection: => @innerState.exportedSelection ? {}
getPreviousSelection: => @innerState.previousExportedSelection ? {}
## TODO DEPRECATE ME: This is only necessary because Undo/Redo is still
#part of the composer and not a core part of the Contenteditable.
getCurrentSelection: => @innerState.exportedSelection
getPreviousSelection: => @innerState.previousExportedSelection
# We save an {ExportedSelection} to `innerState`.
# Every time the selection changes we save its state.
#
# Whatever we set `innerState.exportedSelection` to will be implemented
# on the next `componentDidUpdate` by `_restoreSelection`
# In an ideal world, the selection state, much like the body, would
# behave like any other controlled React input: onchange we'd notify our
# parent, they'd update our props, and we'd re-render.
#
# We also allow props to manually set our `exportedSelection` state.
# This is useful in undo/redo situations when we want to revert the
# selection to where it was at a previous time.
# Unfortunately, Selection is not something React natively keeps track
# of in its virtual DOM, the performance would be terrible if we
# re-rendered on every selection change (think about dragging a
# selection), and having every user of `<Contenteditable>` need to
# remember to deal with, save, and set the Selection object is a pain.
#
# NOTE: The `exportedSelection` object may have `anchorNode` and
# `focusNode` references to similar, but not equal, DOMNodes than what
# we currently have rendered. Every time React re-renders the component
# we get new DOM objects. When the `exportedSelection` is re-imported
# during `_restoreSelection`, the `ExtendedSelection` class will attempt
# to find the appropriate DOM Nodes via the `similar nodes` conveience methods
# in DOMUtils.
# To counter this we save local instance copies of the Selection.
#
# When React re-renders it doesn't restore the Selection. We need to do
# this manually with `_restoreSelection`
# First of all we wrap the native Selection object in an
# [ExtendedSelection} object. This is a pure extension and has all
# standard methods.
#
# As a performance optimization, we don't attach this to React `state`.
# Since re-rendering generates new DOM objects on the heap, testing for
# selection equality is expensive and requires a full tree walk.
# We then save out 3 types of selections on `innerState` for us to use
# later:
#
# We also need to keep references to the previous selection state in
# order for undo/redo to work properly.
_saveExportedSelection: (exportedSelection) =>
return if exportedSelection and exportedSelection.isEqual(@innerState.exportedSelection)
@setInnerState
exportedSelection: exportedSelection
editableFocused: true
previousExportedSelection: @innerState.exportedSelection
# Every time the cursor changes we need to save its location and state.
# We update our cache every time the selection changes by listening to
# the `document` `selectionchange` event.
_onSelectionChange: (event) =>
# 1. `selectionSnapshot` - This is accessed by any sub-components of
# the Contenteditable such as the `<FloatingToolbar>` and its
# extensions.
#
# It is slightly different from an `exportedSelection` in that the
# anchorNode property points to an attached DOM reference and not the
# clone of a node. This is necessary for extensions to be able to
# traverse the actual current DOM from the anchorNode. The
# `exportedSelection`'s, cloned nodes don't have parentNOdes.
#
# This is crucially not a reference to the `rawSelection` object,
# because the anchorNodes of that may change from underneath us at any
# time.
#
# 2. `exportedSelection` - This is an {ExportedSelection} object and is
# used to restore the selection even after the DOM has changed. When our
# component re-renders the actual DOM objects on the heap will be
# different. An {ExportedSelection} contains counting indicies we use to
# re-find the correct DOM Nodes in the new document.
#
# 3. `previousExportedSelection` - This is used for undo / redo so when
# you revert to a previous state, the selection updates as well.
_saveSelection: =>
selection = new ExtendedSelection(@_editableNode())
return unless selection?.isInScope()
@_saveExportedSelection(selection.exportSelection())
@setInnerState
selectionSnapshot: selection.selectionSnapshot()
exportedSelection: selection.exportSelection()
previousExportedSelection: @innerState.exportedSelection
_restoreSelection: =>
return unless @_shouldRestoreSelection()

View file

@ -1,3 +1,4 @@
_ = require 'underscore'
{DOMUtils} = require 'nylas-exports'
ExtendedSelection = require './extended-selection'
@ -25,11 +26,19 @@ class EditorAPI
constructor: (@rootNode) ->
@_extendedSelection = new ExtendedSelection(@rootNode)
wrapSelection:(nodeName) ->
wrapped = DOMUtils.wrap(@_selection.getRangeAt(0), nodeName)
wrapSelection: (nodeName) ->
wrapped = DOMUtils.wrap(@_extendedSelection.getRangeAt(0), nodeName)
@select(wrapped)
return @
unwrapNodeAndSelectAll: (node) ->
replacedNodes = DOMUtils.unwrapNode(node)
return @ if replacedNodes.length is 0
first = replacedNodes[0]
last = _.last(replacedNodes)
@_extendedSelection.selectFromTo(first, last)
return @
regExpSelectorAll:(regex) ->
DOMUtils.regExpSelectorAll(@rootNode, regex)
@ -44,17 +53,26 @@ class EditorAPI
fn()
@select(sel)
getSelectionTextIndex: (args...) -> @_extendedSelection.getSelectionTextIndex(args...)
getSelectionTextIndex: (args...) ->
@_extendedSelection.getSelectionTextIndex(args...)
importSelection: (args...) ->
@_extendedSelection.importSelection(args...); @
collapse: (args...) -> @_extendedSelection.collapse(args...); @
collapseToStart: (args...) -> @_extendedSelection.collapseToStart(args...); @
collapseToEnd: (args...) -> @_extendedSelection.collapseToEnd(args...); @
importSelection: (args...) -> @_extendedSelection.importSelection(args...); @
select: (args...) -> @_extendedSelection.select(args...); @
selectAllChildren: (args...) -> @_extendedSelection.selectAllChildren(args...); @
restoreSelectionByTextIndex: (args...) -> @_extendedSelection.restoreSelectionByTextIndex(args...); @
select: (args...) ->
@_extendedSelection.select(args...); @
selectAllChildren: (args...) ->
@_extendedSelection.selectAllChildren(args...); @
restoreSelectionByTextIndex: (args...) ->
@_extendedSelection.restoreSelectionByTextIndex(args...); @
normalize: -> @rootNode.normalize(); @
########################################################################
####################### execCommand Delegation #########################
########################################################################
backColor: (color) -> @_ec("backColor", false, color)
bold: -> @_ec("bold", false)
copy: -> @_ec("copy", false)
@ -72,7 +90,17 @@ class EditorAPI
increaseFontSize: -> @_ec("increaseFontSize", false)
indent: -> @_ec("indent", false)
insertHorizontalRule: -> @_ec("insertHorizontalRule", false)
insertHTML: (html) -> @_ec("insertHTML", false, html)
insertHTML: (html, {selectInsertion}) ->
if selectInsertion
wrappedHtml = "<span id='tmp-html-insertion-wrap'>#{html}</span>"
@_ec("insertHTML", false, wrappedHtml)
wrap = @rootNode.querySelector("#tmp-html-insertion-wrap")
@unwrapNodeAndSelectAll(wrap)
return @
else
@_ec("insertHTML", false, wrappedHtml)
insertImage: (uri) -> @_ec("insertImage", false, uri)
insertOrderedList: -> @_ec("insertOrderedList", false)
insertUnorderedList: -> @_ec("insertUnorderedList", false)
@ -96,14 +124,15 @@ class EditorAPI
unlink: -> @_ec("unlink", false)
styleWithCSS: (style) -> @_ec("styleWithCSS", false, style)
normalize: -> @rootNode.normalize(); @
contentReadOnly: -> @_notImplemented()
enableInlineTableEditing: -> @_notImplemented()
enableObjectResizing: -> @_notImplemented()
insertBrOnReturn: -> @_notImplemented()
useCSS: -> @_notImplemented()
########################################################################
####################### Private Helper Methods #########################
########################################################################
_ec: (args...) -> document.execCommand(args...); return @
_notImplemented: -> throw new Error("Not implemented")

View file

@ -0,0 +1,50 @@
{ContenteditableExtension} = require 'nylas-exports'
# This provides the default baisc formatting options for the
# Contenteditable using the declarative extension API.
class EmphasisFormattingExtension extends ContenteditableExtension
@keyCommandHandlers: =>
"contenteditable:bold": @_onBold
"contenteditable:italic": @_onItalic
"contenteditable:underline": @_onUnderline
"contenteditable:strikeThrough": @_onStrikeThrough
@toolbarButtons: => [
{
className: "btn-bold"
onClick: @_onBold
tooltip: "Bold"
iconUrl: null # Defined in the css of btn-bold
}
{
className: "btn-italic"
onClick: @_onItalic
tooltip: "Italic"
iconUrl: null # Defined in the css of btn-italic
}
{
className: "btn-underline"
onClick: @_onUnderline
tooltip: "Underline"
iconUrl: null # Defined in the css of btn-underline
}
]
@_onBold: ({editor, event}) -> editor.bold()
@_onItalic: ({editor, event}) -> editor.italic()
@_onUnderline: ({editor, event}) -> editor.underline()
@_onStrikeThrough: ({editor, event}) -> editor.strikeThrough()
# None of the emphasis formatting buttons need a custom component.
#
# They use the default <ToolbarButtons> component via the
# `toolbarButtons` extension API.
#
# The <ToolbarButtons> core component is managed by the
# {ToolbarButtonManager}
@toolbarComponentConfig: => null
module.exports = EmphasisFormattingExtension

View file

@ -17,6 +17,7 @@ class ExtendedSelection
@scopeNode.contains(@anchorNode) and
@scopeNode.contains(@focusNode)
# Public: Conveniently select nodes.
select: (args...) ->
if args.length is 0
throw @_errBadUsage()
@ -35,28 +36,43 @@ class ExtendedSelection
@selectFromToWithIndex(args...)
else if args.length >= 5
throw @_errBadUsage()
return @
selectAt: (at) ->
nodeAt = @findNodeAt(at)
nodeAt = @findSelectableNodeAt(at)
@setBaseAndExtent(nodeAt, 0, nodeAt, (nodeAt.length ? 0))
selectRange: (range) ->
@setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset)
selectFromTo: (from, to) ->
fromNode = @findNodeAt(from)
toNode = @findNodeAt(to)
fromNode = @findSelectableNodeAt(from)
toNode = @findSelectableNodeAt(to)
@setBaseAndExtent(fromNode, 0, toNode, (toNode.length ? 0))
selectFromToWithIndex: (from, fromIndex, to, toIndex) ->
fromNode = @findNodeAt(from)
toNode = @findNodeAt(to)
fromNode = @findSelectableNodeAt(from)
toNode = @findSelectableNodeAt(to)
if (not _.isNumber(fromIndex)) or (not _.isNumber(toIndex))
throw @_errBadUsage()
@setBaseAndExtent(fromNode, fromIndex, toNode, toIndex)
exportSelection: -> new ExportedSelection(@rawSelection, @scopeNode)
# A selectionSnapshot is slightly different from an {ExportedSelection}.
# An {ExportedSelection} maintains clones of the nodes (which don't have
# parentNodes nor are attached to the dcoument). An {ExportedSelection}
# also contains counting indices for future restoration.
#
# This is necessary since references to `rawSelection` can have its
# anchorNodes change out from underneath it as the selection changes.
selectionSnapshot: ->
anchorNode: @rawSelection.anchorNode
anchorOffset: @rawSelection.anchorOffset
focusNode: @rawSelection.focusNode
focusOffset: @rawSelection.focusOffset
isCollapsed: @rawSelection.isCollapsed
# Since the last time we exported the selection, the DOM may have
# completely changed due to a re-render. To the user it may look
# identical, but the newly rendered region may be comprised of
@ -81,15 +97,22 @@ class ExtendedSelection
newFocusNode,
exportedSelection.focusOffset)
findNodeAt: (arg) ->
findSelectableNodeAt: (arg) ->
node = null
if arg instanceof Node
return arg
node = arg
else if _.isString(arg)
return @scopeNode.querySelector(arg)
node = @scopeNode.querySelector(arg)
else if _.isRegExp(arg)
## TODO
DOMUtils.findNodeByRegex(@scopeNode, arg)
return
node = DOMUtils.findNodeByRegex(@scopeNode, arg)
# Normally, selections are designed to work on TextNodes, but you
# query by Elements. If an Element has just one textNode, we'll use
# that. If an Element has multiple children, it's ambiguous and we
# won't attempt to find the Text Node for you.
textNode = DOMUtils.findOnlyChildTextNode(node)
if textNode then return textNode else return node
# Finds the start and end text index of the current selection relative
# to a given Node or Range. Returns an object of the form:

View file

@ -1,327 +0,0 @@
_ = require 'underscore'
React = require 'react'
{Utils, DOMUtils, ExtensionRegistry} = 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:
# We are passed in the Contenteditable's `atomicEdit` mutator
# function. This is the safe way to request updates in the
# contenteditable. It will pass the editable DOM node and the
# exportedSelection object plus any extra args (like DOM event
# objects) to the callback
atomicEdit: React.PropTypes.func
@innerPropTypes:
links: React.PropTypes.array
dragging: React.PropTypes.bool
doubleDown: React.PropTypes.bool
editableNode: React.PropTypes.object
editableFocused: React.PropTypes.bool
exportedSelection: React.PropTypes.object
constructor: (@props) ->
@state =
toolbarTop: 0
toolbarMode: "buttons"
toolbarLeft: 0
toolbarPos: "above"
editAreaWidth: 9999 # This will get set on first exportedSelection
toolbarVisible: false
linkHoveringOver: null
@_setToolbarState = _.debounce(@_setToolbarState, 10)
@innerProps =
links: []
dragging: false
doubleDown: false
editableNode: null
toolbarFocus: false
editableFocused: null
exportedSelection: 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={@state.toolbarVisible}
onSaveUrl={@_onSaveUrl}
onMouseEnter={@_onEnterToolbar}
onChangeMode={@_onChangeMode}
onMouseLeave={@_onLeaveToolbar}
linkToModify={@state.linkToModify}
buttonConfigs={@_toolbarButtonConfigs()}
onChangeFocus={@_onChangeFocus}
editAreaWidth={@state.editAreaWidth}
contentPadding={@CONTENT_PADDING}
onDoneWithLink={@_onDoneWithLink} />
_onSaveUrl: (url, linkToModify) =>
@props.atomicEdit ({editor}) ->
if linkToModify?
equivalentNode = DOMUtils.findSimilarNodeAtIndex(editor.rootNode, linkToModify, 0)
return unless equivalentNode?
equivalentLinkText = DOMUtils.findFirstTextNode(equivalentNode)
return if linkToModify.getAttribute?('href')?.trim() is url.trim()
toSelect = equivalentLinkText
else
# When atomicEdit gets run, the exportedSelection is already restored to
# the last saved exportedSelection state. Any operation we perform will
# apply to the last saved exportedSelection state.
toSelect = null
if url.trim().length is 0
if toSelect then editor.select(toSelect).unlink()
else editor.unlink()
else
if toSelect then editor.select(toSelect).createLink(url)
else editor.createLink(url)
# We setup the buttons that the Toolbar should have as a combination of
# core actions and user-defined plugins. The FloatingToolbar simply
# renders them.
_toolbarButtonConfigs: ->
atomicEditWrap = (command) =>
(event) =>
@props.atomicEdit((({editor}) -> editor[command]()), event)
extensionButtonConfigs = []
ExtensionRegistry.Composer.extensions().forEach (ext) ->
config = ext.composerToolbar?()
extensionButtonConfigs.push(config) if config?
return [
{
className: "btn-bold"
onClick: atomicEditWrap("bold")
tooltip: "Bold"
iconUrl: null # Defined in the css of btn-bold
}
{
className: "btn-italic"
onClick: atomicEditWrap("italic")
tooltip: "Italic"
iconUrl: null # Defined in the css of btn-italic
}
{
className: "btn-underline"
onClick: atomicEditWrap("underline")
tooltip: "Underline"
iconUrl: null # Defined in the css of btn-underline
}
{
className: "btn-link"
onClick: => @setState toolbarMode: "edit-link"
tooltip: "Edit Link"
iconUrl: null # Defined in the css of btn-link
}
].concat(extensionButtonConfigs)
# A user could be done with a link because they're setting a new one, or
# clearing one, or just canceling.
_onDoneWithLink: =>
@componentWillReceiveInnerProps linkHoveringOver: null
@setState
toolbarMode: "buttons"
toolbarVisible: false
return
# We explicitly control the focus of the FloatingToolbar because we can
# do things like switch from "buttons" mode to "edit-link" mode (which
# natively fires focus change events) but not want to signify a "focus"
# change
_onChangeFocus: (focus) =>
@componentWillReceiveInnerProps toolbarFocus: focus
# 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.isEmptyBoundingRect(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.exportedSelection or
props.exportedSelection.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

@ -1,255 +1,199 @@
_ = require 'underscore'
React = require 'react/addons'
classNames = require 'classnames'
{CompositeDisposable} = require 'event-kit'
{RetinaImg} = require 'nylas-component-kit'
{ExtensionRegistry} = require 'nylas-exports'
React = require 'react'
{Utils, DOMUtils, ExtensionRegistry} = require 'nylas-exports'
# Positions and renders a FloatingToolbar in the composer.
#
# The FloatingToolbar declaratively chooses a Component to render. Only
# extensions that expose a `toolbarComponentConfig` will be considered.
# Whether or not there's an available component to render determines
# whether or not the FloatingToolbar is visible.
#
# There's no `toolbarVisible` state. It uses the existance of a
# ToolbarComponent to determine what to display.
#
# The {ToolbarButtonManager} and the {LinkManager} are `coreExtensions`
# that declaratively register the special `<ToolbarButtons/>` component
# and the `<LinkEditor />` component.
class FloatingToolbar extends React.Component
@displayName = "FloatingToolbar"
@displayName: "FloatingToolbar"
# We are passed an array of Extensions. Those that implement the
# `toolbarButton` and/or the `toolbarComponent` methods will be
# injected into the Toolbar.
#
# Every time the `innerState` of the `Contenteditable` change, we get
# passed the data as new `innerProps`.
@propTypes:
# Absolute position in px relative to parent <Contenteditable />
top: React.PropTypes.number
# Absolute position in px relative to parent <Contenteditable />
left: React.PropTypes.number
# Either "above" or "below". Used when determining which CSS to use
pos: React.PropTypes.string
# Either "edit-link" or "buttons". Determines whether we're showing
# edit buttons or the link editor
mode: React.PropTypes.string
# The current display state of the toolbar
visible: React.PropTypes.bool
# A callback function we use to save the URL to the Contenteditable
onSaveUrl: React.PropTypes.func
# A callback so our parent can decide whether or not to hide when the
# mouse has moved over the component
onMouseEnter: React.PropTypes.func
onMouseLeave: React.PropTypes.func
# The current DOM link we are modifying
linkToModify: React.PropTypes.object
# Declares what buttons should appear in the toolbar. An array of
# config objects.
buttonConfigs: React.PropTypes.array
# Notifies our parent of when we focus in and out of inputs in the
# toolbar.
onChangeFocus: React.PropTypes.func
# The absolute available area we have used in calculating our
# appropriate position.
editAreaWidth: React.PropTypes.number
# The absolute available padding we have used in calculating our
# appropriate position.
contentPadding: React.PropTypes.number
# A callback used when a link has been cancled, completed, or escaped
# from. Used to notify our parent to switch modes.
onDoneWithLink: React.PropTypes.func
atomicEdit: React.PropTypes.func
extensions: React.PropTypes.array
@innerPropTypes:
dragging: React.PropTypes.bool
selection: React.PropTypes.object
doubleDown: React.PropTypes.bool
hoveringOver: React.PropTypes.object
editableNode: React.PropTypes.object
@defaultProps:
mode: "buttons"
onMouseEnter: ->
onMouseLeave: ->
buttonConfigs: []
extensions: []
@defaultInnerProps:
dragging: false
selection: null
doubleDown: false
hoveringOver: null
editableNode: null
constructor: (@props) ->
@state =
urlInputValue: @_initialUrl() ? ""
componentWidth: 0
toolbarTop: 0
toolbarMode: "buttons"
toolbarLeft: 0
toolbarPos: "above"
editAreaWidth: 9999 # This will get set on first selection
toolbarWidth: 0
toolbarComponent: null
toolbarLocationRef: null
toolbarComponentProps: {}
@innerProps = FloatingToolbar.defaultInnerProps
componentDidMount: =>
@subscriptions = new CompositeDisposable()
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={}) =>
fullProps = _.extend({}, @props, nextInnerProps)
@innerProps = _.extend @innerProps, nextInnerProps
@setState(@_getStateFromProps(fullProps))
componentWillReceiveProps: (nextProps) =>
@setState
urlInputValue: @_initialUrl(nextProps)
fullProps = _.extend(@innerProps, nextProps)
@setState(@_getStateFromProps(fullProps))
componentWillUnmount: =>
@subscriptions?.dispose()
# 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
componentDidUpdate: =>
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.
React.findDOMNode(@refs.urlInput).focus()
# We render a ToolbarComponent in a floating frame.
render: ->
ToolbarComponent = @state.toolbarComponent
return false unless ToolbarComponent
render: =>
<div ref="floatingToolbar"
className={@_toolbarClasses()} style={@_toolbarStyles()}>
<div className="toolbar-pointer" style={@_toolbarPointerStyles()}></div>
{@_toolbarType()}
<div className="floating-toolbar-container">
<div ref="floatingToolbar"
className={@_toolbarClasses()}
style={@_toolbarStyles()}>
<div className="toolbar-pointer"
style={@_toolbarPointerStyles()}></div>
<ToolbarComponent {...@state.toolbarComponentProps} />
</div>
</div>
_getStateFromProps: (props) ->
toolbarComponentState = @_getToolbarComponentData(props)
locationRefNode = toolbarComponentState.toolbarLocationRef
if locationRefNode
positionState = @_calculatePositionState(props, locationRefNode)
else positionState = {}
return _.extend {}, positionState, toolbarComponentState
# If this returns a `null` component, that means we don't want to show
# anything.
_getToolbarComponentData: (props) ->
toolbarComponent = null
toolbarWidth = 0
toolbarLocationRef = null
toolbarComponentProps = {}
for extension in props.extensions
try
params = extension.toolbarComponentConfig?(toolbarState: props) ? {}
if params.component
toolbarComponent = params.component
toolbarComponentProps = params.props ? {}
toolbarLocationRef = params.locationRefNode
toolbarWidth = params.width
catch error
NylasEnv.emitError(error)
if toolbarComponent and not toolbarLocationRef
throw new Error("You must provider a locationRefNode for #{toolbarComponent.displayName}")
return {toolbarComponent, toolbarComponentProps, toolbarLocationRef, toolbarWidth}
@CONTENT_PADDING: 15
_calculatePositionState: (props, locationRefNode) =>
editableNode = props.editableNode
referenceRect = locationRefNode.getBoundingClientRect()
if not editableNode or not referenceRect or DOMUtils.isEmptyBoundingRect(referenceRect)
return {toolbarTop: 0, toolbarLeft: 0, editAreaWidth: 0, toolbarPos: 'above'}
TOP_PADDING = 10
BORDER_RADIUS_PADDING = 15
editArea = editableNode.getBoundingClientRect()
calcLeft = (referenceRect.left - editArea.left) + referenceRect.width/2
calcLeft = Math.min(Math.max(calcLeft, FloatingToolbar.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 {
toolbarTop: calcTop
toolbarLeft: calcLeft
editAreaWidth: editArea.width
toolbarPos: toolbarPos
}
_toolbarClasses: =>
classes = {}
classes[@props.pos] = true
classes[@state.toolbarPos] = true
classNames _.extend classes,
"floating-toolbar": true
"toolbar": true
"toolbar-visible": @props.visible
_toolbarStyles: =>
styles =
left: @_toolbarLeft()
top: @props.top
width: @_width()
top: @state.toolbarTop
width: @state.toolbarWidth
return styles
_toolbarType: =>
if @props.mode is "buttons" then @_renderButtons()
else if @props.mode is "edit-link" then @_renderLink()
else return <div></div>
_renderButtons: =>
@props.buttonConfigs.map (config, i) ->
if (config.iconUrl ? "").length > 0
icon = <RetinaImg mode={RetinaImg.Mode.ContentIsMask}
url="#{toolbarItem.iconUrl}" />
else icon = ""
<button className="btn toolbar-btn #{config.className ? ''}"
key={"btn-#{i}"}
onClick={config.onClick}
title="#{config.tooltip}">{icon}</button>
_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"
onMouseEnter={@_onMouseEnter}
onMouseLeave={@_onMouseLeave}>
<i className="fa fa-link preview-btn-icon" onClick={@_onPreventToolbarClose}></i>
<input type="text"
ref="urlInput"
value={@state.urlInputValue}
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}
</div>
_onPreventToolbarClose: (event) =>
event.stopPropagation()
_onMouseEnter: =>
@props.onMouseEnter?()
_onMouseLeave: =>
if @props.linkToModify and document.activeElement isnt React.findDOMNode(@refs.urlInput)
@props.onMouseLeave?()
_initialUrl: (props=@props) =>
props.linkToModify?.getAttribute?('href')
_onInputChange: (event) =>
@setState urlInputValue: event.target.value
_saveUrlOnEnter: (event) =>
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
# case we also want to remove the link.
_removeUrl: =>
@setState urlInputValue: ""
@props.onSaveUrl "", @props.linkToModify
@props.onDoneWithLink()
_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.onDoneWithLink()
_toolbarLeft: =>
CONTENT_PADDING = @props.contentPadding ? 15
max = @props.editAreaWidth - @_width() - CONTENT_PADDING
left = Math.min(Math.max(@props.left - @_width()/2, CONTENT_PADDING), max)
max = @state.editAreaWidth - @state.toolbarWidth - FloatingToolbar.CONTENT_PADDING
left = Math.min(Math.max(@state.toolbarLeft - @state.toolbarWidth/2, FloatingToolbar.CONTENT_PADDING), max)
return left
_toolbarPointerStyles: =>
CONTENT_PADDING = @props.contentPadding ? 15
POINTER_WIDTH = 6 + 2 #2px of border-radius
max = @props.editAreaWidth - CONTENT_PADDING
min = CONTENT_PADDING
absoluteLeft = Math.max(Math.min(@props.left, max), min)
max = @state.editAreaWidth - FloatingToolbar.CONTENT_PADDING
min = FloatingToolbar.CONTENT_PADDING
absoluteLeft = Math.max(Math.min(@state.toolbarLeft, max), min)
relativeLeft = absoluteLeft - @_toolbarLeft()
left = Math.max(Math.min(relativeLeft, @_width()-POINTER_WIDTH), POINTER_WIDTH)
left = Math.max(Math.min(relativeLeft, @state.toolbarWidth-POINTER_WIDTH), POINTER_WIDTH)
styles =
left: left
return styles
_width: =>
# We can't calculate the width of the floating toolbar declaratively
# because it hasn't been rendered yet. As such, we'll keep the width
# fixed to make it much eaier.
TOOLBAR_BUTTONS_WIDTH = 114#px
TOOLBAR_URL_WIDTH = 210#px
# If we have a long link, we want to make a larger text area. It's not
# super important to get the length exactly so let's just get within
# the ballpark by guessing charcter lengths
WIDTH_PER_CHAR = 11
max = @props.editAreaWidth - (@props.contentPadding ? 15)*2
if @props.mode is "buttons"
return TOOLBAR_BUTTONS_WIDTH
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)
return fullWidth
else
return TOOLBAR_URL_WIDTH
else
return TOOLBAR_BUTTONS_WIDTH
module.exports = FloatingToolbar

View file

@ -0,0 +1,98 @@
React = require 'react/addons'
class LinkEditor extends React.Component
@displayName = "LinkEditor"
@propTypes:
# A callback function we use to save the URL to the Contenteditable
onSaveUrl: React.PropTypes.func
# The current DOM link we are modifying
linkToModify: React.PropTypes.object
# A callback used when a link has been cancled, completed, or escaped
# from. Used to notify our parent to switch modes.
onDoneWithLink: React.PropTypes.func
constructor: (@props) ->
@state =
urlInputValue: @_initialUrl() ? ""
componentDidMount: ->
if @props.focusOnMount
React.findDOMNode(@refs["urlInput"]).focus()
render: =>
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">
<i className="fa fa-link preview-btn-icon"></i>
<input type="text"
ref="urlInput"
value={@state.urlInputValue}
onBlur={@_onBlur}
onKeyDown={@_detectEscape}
onKeyPress={@_saveUrlOnEnter}
onChange={@_onInputChange}
className="floating-toolbar-input #{withRemove}"
placeholder="Paste or type a link" />
<button className="btn btn-icon"
onKeyDown={@_detectEscape}
onKeyPress={@_saveUrlOnEnter}
onMouseDown={@_saveUrl}><i className="fa fa-check"></i></button>
{removeBtn}
</div>
# 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()
_saveUrl: =>
if @state.urlInputValue.trim().length > 0
@props.onSaveUrl @state.urlInputValue, @props.linkToModify
@props.onDoneWithLink()
_onInputChange: (event) =>
@setState urlInputValue: event.target.value
_detectEscape: (event) =>
if event.key is "Escape"
@props.onDoneWithLink()
_saveUrlOnEnter: (event) =>
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
# case we also want to remove the link.
_removeUrl: =>
@setState urlInputValue: ""
@props.onSaveUrl "", @props.linkToModify
@props.onDoneWithLink()
_initialUrl: (props=@props) =>
props.linkToModify?.getAttribute('href')
module.exports = LinkEditor

View file

@ -0,0 +1,130 @@
_ = require 'underscore'
{RegExpUtils, DOMUtils, ContenteditableExtension} = require 'nylas-exports'
LinkEditor = require './link-editor'
class LinkManager extends ContenteditableExtension
@keyCommandHandlers: =>
"contenteditable:insert-link": @_onInsertLink
@toolbarButtons: =>
[{
className: "btn-link"
onClick: @_onInsertLink
tooltip: "Edit Link"
iconUrl: null # Defined in the css of btn-link
}]
# By default, if you're typing next to an existing anchor tag, it won't
# continue the anchor text. This is important for us since we want you
# to be able to select and then override the existing anchor text with
# something new.
@onContentChanged: ({editor, mutations}) =>
sel = editor.currentSelection()
if sel.isCollapsed
node = sel.anchorNode
sibling = node.previousSibling
return if not sibling
return if sel.anchorOffset > 1
return if node.nodeType isnt Node.TEXT_NODE
return if sibling.nodeName isnt "A"
return if /^\s+/.test(node.data)
return if RegExpUtils.punctuation(exclude: ['\\-', '_']).test(node.data[0])
sibling.appendChild(node)
sibling.normalize()
text = DOMUtils.findLastTextNode(sibling)
editor.select(text, text.length, text, text.length)
@toolbarComponentConfig: ({toolbarState}) =>
return null if toolbarState.dragging or toolbarState.doubleDown
linkToModify = null
if not linkToModify and toolbarState.selectionSnapshot
linkToModify = @_linkAtCursor(toolbarState)
return null if not linkToModify
return {
component: LinkEditor
props:
onSaveUrl: (url, linkToModify) =>
toolbarState.atomicEdit(@_onSaveUrl, {url, linkToModify})
onDoneWithLink: => toolbarState.atomicEdit(@_onDoneWithLink)
linkToModify: linkToModify
focusOnMount: @_shouldFocusOnMount(toolbarState)
locationRefNode: linkToModify
width: @_linkWidth(linkToModify)
}
@_shouldFocusOnMount: (toolbarState) ->
not toolbarState.selectionSnapshot.isCollapsed
@_linkWidth: (linkToModify) ->
href = linkToModify?.getAttribute?('href') ? ""
WIDTH_PER_CHAR = 11
return Math.max(href.length * WIDTH_PER_CHAR, 210)
@_linkAtCursor: (toolbarState) ->
if toolbarState.selectionSnapshot.isCollapsed
anchor = toolbarState.selectionSnapshot.anchorNode
return DOMUtils.closest(anchor, 'a, n1-prompt-link')
else
anchor = toolbarState.selectionSnapshot.anchorNode
focus = toolbarState.selectionSnapshot.anchorNode
return DOMUtils.closest(anchor, 'n1-prompt-link') and DOMUtils.closest(focus, 'n1-prompt-link')
## TODO FIXME: Unfortunately, the keyCommandHandler fires before the
# Contentedtiable onKeyDown.
#
# Normally this wouldn't matter, but when `_onInsertLink` runs it will
# focus on the input box of the link editor.
#
# If onKeyDown in the Contenteditable runs after this, then
# `atomicUpdate` will reset the selection back to the Contenteditable.
# This process blurs the link input, which causes the LinkInput to close
# and attempt to set or clear the link. The net effect is that the link
# insertion appears to not work via keyboard commands.
#
# This would not be a problem if the rendering of the Toolbar happened
# at the same time as the Contenteditable's render cycle. Unfortunatley
# since the Contenteditable shouldn't re-render on all Selection
# changes, while the Toolbar should, these are out of sync.
#
# The temporary fix is adding a _.defer block to change the ordering of
# these keyboard events.
@_onInsertLink: ({editor, event}) -> _.defer ->
if editor.currentSelection().isCollapsed
html = "<n1-prompt-link>link text</n1-prompt-link>"
editor.insertHTML(html, selectInsertion: true)
else
editor.wrapSelection("n1-prompt-link")
@_onDoneWithLink: ({editor}) ->
for node in editor.rootNode.querySelectorAll("n1-prompt-link")
editor.unwrapNodeAndSelectAll(node)
@_onSaveUrl: ({editor, url, linkToModify}) ->
if linkToModify?
equivalentNode = DOMUtils.findSimilarNodesAtIndex(editor.rootNode, linkToModify, 0)?[0]
return unless equivalentNode?
equivalentLinkText = DOMUtils.findFirstTextNode(equivalentNode)
return if linkToModify.getAttribute?('href')?.trim() is url.trim()
toSelect = equivalentLinkText
else
# When atomicEdit gets run, the exportedSelection is already restored to
# the last saved exportedSelection state. Any operation we perform will
# apply to the last saved exportedSelection state.
toSelect = null
if url.trim().length is 0
if toSelect then editor.select(toSelect).unlink()
else editor.unlink()
else
if toSelect then editor.select(toSelect).createLink(url)
else editor.createLink(url)
for node in editor.rootNode.querySelectorAll("n1-prompt-link")
editor.unwrapNodeAndSelectAll(node)
module.exports = LinkManager

View file

@ -2,6 +2,10 @@ _str = require 'underscore.string'
{DOMUtils, ContenteditableExtension} = require 'nylas-exports'
class ListManager extends ContenteditableExtension
@keyCommandHandlers: =>
"contenteditable:numbered-list": @_insertNumberedList
"contenteditable:bulleted-list": @_insertBulletedList
@onContentChanged: ({editor, mutations}) ->
if @_spaceEntered and @hasListStartSignature(editor.currentSelection())
@createList(editor)
@ -98,6 +102,8 @@ class ListManager extends ContenteditableExtension
editor.insertOrderedList() if ordered is true
editor.insertUnorderedList() if not ordered
@_insertNumberedList: ({editor}) -> editor.insertOrderedList()
@_insertBulletedList: ({editor}) -> editor.insertUnorderedList()
@outdentListItem: (editor) ->
if @originalInput

View file

@ -5,10 +5,16 @@ ContenteditableService = require './contenteditable-service'
class MouseService extends ContenteditableService
constructor: ->
super
@HOVER_DEBOUNCE = 250
@setup()
@timer = null
@_inFrame = true
eventHandlers: ->
onClick: @_onClick
onMouseEnter: (event) => @_inFrame = true
onMouseLeave: (event) => @_inFrame = false
onMouseOver: @_onMouseOver
_onClick: (event) ->
# We handle mouseDown, mouseMove, mouseUp, but we want to stop propagation
@ -80,5 +86,10 @@ class MouseService extends ContenteditableService
@setInnerState dragging: false
return event
# Floating toolbar plugins need to know what we're currently hovering
# over. We take care of debouncing the event handlers here to prevent
# flooding plugins with events.
_onMouseOver: (event) =>
# @setInnerState hoveringOver: event.target
module.exports = MouseService

View file

@ -0,0 +1,27 @@
{ContenteditableExtension} = require 'nylas-exports'
# This provides the default baisc formatting options for the
# Contenteditable using the declarative extension API.
#
# NOTE: Blockquotes get their own formatting in `BlockquoteManager`
class ParagraphFormattingExtension extends ContenteditableExtension
@keyCommandHandlers: =>
"contenteditable:indent": @_onIndent
"contenteditable:outdent": @_onOutdent
@toolbarButtons: => []
@_onIndent: ({editor, event}) -> editor.indent()
@_onOutdent: ({editor, event}) -> editor.outdent()
# None of the paragraph formatting buttons need a custom component.
#
# They use the default <ToolbarButtons> component via the
# `toolbarButtons` extension API.
#
# We can either return `null` or return the requsted object with no
# component.
@toolbarComponentConfig: => null
module.exports = ParagraphFormattingExtension

View file

@ -0,0 +1,46 @@
{DOMUtils, ContenteditableExtension} = require 'nylas-exports'
ToolbarButtons = require './toolbar-buttons'
# This contains the logic to declaratively render the core
# <ToolbarButtons> component in a <FloatingToolbar>
class ToolbarButtonManager extends ContenteditableExtension
# See the {EmphasisFormattingExtension} and {LinkManager} and other
# extensions for toolbarButtons.
@toolbarButtons: => []
@toolbarComponentConfig: ({toolbarState}) =>
return null if toolbarState.dragging or toolbarState.doubleDown
return null unless toolbarState.selectionSnapshot
return null if toolbarState.selectionSnapshot.isCollapsed
buttonConfigs = @_toolbarButtonConfigs(toolbarState)
return {
component: ToolbarButtons
props:
buttonConfigs: buttonConfigs
locationRefNode: DOMUtils.getRangeInScope(toolbarState.editableNode)
width: buttonConfigs.length * 28.5
}
@_toolbarButtonConfigs: (toolbarState) ->
{extensions, atomicEdit} = toolbarState
buttonConfigs = []
for extension in extensions
try
extensionConfigs = extension.toolbarButtons?({toolbarState}) ? []
continue if extensionConfigs.length is 0
extensionConfigs.map (config) ->
fn = config.onClick ? ->
config.onClick = (event) -> atomicEdit(fn, {event})
return config
buttonConfigs = buttonConfigs.concat(extensionConfigs)
catch error
NylasEnv.emitError(error)
return buttonConfigs
module.exports = ToolbarButtonManager

View file

@ -0,0 +1,38 @@
React = require 'react/addons'
{RetinaImg} = require 'nylas-component-kit'
# This component renders buttons and is the default view in the
# FloatingToolbar.
#
# Extensions that implement `toolbarButtons` can get their buttons added
# in.
#
# The {EmphasisFormattingExtension} extension is an example of one that
# implements this spec.
class ToolbarButtons extends React.Component
@displayName = "ToolbarButtons"
@propTypes:
# Declares what buttons should appear in the toolbar. An array of
# config objects.
buttonConfigs: React.PropTypes.array
@defaultProps:
buttonConfigs: []
render: ->
<div className="toolbar-buttons">{@_renderToolbarButtons()}</div>
_renderToolbarButtons: ->
@props.buttonConfigs.map (config, i) ->
if (config.iconUrl ? "").length > 0
icon = <RetinaImg mode={RetinaImg.Mode.ContentIsMask}
url="#{config.iconUrl}" />
else icon = ""
<button className="btn toolbar-btn #{config.className ? ''}"
key={"btn-#{i}"}
onClick={config.onClick}
title="#{config.tooltip}">{icon}</button>
module.exports = ToolbarButtons

View file

@ -23,7 +23,7 @@ class Flexbox extends React.Component
style: React.PropTypes.object
render: ->
style = _.extend (@props.style || {}),
style = _.extend {}, (@props.style || {}),
'flexDirection': @props.direction,
'position':'relative'
'display': 'flex'

View file

@ -76,7 +76,7 @@ class Spinner extends React.Component
"spinner-cover": true
"hidden": @state.hidden
style = _.extend @props.style ? {},
style = _.extend {}, (@props.style ? {}),
'position':'absolute'
'display': if @state.hidden then "none" else "block"
'top': '0'
@ -96,7 +96,7 @@ class Spinner extends React.Component
'hidden': @state.hidden
'paused': @state.paused
style = _.extend @props.style ? {},
style = _.extend {}, (@props.style ? {}),
'position':'absolute'
'left': '50%'
'top': '50%'

View file

@ -268,7 +268,7 @@ class TokenizingTextField extends React.Component
render: =>
{Menu} = require 'nylas-component-kit'
classes = classNames _.extend (@props.menuClassSet ? {}),
classes = classNames _.extend {}, (@props.menuClassSet ? {}),
"tokenizing-field": true
"native-key-bindings": true
"focused": @state.focus

View file

@ -1,4 +1,5 @@
React = require 'react'
{Utils} = require 'nylas-exports'
_ = require 'underscore'
###
@ -42,6 +43,10 @@ class UnsafeComponent extends React.Component
componentDidMount: =>
@renderInjected()
shouldComponentUpdate: (nextProps, nextState) =>
not Utils.isEqualReact(nextProps, @props) or
not Utils.isEqualReact(nextState, @state)
componentDidUpdate: =>
@renderInjected()

View file

@ -316,6 +316,14 @@ DOMUtils =
else continue
return null
# Only looks down node trees with one child for a text node.
# Returns null if there's no single text node
findOnlyChildTextNode: (node) ->
return null unless node
return node if node.nodeType is Node.TEXT_NODE
return null if node.childNodes.length > 1
return DOMUtils.findOnlyChildTextNode(node.childNodes[0])
findFirstTextNode: (node) ->
return null unless node
return node if node.nodeType is Node.TEXT_NODE
@ -623,12 +631,24 @@ DOMUtils =
# Modifies the DOM to "unwrap" a given node, replacing that node with its contents.
# This may break selections containing the affected nodes.
# We don't use `document.createFragment` because the returned `fragment`
# would be empty and useless after its children get replaced.
unwrapNode: (node) ->
fragment = document.createDocumentFragment()
while (child = node.firstChild)
fragment.appendChild(child)
node.parentNode.replaceChild(fragment, node)
return fragment
return node if node.childNodes.length is 0
replacedNodes = []
parent = node.parentNode
return node if not parent?
lastChild = _.last(node.childNodes)
replacedNodes.unshift(lastChild)
parent.replaceChild(lastChild, node)
while child = _.last(node.childNodes)
replacedNodes.unshift(child)
parent.insertBefore(child, lastChild)
lastChild = child
return replacedNodes
isDescendantOf: (node, matcher = -> false) ->
parent = node?.parentElement

View file

@ -50,30 +50,30 @@ class ComposerExtension extends ContenteditableExtension
@warningsForSending: ({draft}) ->
[]
###
Public: declare an icon to be displayed in the composer's toolbar (where
bold, italic, underline, etc are).
You must return an object that contains the following properties:
- `mutator`: A function that's called when your toolbar button is
clicked. The mutator will be passed: `(contenteditableDOM, selection,
event)`. It will be executed in a wrapped transaction block where it is
safe to mutate the DOM and the selection object.
- `className`: The button will already have the `btn` and `toolbar-btn`
classes.
- `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: declare an icon to be displayed in the composer's toolbar (where
# bold, italic, underline, etc are).
#
# You must return an object that contains the following properties:
#
# - `mutator`: A function that's called when your toolbar button is
# clicked. The mutator will be passed: `(contenteditableDOM, selection,
# event)`. It will be executed in a wrapped transaction block where it is
# safe to mutate the DOM and the selection object.
#
# - `className`: The button will already have the `btn` and `toolbar-btn`
# classes.
#
# - `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

View file

@ -148,4 +148,73 @@ class ContenteditableExtension
###
@onShowContextMenu: ({editor, event, menu}) ->
###
Public: Override `keyCommandHandlers` to declaratively map keyboard
commands to callbacks.
Return an object keyed by the command name whose values are the
callbacks.
Callbacks are automatically bound to the Contenteditable context and
passed `({editor, event})` as its argument.
New commands are defined in keymap.cson files.
###
@keyCommandHandlers: =>
###
Public: Override `toolbarButtons` to declaratively add your own button
to the composer's toolbar.
- toolbarState: The current state of the Toolbar and Composer. This is
Read only.
Must return an array of objects obeying the following spec:
- className: A string class name
- onClick: Callback to fire when your button is clicked. The callback
is automatically bound to the editor and will get passed an single
object with the following args.
- editor - The {Editor} controller for manipulating the DOM
- event - The click Event object
- tooltip: A string to display when users hover over your button
- iconUrl: A url for the icon.
###
@toolbarButtons: ({toolbarState}) ->
###
Public: Override `toolbarComponentConfig` to declaratively show your own
toolbar when certain conditions are met.
If you want to hide your toolbar component, return null.
If you want to display your toolbar, then return an object with the
signature indicated below.
This methods gets called anytime the `toolbarState` changes. Since
`toolbarState` includes the current value of the Selection and any
objects a user is hovering over, you should expect it to change very
frequently.
- toolbarState: The current state of the Toolbar and Composer. This is
Read only.
- dragging
- doubleDown
- hoveringOver
- editableNode
- exportedSelection
- extensions
- atomicEdit
Must return an object with the following signature
- component: A React component or null.
- props: Props to be passed into your custom Component
- locationRefNode: Anything (usually a DOM Node) that responds to
`getBoundingClientRect`. This is used to determine where to display
your component.
- width: The width of your component. This is necessary because when
your component is displayed in the {FloatingToolbar}, the position is
pre-computed based on the absolute width of the item.
###
@toolbarComponentConfig: ({toolbarState}) ->
module.exports = ContenteditableExtension

View file

@ -270,7 +270,8 @@ class NylasEnvConstructor extends Model
@emitError(error)
emitError: (error) ->
console.error(error) unless @inSpecMode()
console.error(error.message) unless @inSpecMode()
console.error(error.stack) unless @inSpecMode()
eventObject = {message: error.message, originalError: error}
@emitter.emit('will-throw-error', eventObject)
@emit('uncaught-error', error.message, null, null, null, error)

View file

@ -1,3 +1,4 @@
_ = require('underscore')
RegExpUtils =
# It's important that the regex be wrapped in parens, otherwise
@ -19,6 +20,14 @@ RegExpUtils =
# https://regex101.com/r/zG7aW4/3
imageTagRegex: -> /<img\s+[^>]*src="([^"]*)"[^>]*>/g
punctuation: ({exclude}={}) ->
exclude ?= []
punctuation = [ '.', ',', '\\/', '#', '!', '$', '%', '^', '&', '*',
';', ':', '{', '}', '=', '\\-', '_', '`', '~', '(', ')', '@', '+',
'?', '>', '<', '\\[', '\\]', '+' ]
punctuation = _.difference(punctuation, exclude).join('')
return new RegExp("[#{punctuation}]", 'g')
# This tests for valid schemes as per RFC 3986
# We need both http: https: and mailto: and a variety of other schemes.
# This does not check for invalid usage of the http: scheme. For

View file

@ -10,6 +10,10 @@
div[contenteditable], .contenteditable {
flex: 1;
a:hover {
cursor: text;
}
}
spelling.misspelled {
@ -30,9 +34,7 @@
transition-duration: .15s;
transition-property: opacity, margin;
opacity: 0;
visibility: hidden;
margin-top: 3px;
margin-top: 0;
&.toolbar-visible {
opacity: 1;