mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-09-06 20:54:26 +08:00
update(extensions): Rename DraftStoreExtension and MessageStoreExtension
Summary: - Rename DraftStoreExtension to ComposerExtension - Rename MessageStoreExtension to MessageViewExtension - Rename ContenteditablePlugin to ContenteditableExtension - Update Contenteditable to use new naming convention - Adds support for extension handlers as props - Add ExtensionRegistry to register extensions: - ContenteditableExtensions will not be registered through the ExtensionRegistry. They are meant for internal use, or if anyone wants to use our Contenteditable component directly in their plugins. - Adds specs - Refactors internal_packages and src to use new names and new ExtensionRegistry api - Adds deprecation util function and deprecation notices for old api methods: - DraftStore.{registerExtension, unregisterExtension} - MessageStore.{registerExtension, unregisterExtension} - DraftStoreExtension.{onMouseUp, onTabDown} - MessageStoreExtension - Adds and updates docs Test Plan: - Unit tests Reviewers: bengotow, evan Reviewed By: evan Differential Revision: https://phab.nylas.com/D2293
This commit is contained in:
parent
02633ffd57
commit
cfdc401c54
34 changed files with 682 additions and 369 deletions
|
@ -14,18 +14,18 @@ This API allows your package to:
|
||||||
|
|
||||||
- Transform the draft and make additional changes before it is sent.
|
- Transform the draft and make additional changes before it is sent.
|
||||||
|
|
||||||
To create your own composer extensions, subclass {DraftStoreExtensions} and override the methods your extension needs.
|
To create your own composer extensions, subclass {ComposerExtension} and override the methods your extension needs.
|
||||||
|
|
||||||
In the sample packages repository, [templates]() is an example of a package which uses a DraftStoreExtension to enhance the composer experience.
|
In the sample packages repository, [templates]() is an example of a package which uses a ComposerExtension to enhance the composer experience.
|
||||||
|
|
||||||
### Example
|
### Example
|
||||||
|
|
||||||
This extension displays a warning before sending a draft that contains the names of competitors' products. If the user proceeds to send the draft containing the words, it appends a disclaimer.
|
This extension displays a warning before sending a draft that contains the names of competitors' products. If the user proceeds to send the draft containing the words, it appends a disclaimer.
|
||||||
|
|
||||||
```coffee
|
```coffee
|
||||||
{DraftStoreExtension} = require 'nylas-exports'
|
{ComposerExtension} = require 'nylas-exports'
|
||||||
|
|
||||||
class ProductsExtension extends DraftStoreExtension
|
class ProductsExtension extends ComposerExtension
|
||||||
|
|
||||||
@warningsForSending: (draft) ->
|
@warningsForSending: (draft) ->
|
||||||
words = ['acme', 'anvil', 'tunnel', 'rocket', 'dynamite']
|
words = ['acme', 'anvil', 'tunnel', 'rocket', 'dynamite']
|
|
@ -43,7 +43,6 @@ class TemplatesDraftStoreExtension extends DraftStoreExtension {
|
||||||
}
|
}
|
||||||
|
|
||||||
static onTabDown(editableNode, range, event) {
|
static onTabDown(editableNode, range, event) {
|
||||||
console.log('tabbed');
|
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
return this.onTabSelectNextVar(editableNode, range, event, -1);
|
return this.onTabSelectNextVar(editableNode, range, event, -1);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{PreferencesUIStore, DraftStore} = require 'nylas-exports'
|
{PreferencesUIStore, ExtensionRegistry} = require 'nylas-exports'
|
||||||
SignatureDraftExtension = require './signature-draft-extension'
|
SignatureComposerExtension = require './signature-composer-extension'
|
||||||
|
|
||||||
module.exports =
|
module.exports =
|
||||||
activate: (@state={}) ->
|
activate: (@state={}) ->
|
||||||
|
@ -8,11 +8,11 @@ module.exports =
|
||||||
displayName: "Signatures"
|
displayName: "Signatures"
|
||||||
component: require "./preferences-signatures"
|
component: require "./preferences-signatures"
|
||||||
|
|
||||||
DraftStore.registerExtension(SignatureDraftExtension)
|
ExtensionRegistry.Composer.register(SignatureComposerExtension)
|
||||||
PreferencesUIStore.registerPreferencesTab(@preferencesTab)
|
PreferencesUIStore.registerPreferencesTab(@preferencesTab)
|
||||||
|
|
||||||
deactivate: ->
|
deactivate: ->
|
||||||
DraftStore.unregisterExtension(SignatureDraftExtension)
|
ExtensionRegistry.Composer.unregister(SignatureComposerExtension)
|
||||||
PreferencesUIStore.unregisterPreferencesTab(@preferencesTab.sectionId)
|
PreferencesUIStore.unregisterPreferencesTab(@preferencesTab.sectionId)
|
||||||
|
|
||||||
serialize: -> @state
|
serialize: -> @state
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{DraftStoreExtension, AccountStore} = require 'nylas-exports'
|
{ComposerExtension, AccountStore} = require 'nylas-exports'
|
||||||
|
|
||||||
class SignatureDraftStoreExtension extends DraftStoreExtension
|
class SignatureComposerExtension extends ComposerExtension
|
||||||
@prepareNewDraft: (draft) ->
|
@prepareNewDraft: (draft) ->
|
||||||
accountId = AccountStore.current().id
|
accountId = AccountStore.current().id
|
||||||
signature = NylasEnv.config.get("nylas.account-#{accountId}.signature")
|
signature = NylasEnv.config.get("nylas.account-#{accountId}.signature")
|
||||||
|
@ -11,4 +11,4 @@ class SignatureDraftStoreExtension extends DraftStoreExtension
|
||||||
insertionPoint = draft.body.length
|
insertionPoint = draft.body.length
|
||||||
draft.body = draft.body.substr(0, insertionPoint-1) + "<br/>" + signature + draft.body.substr(insertionPoint)
|
draft.body = draft.body.substr(0, insertionPoint-1) + "<br/>" + signature + draft.body.substr(insertionPoint)
|
||||||
|
|
||||||
module.exports = SignatureDraftStoreExtension
|
module.exports = SignatureComposerExtension
|
|
@ -1,8 +1,8 @@
|
||||||
{Message} = require 'nylas-exports'
|
{Message} = require 'nylas-exports'
|
||||||
|
|
||||||
SignatureDraftStoreExtension = require '../lib/signature-draft-extension'
|
SignatureComposerExtension = require '../lib/signature-composer-extension'
|
||||||
|
|
||||||
describe "SignatureDraftStoreExtension", ->
|
describe "SignatureComposerExtension", ->
|
||||||
describe "prepareNewDraft", ->
|
describe "prepareNewDraft", ->
|
||||||
describe "when a signature is defined", ->
|
describe "when a signature is defined", ->
|
||||||
beforeEach ->
|
beforeEach ->
|
||||||
|
@ -18,9 +18,9 @@ describe "SignatureDraftStoreExtension", ->
|
||||||
draft: true
|
draft: true
|
||||||
body: 'This is a another test.'
|
body: 'This is a another test.'
|
||||||
|
|
||||||
SignatureDraftStoreExtension.prepareNewDraft(a)
|
SignatureComposerExtension.prepareNewDraft(a)
|
||||||
expect(a.body).toEqual("This is a test!<br/><div id='signature'>This is my signature.</div><blockquote>Hello world</blockquote>")
|
expect(a.body).toEqual("This is a test!<br/><div id='signature'>This is my signature.</div><blockquote>Hello world</blockquote>")
|
||||||
SignatureDraftStoreExtension.prepareNewDraft(b)
|
SignatureComposerExtension.prepareNewDraft(b)
|
||||||
expect(b.body).toEqual("This is a another test<br/><div id='signature'>This is my signature.</div>")
|
expect(b.body).toEqual("This is a another test<br/><div id='signature'>This is my signature.</div>")
|
||||||
|
|
||||||
describe "when a signature is not defined", ->
|
describe "when a signature is not defined", ->
|
||||||
|
@ -32,5 +32,5 @@ describe "SignatureDraftStoreExtension", ->
|
||||||
a = new Message
|
a = new Message
|
||||||
draft: true
|
draft: true
|
||||||
body: 'This is a test! <blockquote>Hello world</blockquote>'
|
body: 'This is a test! <blockquote>Hello world</blockquote>'
|
||||||
SignatureDraftStoreExtension.prepareNewDraft(a)
|
SignatureComposerExtension.prepareNewDraft(a)
|
||||||
expect(a.body).toEqual('This is a test! <blockquote>Hello world</blockquote>')
|
expect(a.body).toEqual('This is a test! <blockquote>Hello world</blockquote>')
|
|
@ -1,11 +1,11 @@
|
||||||
{ComponentRegistry, DraftStore} = require 'nylas-exports'
|
{ExtensionRegistry} = require 'nylas-exports'
|
||||||
Extension = require './draft-extension'
|
SpellcheckComposerExtension = require './spellcheck-composer-extension'
|
||||||
|
|
||||||
module.exports =
|
module.exports =
|
||||||
activate: (@state={}) ->
|
activate: (@state={}) ->
|
||||||
DraftStore.registerExtension(Extension)
|
ExtensionRegistry.Composer.register(SpellcheckComposerExtension)
|
||||||
|
|
||||||
deactivate: ->
|
deactivate: ->
|
||||||
DraftStore.unregisterExtension(Extension)
|
ExtensionRegistry.Composer.unregister(SpellcheckComposerExtension)
|
||||||
|
|
||||||
serialize: -> @state
|
serialize: -> @state
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
{DraftStoreExtension, AccountStore, DOMUtils} = require 'nylas-exports'
|
{ComposerExtension, AccountStore, DOMUtils} = require 'nylas-exports'
|
||||||
_ = require 'underscore'
|
_ = require 'underscore'
|
||||||
spellchecker = require('spellchecker')
|
spellchecker = require('spellchecker')
|
||||||
remote = require('remote')
|
remote = require('remote')
|
||||||
|
@ -6,16 +6,16 @@ MenuItem = remote.require('menu-item')
|
||||||
|
|
||||||
SpellcheckCache = {}
|
SpellcheckCache = {}
|
||||||
|
|
||||||
class SpellcheckDraftStoreExtension extends DraftStoreExtension
|
class SpellcheckComposerExtension extends ComposerExtension
|
||||||
|
|
||||||
@isMisspelled: (word) ->
|
@isMisspelled: (word) ->
|
||||||
SpellcheckCache[word] ?= spellchecker.isMisspelled(word)
|
SpellcheckCache[word] ?= spellchecker.isMisspelled(word)
|
||||||
SpellcheckCache[word]
|
SpellcheckCache[word]
|
||||||
|
|
||||||
@onInput: (editableNode) ->
|
@onInput: (editableNode) =>
|
||||||
@walkTree(editableNode)
|
@walkTree(editableNode)
|
||||||
|
|
||||||
@onShowContextMenu: (event, editableNode, selection, innerStateProxy, menu) =>
|
@onShowContextMenu: (event, editableNode, selection, menu) =>
|
||||||
range = DOMUtils.Mutating.getRangeAtAndSelectWord(selection, 0)
|
range = DOMUtils.Mutating.getRangeAtAndSelectWord(selection, 0)
|
||||||
word = range.toString()
|
word = range.toString()
|
||||||
if @isMisspelled(word)
|
if @isMisspelled(word)
|
||||||
|
@ -123,6 +123,6 @@ class SpellcheckDraftStoreExtension extends DraftStoreExtension
|
||||||
if body != clean
|
if body != clean
|
||||||
session.changes.add(body: clean)
|
session.changes.add(body: clean)
|
||||||
|
|
||||||
SpellcheckDraftStoreExtension.SpellcheckCache = SpellcheckCache
|
SpellcheckComposerExtension.SpellcheckCache = SpellcheckCache
|
||||||
|
|
||||||
module.exports = SpellcheckDraftStoreExtension
|
module.exports = SpellcheckComposerExtension
|
|
@ -1,22 +1,22 @@
|
||||||
SpellcheckDraftStoreExtension = require '../lib/draft-extension'
|
SpellcheckComposerExtension = require '../lib/spellcheck-composer-extension'
|
||||||
fs = require 'fs'
|
fs = require 'fs'
|
||||||
_ = require 'underscore'
|
_ = require 'underscore'
|
||||||
|
|
||||||
initialHTML = fs.readFileSync(__dirname + '/fixtures/california-with-misspellings-before.html').toString()
|
initialHTML = fs.readFileSync(__dirname + '/fixtures/california-with-misspellings-before.html').toString()
|
||||||
expectedHTML = fs.readFileSync(__dirname + '/fixtures/california-with-misspellings-after.html').toString()
|
expectedHTML = fs.readFileSync(__dirname + '/fixtures/california-with-misspellings-after.html').toString()
|
||||||
|
|
||||||
describe "SpellcheckDraftStoreExtension", ->
|
describe "SpellcheckComposerExtension", ->
|
||||||
beforeEach ->
|
beforeEach ->
|
||||||
# Avoid differences between node-spellcheck on different platforms
|
# Avoid differences between node-spellcheck on different platforms
|
||||||
spellings = JSON.parse(fs.readFileSync(__dirname + '/fixtures/california-spelling-lookup.json'))
|
spellings = JSON.parse(fs.readFileSync(__dirname + '/fixtures/california-spelling-lookup.json'))
|
||||||
spyOn(SpellcheckDraftStoreExtension, 'isMisspelled').andCallFake (word) ->
|
spyOn(SpellcheckComposerExtension, 'isMisspelled').andCallFake (word) ->
|
||||||
spellings[word]
|
spellings[word]
|
||||||
|
|
||||||
describe "walkTree", ->
|
describe "walkTree", ->
|
||||||
it "correctly walks a DOM tree and surrounds mispelled words", ->
|
it "correctly walks a DOM tree and surrounds mispelled words", ->
|
||||||
dom = document.createElement('div')
|
dom = document.createElement('div')
|
||||||
dom.innerHTML = initialHTML
|
dom.innerHTML = initialHTML
|
||||||
SpellcheckDraftStoreExtension.walkTree(dom)
|
SpellcheckComposerExtension.walkTree(dom)
|
||||||
expect(dom.innerHTML).toEqual(expectedHTML)
|
expect(dom.innerHTML).toEqual(expectedHTML)
|
||||||
|
|
||||||
describe "finalizeSessionBeforeSending", ->
|
describe "finalizeSessionBeforeSending", ->
|
||||||
|
@ -27,7 +27,7 @@ describe "SpellcheckDraftStoreExtension", ->
|
||||||
changes:
|
changes:
|
||||||
add: jasmine.createSpy('add')
|
add: jasmine.createSpy('add')
|
||||||
|
|
||||||
SpellcheckDraftStoreExtension.finalizeSessionBeforeSending(session)
|
SpellcheckComposerExtension.finalizeSessionBeforeSending(session)
|
||||||
expect(session.changes.add).toHaveBeenCalledWith(body: initialHTML)
|
expect(session.changes.add).toHaveBeenCalledWith(body: initialHTML)
|
||||||
|
|
||||||
module.exports = SpellcheckDraftStoreExtension
|
module.exports = SpellcheckComposerExtension
|
|
@ -1,27 +0,0 @@
|
||||||
{DraftStore, DOMUtils, ContenteditablePlugin} = require 'nylas-exports'
|
|
||||||
|
|
||||||
class ComposerExtensionsPlugin extends ContenteditablePlugin
|
|
||||||
@onInput: (event, editableNode, selection, innerStateProxy) ->
|
|
||||||
for extension in DraftStore.extensions()
|
|
||||||
extension.onInput?(editableNode, event)
|
|
||||||
|
|
||||||
@onKeyDown: (event, editableNode, selection, innerStateProxy) ->
|
|
||||||
if event.key is "Tab"
|
|
||||||
range = DOMUtils.getRangeInScope(editableNode)
|
|
||||||
for extension in DraftStore.extensions()
|
|
||||||
extension.onTabDown?(editableNode, range, event)
|
|
||||||
|
|
||||||
@onShowContextMenu: (args...) ->
|
|
||||||
for extension in DraftStore.extensions()
|
|
||||||
extension.onShowContextMenu?(args...)
|
|
||||||
|
|
||||||
@onClick: (event, editableNode, selection, innerStateProxy) ->
|
|
||||||
range = DOMUtils.getRangeInScope(editableNode)
|
|
||||||
return unless range
|
|
||||||
try
|
|
||||||
for extension in DraftStore.extensions()
|
|
||||||
extension.onMouseUp?(editableNode, range, event)
|
|
||||||
catch e
|
|
||||||
console.error('DraftStore extension raised an error: '+e.toString())
|
|
||||||
|
|
||||||
module.exports = ComposerExtensionsPlugin
|
|
|
@ -10,7 +10,8 @@ React = require 'react'
|
||||||
AccountStore,
|
AccountStore,
|
||||||
FileUploadStore,
|
FileUploadStore,
|
||||||
QuotedHTMLParser,
|
QuotedHTMLParser,
|
||||||
FileDownloadStore} = require 'nylas-exports'
|
FileDownloadStore,
|
||||||
|
ExtensionRegistry} = require 'nylas-exports'
|
||||||
|
|
||||||
{DropZone,
|
{DropZone,
|
||||||
RetinaImg,
|
RetinaImg,
|
||||||
|
@ -29,8 +30,6 @@ CollapsedParticipants = require './collapsed-participants'
|
||||||
|
|
||||||
Fields = require './fields'
|
Fields = require './fields'
|
||||||
|
|
||||||
ComposerExtensionsPlugin = require './composer-extensions-plugin'
|
|
||||||
|
|
||||||
# The ComposerView is a unique React component because it (currently) is a
|
# The ComposerView is a unique React component because it (currently) is a
|
||||||
# singleton. Normally, the React way to do things would be to re-render the
|
# singleton. Normally, the React way to do things would be to re-render the
|
||||||
# Composer with new props.
|
# Composer with new props.
|
||||||
|
@ -68,6 +67,7 @@ class ComposerView extends React.Component
|
||||||
enabledFields: [] # Gets updated in @_initiallyEnabledFields
|
enabledFields: [] # Gets updated in @_initiallyEnabledFields
|
||||||
showQuotedText: false
|
showQuotedText: false
|
||||||
uploads: FileUploadStore.uploadsForMessage(@props.draftClientId) ? []
|
uploads: FileUploadStore.uploadsForMessage(@props.draftClientId) ? []
|
||||||
|
extensions: ExtensionRegistry.Composer.extensions()
|
||||||
|
|
||||||
componentWillMount: =>
|
componentWillMount: =>
|
||||||
@_prepareForDraft(@props.draftClientId)
|
@_prepareForDraft(@props.draftClientId)
|
||||||
|
@ -80,6 +80,7 @@ class ComposerView extends React.Component
|
||||||
@_usubs = []
|
@_usubs = []
|
||||||
@_usubs.push FileUploadStore.listen @_onFileUploadStoreChange
|
@_usubs.push FileUploadStore.listen @_onFileUploadStoreChange
|
||||||
@_usubs.push AccountStore.listen @_onAccountStoreChanged
|
@_usubs.push AccountStore.listen @_onAccountStoreChanged
|
||||||
|
@_usubs.push ExtensionRegistry.Composer.listen @_onExtensionsChanged
|
||||||
@_applyFieldFocus()
|
@_applyFieldFocus()
|
||||||
|
|
||||||
componentWillUnmount: =>
|
componentWillUnmount: =>
|
||||||
|
@ -300,7 +301,7 @@ class ComposerView extends React.Component
|
||||||
onScrollTo={@props.onRequestScrollTo}
|
onScrollTo={@props.onRequestScrollTo}
|
||||||
onFilePaste={@_onFilePaste}
|
onFilePaste={@_onFilePaste}
|
||||||
onScrollToBottom={@_onScrollToBottom()}
|
onScrollToBottom={@_onScrollToBottom()}
|
||||||
plugins={[ComposerExtensionsPlugin]}
|
extensions={@state.extensions}
|
||||||
getComposerBoundingRect={@_getComposerBoundingRect}
|
getComposerBoundingRect={@_getComposerBoundingRect}
|
||||||
initialSelectionSnapshot={@_recoveredSelection} />
|
initialSelectionSnapshot={@_recoveredSelection} />
|
||||||
|
|
||||||
|
@ -529,6 +530,9 @@ class ComposerView extends React.Component
|
||||||
enabledFields.push Fields.Body
|
enabledFields.push Fields.Body
|
||||||
return enabledFields
|
return enabledFields
|
||||||
|
|
||||||
|
_onExtensionsChanged: =>
|
||||||
|
@setState extensions: ExtensionRegistry.Composer.extensions()
|
||||||
|
|
||||||
# When the account store changes, the From field may or may not still
|
# When the account store changes, the From field may or may not still
|
||||||
# be in scope. We need to make sure to update our enabled fields.
|
# be in scope. We need to make sure to update our enabled fields.
|
||||||
_onAccountStoreChanged: =>
|
_onAccountStoreChanged: =>
|
||||||
|
@ -685,7 +689,7 @@ class ComposerView extends React.Component
|
||||||
warnings.push('without a body')
|
warnings.push('without a body')
|
||||||
|
|
||||||
# Check third party warnings added via DraftStore extensions
|
# Check third party warnings added via DraftStore extensions
|
||||||
for extension in DraftStore.extensions()
|
for extension in @state.extensions
|
||||||
continue unless extension.warningsForSending
|
continue unless extension.warningsForSending
|
||||||
warnings = warnings.concat(extension.warningsForSending(draft))
|
warnings = warnings.concat(extension.warningsForSending(draft))
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
AutoloadImagesStore = require './autoload-images-store'
|
AutoloadImagesStore = require './autoload-images-store'
|
||||||
{MessageStoreExtension} = require 'nylas-exports'
|
{MessageViewExtension} = require 'nylas-exports'
|
||||||
|
|
||||||
class AutoloadImagesExtension extends MessageStoreExtension
|
class AutoloadImagesExtension extends MessageViewExtension
|
||||||
|
|
||||||
@formatMessageBody: (message) ->
|
@formatMessageBody: (message) ->
|
||||||
if AutoloadImagesStore.shouldBlockImagesIn(message)
|
if AutoloadImagesStore.shouldBlockImagesIn(message)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{ComponentRegistry,
|
{ComponentRegistry,
|
||||||
MessageStore,
|
ExtensionRegistry,
|
||||||
WorkspaceStore} = require 'nylas-exports'
|
WorkspaceStore} = require 'nylas-exports'
|
||||||
|
|
||||||
AutoloadImagesExtension = require './autoload-images-extension'
|
AutoloadImagesExtension = require './autoload-images-extension'
|
||||||
|
@ -10,12 +10,12 @@ module.exports =
|
||||||
|
|
||||||
activate: (@state={}) ->
|
activate: (@state={}) ->
|
||||||
# Register Message List Actions we provide globally
|
# Register Message List Actions we provide globally
|
||||||
MessageStore.registerExtension(AutoloadImagesExtension)
|
ExtensionRegistry.MessageView.register AutoloadImagesExtension
|
||||||
ComponentRegistry.register AutoloadImagesHeader,
|
ComponentRegistry.register AutoloadImagesHeader,
|
||||||
role: 'message:BodyHeader'
|
role: 'message:BodyHeader'
|
||||||
|
|
||||||
deactivate: ->
|
deactivate: ->
|
||||||
MessageStore.unregisterExtension(AutoloadImagesExtension)
|
ExtensionRegistry.MessageView.unregister AutoloadImagesExtension
|
||||||
ComponentRegistry.unregister(AutoloadImagesHeader)
|
ComponentRegistry.unregister(AutoloadImagesHeader)
|
||||||
|
|
||||||
serialize: -> @state
|
serialize: -> @state
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
MessageList = require "./message-list"
|
MessageList = require "./message-list"
|
||||||
MessageToolbarItems = require "./message-toolbar-items"
|
MessageToolbarItems = require "./message-toolbar-items"
|
||||||
{ComponentRegistry,
|
{ComponentRegistry,
|
||||||
MessageStore,
|
ExtensionRegistry,
|
||||||
WorkspaceStore} = require 'nylas-exports'
|
WorkspaceStore} = require 'nylas-exports'
|
||||||
|
|
||||||
{SidebarContactCard,
|
{SidebarContactCard,
|
||||||
|
@ -46,8 +46,8 @@ module.exports =
|
||||||
ComponentRegistry.register ThreadToggleUnreadButton,
|
ComponentRegistry.register ThreadToggleUnreadButton,
|
||||||
role: 'message:Toolbar'
|
role: 'message:Toolbar'
|
||||||
|
|
||||||
MessageStore.registerExtension(AutolinkerExtension)
|
ExtensionRegistry.MessageView.register AutolinkerExtension
|
||||||
MessageStore.registerExtension(TrackingPixelsExtension)
|
ExtensionRegistry.MessageView.register TrackingPixelsExtension
|
||||||
|
|
||||||
deactivate: ->
|
deactivate: ->
|
||||||
ComponentRegistry.unregister MessageList
|
ComponentRegistry.unregister MessageList
|
||||||
|
@ -59,7 +59,7 @@ module.exports =
|
||||||
ComponentRegistry.unregister SidebarContactCard
|
ComponentRegistry.unregister SidebarContactCard
|
||||||
ComponentRegistry.unregister SidebarSpacer
|
ComponentRegistry.unregister SidebarSpacer
|
||||||
ComponentRegistry.unregister SidebarContactList
|
ComponentRegistry.unregister SidebarContactList
|
||||||
MessageStore.unregisterExtension(AutolinkerExtension)
|
ExtensionRegistry.MessageView.unregister AutolinkerExtension
|
||||||
MessageStore.unregisterExtension(TrackingPixelsExtension)
|
ExtensionRegistry.MessageView.unregister TrackingPixelsExtension
|
||||||
|
|
||||||
serialize: -> @state
|
serialize: -> @state
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
Autolinker = require 'autolinker'
|
Autolinker = require 'autolinker'
|
||||||
{MessageStoreExtension} = require 'nylas-exports'
|
{MessageViewExtension} = require 'nylas-exports'
|
||||||
|
|
||||||
class AutolinkerExtension extends MessageStoreExtension
|
class AutolinkerExtension extends MessageViewExtension
|
||||||
|
|
||||||
@formatMessageBody: (message) ->
|
@formatMessageBody: (message) ->
|
||||||
# Apply the autolinker pass to make emails and links clickable
|
# Apply the autolinker pass to make emails and links clickable
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
{MessageStoreExtension, RegExpUtils} = require 'nylas-exports'
|
{MessageViewExtension, RegExpUtils} = require 'nylas-exports'
|
||||||
|
|
||||||
TrackingBlacklist = [{
|
TrackingBlacklist = [{
|
||||||
name: 'Sidekick',
|
name: 'Sidekick',
|
||||||
|
@ -98,7 +98,7 @@ TrackingBlacklist = [{
|
||||||
homepage: 'http://salesloft.com'
|
homepage: 'http://salesloft.com'
|
||||||
}]
|
}]
|
||||||
|
|
||||||
class TrackingPixelsExtension extends MessageStoreExtension
|
class TrackingPixelsExtension extends MessageViewExtension
|
||||||
|
|
||||||
@formatMessageBody: (message) ->
|
@formatMessageBody: (message) ->
|
||||||
return unless message.isFromMe()
|
return unless message.isFromMe()
|
||||||
|
|
63
spec/extension-registry-spec.coffee
Normal file
63
spec/extension-registry-spec.coffee
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
ExtensionRegistry = require '../src/extension-registry'
|
||||||
|
|
||||||
|
class TestExtension
|
||||||
|
@name: 'TestExtension'
|
||||||
|
|
||||||
|
describe 'ExtensionRegistry', ->
|
||||||
|
beforeEach ->
|
||||||
|
@originalAdapters = ExtensionRegistry._deprecationAdapters
|
||||||
|
@registry = new ExtensionRegistry.Registry('Test')
|
||||||
|
spyOn @registry, 'triggerDebounced'
|
||||||
|
|
||||||
|
describe 'Registry', ->
|
||||||
|
it 'has trigger and listen to defined', ->
|
||||||
|
expect(@registry.trigger).toBeDefined()
|
||||||
|
expect(@registry.listen).toBeDefined()
|
||||||
|
expect(@registry.listenTo).toBeDefined()
|
||||||
|
|
||||||
|
describe 'register', ->
|
||||||
|
it 'throws an exception if extension not passed', ->
|
||||||
|
expect(=> @registry.register(null)).toThrow()
|
||||||
|
|
||||||
|
it 'throws an exception if extension does not have a name', ->
|
||||||
|
expect(=> @registry.register({})).toThrow()
|
||||||
|
|
||||||
|
it 'throws an exception if extension is array', ->
|
||||||
|
expect(=> @registry.register([])).toThrow()
|
||||||
|
|
||||||
|
it 'throws an exception if extension is string', ->
|
||||||
|
expect(=> @registry.register('')).toThrow()
|
||||||
|
|
||||||
|
it 'returns itself', ->
|
||||||
|
expect(@registry.register(TestExtension)).toBe(@registry)
|
||||||
|
|
||||||
|
it 'registers extension and triggers', ->
|
||||||
|
@registry.register(TestExtension)
|
||||||
|
expect(@registry.extensions().length).toEqual 1
|
||||||
|
expect(@registry.triggerDebounced).toHaveBeenCalled()
|
||||||
|
|
||||||
|
it 'does not add extensions with the same name', ->
|
||||||
|
expect(@registry.extensions().length).toEqual 0
|
||||||
|
@registry.register(TestExtension)
|
||||||
|
expect(@registry.extensions().length).toEqual 1
|
||||||
|
@registry.register({name: 'TestExtension'})
|
||||||
|
expect(@registry.extensions().length).toEqual 1
|
||||||
|
|
||||||
|
it 'calls deprecationAdapters if present for a role', ->
|
||||||
|
adapterSpy = jasmine.createSpy('adapterSpy').andCallFake (ext) -> ext
|
||||||
|
@registry = new ExtensionRegistry.Registry('Test', adapterSpy)
|
||||||
|
spyOn @registry, 'triggerDebounced'
|
||||||
|
@registry.register(TestExtension)
|
||||||
|
expect(adapterSpy.calls.length).toEqual 1
|
||||||
|
|
||||||
|
describe 'unregister', ->
|
||||||
|
it 'unregisters the extension if it exists', ->
|
||||||
|
@registry.register(TestExtension)
|
||||||
|
@registry.unregister(TestExtension)
|
||||||
|
expect(@registry.extensions().length).toEqual 0
|
||||||
|
|
||||||
|
it 'throws if invalid extension passed', ->
|
||||||
|
expect( => @registry.unregister('Test')).toThrow()
|
||||||
|
expect( => @registry.unregister(null)).toThrow()
|
||||||
|
expect( => @registry.unregister([])).toThrow()
|
||||||
|
expect( => @registry.unregister({})).toThrow()
|
|
@ -6,7 +6,7 @@ ModelQuery = require '../../src/flux/models/query'
|
||||||
AccountStore = require '../../src/flux/stores/account-store'
|
AccountStore = require '../../src/flux/stores/account-store'
|
||||||
DatabaseStore = require '../../src/flux/stores/database-store'
|
DatabaseStore = require '../../src/flux/stores/database-store'
|
||||||
DraftStore = require '../../src/flux/stores/draft-store'
|
DraftStore = require '../../src/flux/stores/draft-store'
|
||||||
DraftStoreExtension = require '../../src/flux/stores/draft-store-extension'
|
ComposerExtension = require '../../src/flux/extensions/composer-extension'
|
||||||
SendDraftTask = require '../../src/flux/tasks/send-draft'
|
SendDraftTask = require '../../src/flux/tasks/send-draft'
|
||||||
DestroyDraftTask = require '../../src/flux/tasks/destroy-draft'
|
DestroyDraftTask = require '../../src/flux/tasks/destroy-draft'
|
||||||
SoundRegistry = require '../../src/sound-registry'
|
SoundRegistry = require '../../src/sound-registry'
|
||||||
|
@ -26,7 +26,7 @@ messageWithStyleTags = null
|
||||||
fakeMessages = null
|
fakeMessages = null
|
||||||
fakeMessageWithFiles = null
|
fakeMessageWithFiles = null
|
||||||
|
|
||||||
class TestExtension extends DraftStoreExtension
|
class TestExtension extends ComposerExtension
|
||||||
@prepareNewDraft: (draft) ->
|
@prepareNewDraft: (draft) ->
|
||||||
draft.body = "Edited by TestExtension!" + draft.body
|
draft.body = "Edited by TestExtension!" + draft.body
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
_ = require 'underscore'
|
_ = require 'underscore'
|
||||||
Actions = require './flux/actions'
|
|
||||||
|
|
||||||
{Listener, Publisher} = require './flux/modules/reflux-coffee'
|
{Listener, Publisher} = require './flux/modules/reflux-coffee'
|
||||||
CoffeeHelpers = require './flux/coffee-helpers'
|
CoffeeHelpers = require './flux/coffee-helpers'
|
||||||
|
|
|
@ -1,26 +0,0 @@
|
||||||
###
|
|
||||||
ContenteditablePlugin is an abstract base class. Implementations of this
|
|
||||||
are used to make additional changes to a <Contenteditable /> component
|
|
||||||
beyond a user's input intents.
|
|
||||||
|
|
||||||
While some ContenteditablePlugins are included with the core
|
|
||||||
<Contenteditable /> component, others may be added via the `plugins`
|
|
||||||
prop.
|
|
||||||
###
|
|
||||||
class ContenteditablePlugin
|
|
||||||
|
|
||||||
# The onInput event can be triggered by a variety of events, some of
|
|
||||||
# which could have been already been looked at by a callback.
|
|
||||||
# Pretty much any DOM mutation will fire this.
|
|
||||||
# Sometimes those mutations are the cause of callbacks.
|
|
||||||
@onInput: (event, editableNode, selection, innerStateProxy) ->
|
|
||||||
|
|
||||||
@onBlur: (event, editableNode, selection, innerStateProxy) ->
|
|
||||||
|
|
||||||
@onFocus: (event, editableNode, selection, innerStateProxy) ->
|
|
||||||
|
|
||||||
@onClick: (event, editableNode, selection, innerStateProxy) ->
|
|
||||||
|
|
||||||
@onKeyDown: (event, editableNode, selection, innerStateProxy) ->
|
|
||||||
|
|
||||||
@onShowContextMenu: (event, editableNode, selection, innerStateProxy, menu) ->
|
|
|
@ -36,25 +36,34 @@ class Contenteditable extends React.Component
|
||||||
|
|
||||||
initialSelectionSnapshot: React.PropTypes.object
|
initialSelectionSnapshot: React.PropTypes.object
|
||||||
|
|
||||||
# Passes an absolute top coordinate to scroll to.
|
# Handlers
|
||||||
onChange: React.PropTypes.func.isRequired
|
onChange: React.PropTypes.func.isRequired
|
||||||
onFilePaste: React.PropTypes.func
|
onFilePaste: React.PropTypes.func
|
||||||
|
# Passes an absolute top coordinate to scroll to.
|
||||||
onScrollTo: React.PropTypes.func
|
onScrollTo: React.PropTypes.func
|
||||||
onScrollToBottom: React.PropTypes.func
|
onScrollToBottom: React.PropTypes.func
|
||||||
|
|
||||||
# A list of objects that extend {ContenteditablePlugin}
|
# Extension DOM Mutating handlers. See {ContenteditableExtension}
|
||||||
plugins: React.PropTypes.array
|
onInput: React.PropTypes.func
|
||||||
|
onBlur: React.PropTypes.func
|
||||||
|
onFocus: React.PropTypes.func
|
||||||
|
onClick: React.PropTypes.func
|
||||||
|
onKeyDown: React.PropTypes.func
|
||||||
|
onShowContextMenu: React.PropTypes.func
|
||||||
|
|
||||||
|
# A list of objects that extend {ContenteditableExtension}
|
||||||
|
extensions: React.PropTypes.array
|
||||||
|
|
||||||
spellcheck: React.PropTypes.bool
|
spellcheck: React.PropTypes.bool
|
||||||
|
|
||||||
floatingToolbar: React.PropTypes.bool
|
floatingToolbar: React.PropTypes.bool
|
||||||
|
|
||||||
@defaultProps:
|
@defaultProps:
|
||||||
plugins: []
|
extensions: []
|
||||||
spellcheck: true
|
spellcheck: true
|
||||||
floatingToolbar: true
|
floatingToolbar: true
|
||||||
|
|
||||||
corePlugins: [ListManager]
|
coreExtensions: [ListManager]
|
||||||
|
|
||||||
# We allow extensions to read, and mutate the:
|
# We allow extensions to read, and mutate the:
|
||||||
#
|
#
|
||||||
|
@ -70,7 +79,7 @@ class Contenteditable extends React.Component
|
||||||
innerStateProxy =
|
innerStateProxy =
|
||||||
get: => return @innerState
|
get: => return @innerState
|
||||||
set: (newInnerState) => @setInnerState(newInnerState)
|
set: (newInnerState) => @setInnerState(newInnerState)
|
||||||
args = [event, @_editableNode(), document.getSelection(), innerStateProxy, extraArgs...]
|
args = [event, @_editableNode(), document.getSelection(), extraArgs..., innerStateProxy]
|
||||||
editingFunction.apply(null, args)
|
editingFunction.apply(null, args)
|
||||||
@_setupSelectionListeners()
|
@_setupSelectionListeners()
|
||||||
|
|
||||||
|
@ -162,7 +171,7 @@ class Contenteditable extends React.Component
|
||||||
selection.addRange(range)
|
selection.addRange(range)
|
||||||
|
|
||||||
# When some other component (like the `FloatingToolbar` or some
|
# When some other component (like the `FloatingToolbar` or some
|
||||||
# `DraftStoreExtension`) wants to mutate the DOM, it declares a
|
# `ComposerExtension`) wants to mutate the DOM, it declares a
|
||||||
# `mutator` function. That mutator expects to be passed the latest DOM
|
# `mutator` function. That mutator expects to be passed the latest DOM
|
||||||
# object (the `_editableNode()`) and will do mutations to it. Once those
|
# object (the `_editableNode()`) and will do mutations to it. Once those
|
||||||
# mutations are done, we need to be sure to notify that changes
|
# mutations are done, we need to be sure to notify that changes
|
||||||
|
@ -201,38 +210,42 @@ class Contenteditable extends React.Component
|
||||||
@_setupSelectionListeners()
|
@_setupSelectionListeners()
|
||||||
@_onInput()
|
@_onInput()
|
||||||
|
|
||||||
# Will execute the event handlers on each of the registerd and core plugins
|
# Will execute the event handlers on each of the registerd and core extensions
|
||||||
# In this context, event.preventDefault and event.stopPropagation don't refer
|
# In this context, event.preventDefault and event.stopPropagation don't refer
|
||||||
# to stopping default DOM behavior or prevent event bubbling through the DOM,
|
# to stopping default DOM behavior or prevent event bubbling through the DOM,
|
||||||
# but rather prevent our own Contenteditable default behavior, and preventing
|
# but rather prevent our own Contenteditable default behavior, and preventing
|
||||||
# other plugins from being called.
|
# other extensions from being called.
|
||||||
# If any of the plugins calls event.preventDefault() it will prevent the
|
# If any of the extensions calls event.preventDefault() it will prevent the
|
||||||
# default behavior for the Contenteditable, which basically means preventing
|
# default behavior for the Contenteditable, which basically means preventing
|
||||||
# the core plugin handlers from being called.
|
# the core extension handlers from being called.
|
||||||
# If any of the plugins calls event.stopPropagation(), it will prevent any
|
# If any of the extensions calls event.stopPropagation(), it will prevent any
|
||||||
# other plugin handlers from being called.
|
# other extension handlers from being called.
|
||||||
#
|
#
|
||||||
# NOTE: It's possible for there to be no `event` passed in.
|
# NOTE: It's possible for there to be no `event` passed in.
|
||||||
_runPluginHandlersForEvent: (method, event, args...) =>
|
_runExtensionHandlersForEvent: (method, event, args...) =>
|
||||||
executeCallback = (plugin) =>
|
executeCallback = (extension) =>
|
||||||
return if not plugin[method]?
|
return if not extension[method]?
|
||||||
callback = plugin[method].bind(plugin)
|
callback = extension[method].bind(extension)
|
||||||
@atomicEdit(callback, event, args...)
|
@atomicEdit(callback, event, args...)
|
||||||
|
|
||||||
for plugin in @props.plugins
|
# Check if any of the extension handlers where passed as a prop and call
|
||||||
|
# that first
|
||||||
|
executeCallback(@props)
|
||||||
|
|
||||||
|
for extension in @props.extensions
|
||||||
break if event?.isPropagationStopped()
|
break if event?.isPropagationStopped()
|
||||||
executeCallback(plugin)
|
executeCallback(extension)
|
||||||
|
|
||||||
return if event?.defaultPrevented or event?.isPropagationStopped()
|
return if event?.defaultPrevented or event?.isPropagationStopped()
|
||||||
for plugin in @corePlugins
|
for extension in @coreExtensions
|
||||||
break if event?.isPropagationStopped()
|
break if event?.isPropagationStopped()
|
||||||
executeCallback(plugin)
|
executeCallback(extension)
|
||||||
|
|
||||||
_onKeyDown: (event) =>
|
_onKeyDown: (event) =>
|
||||||
@_runPluginHandlersForEvent("onKeyDown", event)
|
@_runExtensionHandlersForEvent("onKeyDown", event)
|
||||||
|
|
||||||
# This is a special case where we don't want to bubble up the event to the
|
# This is a special case where we don't want to bubble up the event to the
|
||||||
# keymap manager if the plugin prevented the default behavior
|
# keymap manager if the extension prevented the default behavior
|
||||||
if event.defaultPrevented
|
if event.defaultPrevented
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
return
|
return
|
||||||
|
@ -259,7 +272,7 @@ class Contenteditable extends React.Component
|
||||||
@_ignoreInputChanges = true
|
@_ignoreInputChanges = true
|
||||||
@_resetInnerStateOnInput()
|
@_resetInnerStateOnInput()
|
||||||
|
|
||||||
@_runPluginHandlersForEvent("onInput", event)
|
@_runExtensionHandlersForEvent("onInput", event)
|
||||||
|
|
||||||
@_normalize()
|
@_normalize()
|
||||||
|
|
||||||
|
@ -413,11 +426,12 @@ class Contenteditable extends React.Component
|
||||||
_onBlur: (event) =>
|
_onBlur: (event) =>
|
||||||
@setInnerState dragging: false
|
@setInnerState dragging: false
|
||||||
return if @_editableNode().parentElement.contains event.relatedTarget
|
return if @_editableNode().parentElement.contains event.relatedTarget
|
||||||
|
@_runExtensionHandlersForEvent("onBlur", event)
|
||||||
@setInnerState editableFocused: false
|
@setInnerState editableFocused: false
|
||||||
|
|
||||||
_onFocus: (event) =>
|
_onFocus: (event) =>
|
||||||
@setInnerState editableFocused: true
|
@setInnerState editableFocused: true
|
||||||
@props.onFocus?(event)
|
@_runExtensionHandlersForEvent("onFocus", event)
|
||||||
|
|
||||||
_editableNode: =>
|
_editableNode: =>
|
||||||
React.findDOMNode(@refs.contenteditable)
|
React.findDOMNode(@refs.contenteditable)
|
||||||
|
@ -616,7 +630,8 @@ class Contenteditable extends React.Component
|
||||||
MenuItem = remote.require('menu-item')
|
MenuItem = remote.require('menu-item')
|
||||||
|
|
||||||
menu = new Menu()
|
menu = new Menu()
|
||||||
@_runPluginHandlersForEvent("onShowContextMenu", event, menu)
|
|
||||||
|
@_runExtensionHandlersForEvent("onShowContextMenu", event, menu)
|
||||||
menu.append(new MenuItem({ label: 'Cut', role: 'cut'}))
|
menu.append(new MenuItem({ label: 'Cut', role: 'cut'}))
|
||||||
menu.append(new MenuItem({ label: 'Copy', role: 'copy'}))
|
menu.append(new MenuItem({ label: 'Copy', role: 'copy'}))
|
||||||
menu.append(new MenuItem({ label: 'Paste', role: 'paste'}))
|
menu.append(new MenuItem({ label: 'Paste', role: 'paste'}))
|
||||||
|
@ -660,7 +675,7 @@ class Contenteditable extends React.Component
|
||||||
selection = document.getSelection()
|
selection = document.getSelection()
|
||||||
return event unless DOMUtils.selectionInScope(selection, editableNode)
|
return event unless DOMUtils.selectionInScope(selection, editableNode)
|
||||||
|
|
||||||
@_runPluginHandlersForEvent("onClick", event)
|
@_runExtensionHandlersForEvent("onClick", event)
|
||||||
return event
|
return event
|
||||||
|
|
||||||
_onDragStart: (event) =>
|
_onDragStart: (event) =>
|
||||||
|
|
|
@ -3,7 +3,7 @@ React = require 'react/addons'
|
||||||
classNames = require 'classnames'
|
classNames = require 'classnames'
|
||||||
{CompositeDisposable} = require 'event-kit'
|
{CompositeDisposable} = require 'event-kit'
|
||||||
{RetinaImg} = require 'nylas-component-kit'
|
{RetinaImg} = require 'nylas-component-kit'
|
||||||
{DraftStore} = require 'nylas-exports'
|
{ExtensionRegistry} = require 'nylas-exports'
|
||||||
|
|
||||||
class FloatingToolbar extends React.Component
|
class FloatingToolbar extends React.Component
|
||||||
@displayName = "FloatingToolbar"
|
@displayName = "FloatingToolbar"
|
||||||
|
@ -29,9 +29,11 @@ class FloatingToolbar extends React.Component
|
||||||
@state =
|
@state =
|
||||||
urlInputValue: @_initialUrl() ? ""
|
urlInputValue: @_initialUrl() ? ""
|
||||||
componentWidth: 0
|
componentWidth: 0
|
||||||
|
extensions: ExtensionRegistry.Composer.extensions()
|
||||||
|
|
||||||
componentDidMount: =>
|
componentDidMount: =>
|
||||||
@subscriptions = new CompositeDisposable()
|
@subscriptions = new CompositeDisposable()
|
||||||
|
@usubExtensions = ExtensionRegistry.Composer.listen @_onExtensionsChanged
|
||||||
|
|
||||||
componentWillReceiveProps: (nextProps) =>
|
componentWillReceiveProps: (nextProps) =>
|
||||||
@setState
|
@setState
|
||||||
|
@ -39,6 +41,7 @@ class FloatingToolbar extends React.Component
|
||||||
|
|
||||||
componentWillUnmount: =>
|
componentWillUnmount: =>
|
||||||
@subscriptions?.dispose()
|
@subscriptions?.dispose()
|
||||||
|
@usubExtensions()
|
||||||
|
|
||||||
componentDidUpdate: =>
|
componentDidUpdate: =>
|
||||||
if @props.mode is "edit-link" and not @props.linkToModify
|
if @props.mode is "edit-link" and not @props.linkToModify
|
||||||
|
@ -88,12 +91,12 @@ class FloatingToolbar extends React.Component
|
||||||
<button className="btn btn-link toolbar-btn"
|
<button className="btn btn-link toolbar-btn"
|
||||||
onClick={@props.onClickLinkEditBtn}
|
onClick={@props.onClickLinkEditBtn}
|
||||||
data-command-name="link"></button>
|
data-command-name="link"></button>
|
||||||
{@_toolbarExtensions()}
|
{@_toolbarExtensions(@state.extensions)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
_toolbarExtensions: ->
|
_toolbarExtensions: (extensions) ->
|
||||||
buttons = []
|
buttons = []
|
||||||
for extension in DraftStore.extensions()
|
for extension in extensions
|
||||||
toolbarItem = extension.composerToolbar?()
|
toolbarItem = extension.composerToolbar?()
|
||||||
if toolbarItem
|
if toolbarItem
|
||||||
buttons.push(
|
buttons.push(
|
||||||
|
@ -102,6 +105,9 @@ class FloatingToolbar extends React.Component
|
||||||
title="#{toolbarItem.tooltip}"><RetinaImg mode={RetinaImg.Mode.ContentIsMask} url="#{toolbarItem.iconUrl}" /></button>)
|
title="#{toolbarItem.tooltip}"><RetinaImg mode={RetinaImg.Mode.ContentIsMask} url="#{toolbarItem.iconUrl}" /></button>)
|
||||||
return buttons
|
return buttons
|
||||||
|
|
||||||
|
_onExtensionsChanged: =>
|
||||||
|
@setState extensions: ExtensionRegistry.Composer.extensions()
|
||||||
|
|
||||||
_extensionMutateDom: (mutator) =>
|
_extensionMutateDom: (mutator) =>
|
||||||
@props.onDomMutator(mutator)
|
@props.onDomMutator(mutator)
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
_str = require 'underscore.string'
|
_str = require 'underscore.string'
|
||||||
{DOMUtils} = require 'nylas-exports'
|
{DOMUtils, ContenteditableExtension} = require 'nylas-exports'
|
||||||
ContenteditablePlugin = require './contenteditable-plugin'
|
|
||||||
|
|
||||||
class ListManager extends ContenteditablePlugin
|
class ListManager extends ContenteditableExtension
|
||||||
@onInput: (event, editableNode, selection) ->
|
@onInput: (event, editableNode, selection) ->
|
||||||
if @_spaceEntered and @hasListStartSignature(selection)
|
if @_spaceEntered and @hasListStartSignature(selection)
|
||||||
@createList(event, selection)
|
@createList(event, selection)
|
||||||
|
|
21
src/deprecate-utils.coffee
Normal file
21
src/deprecate-utils.coffee
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
_ = require 'underscore'
|
||||||
|
|
||||||
|
class DeprecateUtils
|
||||||
|
@warn: (condition, message) ->
|
||||||
|
console.warn message if condition
|
||||||
|
|
||||||
|
@deprecate: (fnName, newName, ctx, fn) ->
|
||||||
|
if NylasEnv.inDevMode
|
||||||
|
warn = true
|
||||||
|
newFn = =>
|
||||||
|
DeprecateUtils.warn(
|
||||||
|
warn,
|
||||||
|
"Deprecation warning! #{fnName} is deprecated and will be removed soon.
|
||||||
|
Use #{newName} instead."
|
||||||
|
)
|
||||||
|
warn = false
|
||||||
|
return fn.apply(ctx, arguments)
|
||||||
|
return _.extend(newFn, fn)
|
||||||
|
return fn
|
||||||
|
|
||||||
|
module.exports = DeprecateUtils
|
63
src/extension-registry.es6
Normal file
63
src/extension-registry.es6
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
|
||||||
|
import _ from 'underscore';
|
||||||
|
import {Listener, Publisher} from './flux/modules/reflux-coffee';
|
||||||
|
import {includeModule} from './flux/coffee-helpers';
|
||||||
|
|
||||||
|
export class Registry {
|
||||||
|
|
||||||
|
static include = includeModule;
|
||||||
|
|
||||||
|
constructor(name, deprecationAdapter = (ext)=> ext) {
|
||||||
|
this.name = name;
|
||||||
|
this._deprecationAdapter = deprecationAdapter;
|
||||||
|
this._registry = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
register(extension) {
|
||||||
|
this.validateExtension(extension, 'register');
|
||||||
|
this._registry.set(extension.name, this._deprecationAdapter(extension));
|
||||||
|
this.triggerDebounced();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
unregister(extension) {
|
||||||
|
this.validateExtension(extension, 'unregister');
|
||||||
|
this._registry.delete(extension.name);
|
||||||
|
this.triggerDebounced();
|
||||||
|
}
|
||||||
|
|
||||||
|
extensions() {
|
||||||
|
return Array.from(this._registry.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this._registry = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerDebounced() {
|
||||||
|
_.debounce(()=> this.trigger(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateExtension(extension, method) {
|
||||||
|
if (!extension || Array.isArray(extension) || !_.isObject(extension)) {
|
||||||
|
throw new Error(`ExtensionRegistry.${this.name}.${method} requires a valid \\
|
||||||
|
extension object that implements one of the functions defined by ${this.name}Extension`);
|
||||||
|
}
|
||||||
|
if (!extension.name) {
|
||||||
|
throw new Error(`ExtensionRegistry.${this.name}.${method} requires a \\
|
||||||
|
\`name\` property defined on the extension object`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Registry.include(Publisher);
|
||||||
|
Registry.include(Listener);
|
||||||
|
|
||||||
|
export const Composer = new Registry(
|
||||||
|
'Composer',
|
||||||
|
require('./flux/extensions/composer-extension-adapter')
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MessageView = new Registry(
|
||||||
|
'MessageView',
|
||||||
|
);
|
|
@ -296,7 +296,7 @@ class Actions
|
||||||
|
|
||||||
###
|
###
|
||||||
Public: Send the draft with the given ID. This Action is handled by the {DraftStore},
|
Public: Send the draft with the given ID. This Action is handled by the {DraftStore},
|
||||||
which finalizes the {DraftChangeSet} and allows {DraftStoreExtension}s to display
|
which finalizes the {DraftChangeSet} and allows {ComposerExtension}s to display
|
||||||
warnings and do post-processing. To change send behavior, you should consider using
|
warnings and do post-processing. To change send behavior, you should consider using
|
||||||
one of these objects rather than listening for the {sendDraft} action.
|
one of these objects rather than listening for the {sendDraft} action.
|
||||||
|
|
||||||
|
|
51
src/flux/extensions/composer-extension-adapter.coffee
Normal file
51
src/flux/extensions/composer-extension-adapter.coffee
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
_ = require 'underscore'
|
||||||
|
{deprecate} = require '../../deprecate-utils'
|
||||||
|
DOMUtils = require '../../dom-utils'
|
||||||
|
|
||||||
|
ComposerExtensionAdapter = (extension) ->
|
||||||
|
|
||||||
|
if extension.onInput?.length <= 2
|
||||||
|
origInput = extension.onInput
|
||||||
|
extension.onInput = (event, editableNode, selection) ->
|
||||||
|
origInput(editableNode, event)
|
||||||
|
|
||||||
|
extension.onInput = deprecate(
|
||||||
|
"DraftStoreExtension.onInput",
|
||||||
|
"ComposerExtension.onInput",
|
||||||
|
extension,
|
||||||
|
extension.onInput
|
||||||
|
)
|
||||||
|
|
||||||
|
if extension.onTabDown?
|
||||||
|
origKeyDown = extension.onKeyDown
|
||||||
|
extension.onKeyDown = (event, editableNode, selection) ->
|
||||||
|
if event.key is "Tab"
|
||||||
|
range = DOMUtils.getRangeInScope(editableNode)
|
||||||
|
extension.onTabDown(editableNode, range, event)
|
||||||
|
else
|
||||||
|
origKeyDown?(event, editableNode, selection)
|
||||||
|
|
||||||
|
extension.onKeyDown = deprecate(
|
||||||
|
"DraftStoreExtension.onTabDown",
|
||||||
|
"ComposerExtension.onKeyDown",
|
||||||
|
extension,
|
||||||
|
extension.onKeyDown
|
||||||
|
)
|
||||||
|
|
||||||
|
if extension.onMouseUp?
|
||||||
|
origOnClick = extension.onClick
|
||||||
|
extension.onClick = (event, editableNode, selection) ->
|
||||||
|
range = DOMUtils.getRangeInScope(editableNode)
|
||||||
|
extension.onMouseUp(editableNode, range, event)
|
||||||
|
origOnClick?(event, editableNode, selection)
|
||||||
|
|
||||||
|
extension.onClick = deprecate(
|
||||||
|
"DraftStoreExtension.onMouseUp",
|
||||||
|
"ComposerExtension.onClick",
|
||||||
|
extension,
|
||||||
|
extension.onClick
|
||||||
|
)
|
||||||
|
|
||||||
|
return extension
|
||||||
|
|
||||||
|
module.exports = ComposerExtensionAdapter
|
109
src/flux/extensions/composer-extension.coffee
Normal file
109
src/flux/extensions/composer-extension.coffee
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
###
|
||||||
|
Public: To create ComposerExtensions that enhance the composer experience, you
|
||||||
|
should create objects that implement the interface defined at {ComposerExtension}.
|
||||||
|
|
||||||
|
{ComposerExtension} extends {ContenteditableExtension}, so you can also
|
||||||
|
implement the methods defined there to further enhance the composer
|
||||||
|
experience.
|
||||||
|
|
||||||
|
To register your extension with the ExtensionRegistry, call {ExtensionRegistry::Composer::register}.
|
||||||
|
When your package is being unloaded, you *must* call the corresponding
|
||||||
|
{ExtensionRegistry::Composer::unregister} to unhook your extension.
|
||||||
|
|
||||||
|
```coffee
|
||||||
|
activate: ->
|
||||||
|
ExtensionRegistry.Composer.register(MyExtension)
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
deactivate: ->
|
||||||
|
ExtensionRegistry.Composer.unregister(MyExtension)
|
||||||
|
```
|
||||||
|
|
||||||
|
Your ComposerExtension should be stateless. The user may have multiple drafts
|
||||||
|
open at any time, and the methods of your ComposerExtension may be called for different
|
||||||
|
drafts at any time. You should not expect that the session you receive in
|
||||||
|
{::finalizeSessionBeforeSending} is for the same draft you previously received in
|
||||||
|
{::warningsForSending}, etc.
|
||||||
|
|
||||||
|
The ComposerExtension API does not currently expose any asynchronous or {Promise}-based APIs.
|
||||||
|
This will likely change in the future. If you have a use-case for a ComposerExtension that
|
||||||
|
is not possible with the current API, please let us know.
|
||||||
|
|
||||||
|
Section: Extensions
|
||||||
|
###
|
||||||
|
class ComposerExtension
|
||||||
|
|
||||||
|
###
|
||||||
|
Public: Inspect the draft, and return any warnings that need to be displayed before
|
||||||
|
the draft is sent. Warnings should be string phrases, such as "without an attachment"
|
||||||
|
that fit into a message of the form: "Send #{phase1} and #{phase2}?"
|
||||||
|
|
||||||
|
- `draft`: A fully populated {Message} object that is about to be sent.
|
||||||
|
|
||||||
|
Returns a list of warning strings, or an empty array if no warnings need to be displayed.
|
||||||
|
###
|
||||||
|
@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. 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
|
||||||
|
to modify the draft object you're given directly to add participants to the draft,
|
||||||
|
add a signature, etc.
|
||||||
|
|
||||||
|
By default, new drafts are considered `pristine`. If the user leaves the composer
|
||||||
|
without making any changes, the draft is discarded. If your extension populates
|
||||||
|
the draft in a way that makes it "populated" in a valuable way, you should set
|
||||||
|
`draft.pristine = false` so the draft saves, even if no further changes are made.
|
||||||
|
###
|
||||||
|
@prepareNewDraft: (draft) ->
|
||||||
|
return
|
||||||
|
|
||||||
|
###
|
||||||
|
Public: Override finalizeSessionBeforeSending in your ComposerExtension subclass to
|
||||||
|
transform the {DraftStoreProxy} editing session just before the draft is sent. This method
|
||||||
|
gives you an opportunity to make any final substitutions or changes after any
|
||||||
|
{::warningsForSending} have been displayed.
|
||||||
|
|
||||||
|
- `session`: A {DraftStoreProxy} for the draft.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```coffee
|
||||||
|
# Remove any <code> tags found in the draft body
|
||||||
|
finalizeSessionBeforeSending: (session) ->
|
||||||
|
body = session.draft().body
|
||||||
|
clean = body.replace(/<\/?code[^>]*>/g, '')
|
||||||
|
if body != clean
|
||||||
|
session.changes.add(body: clean)
|
||||||
|
```
|
||||||
|
###
|
||||||
|
@finalizeSessionBeforeSending: (session) ->
|
||||||
|
return
|
||||||
|
|
||||||
|
module.exports = ComposerExtension
|
146
src/flux/extensions/contenteditable-extension.coffee
Normal file
146
src/flux/extensions/contenteditable-extension.coffee
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
###
|
||||||
|
Public: ContenteditableExtension is an abstract base class. Implementations of this
|
||||||
|
are used to make additional changes to a <Contenteditable /> component
|
||||||
|
beyond a user's input intents. The hooks in this class provide the contenteditable
|
||||||
|
DOM Node itself, allowing you to adjust selection ranges and change content
|
||||||
|
as necessary.
|
||||||
|
|
||||||
|
While some ContenteditableExtension are included with the core
|
||||||
|
<{Contenteditable} /> component, others may be added via the `plugins`
|
||||||
|
prop when you use it inside your own components.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
render() {
|
||||||
|
return(
|
||||||
|
<div>
|
||||||
|
<Contenteditable extensions={[MyAwesomeExtension]}>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If you specifically want to enhance the Composer experience you should register
|
||||||
|
a {ComposerExtension}
|
||||||
|
|
||||||
|
Section: Extensions
|
||||||
|
###
|
||||||
|
class ContenteditableExtension
|
||||||
|
|
||||||
|
###
|
||||||
|
Public: Override onInput in your Contenteditable subclass to implement custom
|
||||||
|
behavior as the user types in the contenteditable's body field. You may mutate
|
||||||
|
the contenteditable in place, we do not expect any return value from this method.
|
||||||
|
|
||||||
|
The onInput event can be triggered by a variety of events, some of which could
|
||||||
|
have been already been looked at by a callback. Almost any DOM mutation will
|
||||||
|
fire this event. Sometimes those mutations are the cause of other callbacks.
|
||||||
|
|
||||||
|
- event: DOM event fired on the contenteditable
|
||||||
|
- editableNode: DOM node that represents the current contenteditable.This object
|
||||||
|
can be mutated in place to modify the Contenteditable's content
|
||||||
|
- selection: [Selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection)
|
||||||
|
object that represents the current selection on the contenteditable
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
The Nylas `templates` package uses this method to see if the user has populated a
|
||||||
|
`<code>` tag placed in the body and change it's CSS class to reflect that it is no
|
||||||
|
longer empty.
|
||||||
|
|
||||||
|
```coffee
|
||||||
|
onInput: (event, editableNode, selection) ->
|
||||||
|
isWithinNode = (node) ->
|
||||||
|
test = selection.baseNode
|
||||||
|
while test isnt editableNode
|
||||||
|
return true if test is node
|
||||||
|
test = test.parentNode
|
||||||
|
return false
|
||||||
|
|
||||||
|
codeTags = editableNode.querySelectorAll('code.var.empty')
|
||||||
|
for codeTag in codeTags
|
||||||
|
if selection.containsNode(codeTag) or isWithinNode(codeTag)
|
||||||
|
codeTag.classList.remove('empty')
|
||||||
|
```
|
||||||
|
###
|
||||||
|
@onInput: (event, editableNode, selection) ->
|
||||||
|
|
||||||
|
###
|
||||||
|
Public: Override onBlur to mutate the contenteditable DOM node whenever the
|
||||||
|
onBlur event is fired on it. You may mutate the contenteditable in place, we
|
||||||
|
not expect any return value from this method.
|
||||||
|
|
||||||
|
- event: DOM event fired on the contenteditable
|
||||||
|
- editableNode: DOM node that represents the current contenteditable.This object
|
||||||
|
can be mutated in place to modify the Contenteditable's content
|
||||||
|
- selection: [Selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection)
|
||||||
|
object that represents the current selection on the contenteditable
|
||||||
|
###
|
||||||
|
@onBlur: (event, editableNode, selection) ->
|
||||||
|
|
||||||
|
###
|
||||||
|
Public: Override onFocus to mutate the contenteditable DOM node whenever the
|
||||||
|
onFocus event is fired on it. You may mutate the contenteditable in place, we
|
||||||
|
not expect any return value from this method.
|
||||||
|
|
||||||
|
- event: DOM event fired on the contenteditable
|
||||||
|
- editableNode: DOM node that represents the current contenteditable.This object
|
||||||
|
can be mutated in place to modify the Contenteditable's content
|
||||||
|
- selection: [Selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection)
|
||||||
|
object that represents the current selection on the contenteditable
|
||||||
|
###
|
||||||
|
@onFocus: (event, editableNode, selection) ->
|
||||||
|
|
||||||
|
###
|
||||||
|
Public: Override onClick to mutate the contenteditable DOM node whenever the
|
||||||
|
onClick event is fired on it. You may mutate the contenteditable in place, we
|
||||||
|
not expect any return value from this method.
|
||||||
|
|
||||||
|
- event: DOM event fired on the contenteditable
|
||||||
|
- editableNode: DOM node that represents the current contenteditable.This object
|
||||||
|
can be mutated in place to modify the Contenteditable's content
|
||||||
|
- selection: [Selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection)
|
||||||
|
object that represents the current selection on the contenteditable
|
||||||
|
###
|
||||||
|
@onClick: (event, editableNode, selection) ->
|
||||||
|
|
||||||
|
###
|
||||||
|
Public: Override onKeyDown to mutate the contenteditable DOM node whenever the
|
||||||
|
onKeyDown event is fired on it.
|
||||||
|
Public: Called when the user presses a key while focused on the contenteditable's body field.
|
||||||
|
Override onKeyDown in your ContenteditableExtension to adjust the selection or
|
||||||
|
perform other actions.
|
||||||
|
|
||||||
|
If your package implements key down behavior for a particular scenario, you
|
||||||
|
should prevent the default behavior of the key via `event.preventDefault()`.
|
||||||
|
You may mutate the contenteditable in place, we not expect any return value
|
||||||
|
from this method.
|
||||||
|
|
||||||
|
Important: You should prevent the default key down behavior with great care.
|
||||||
|
|
||||||
|
- event: DOM event fired on the contenteditable
|
||||||
|
- editableNode: DOM node that represents the current contenteditable.This object
|
||||||
|
can be mutated in place to modify the Contenteditable's content
|
||||||
|
- selection: [Selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection)
|
||||||
|
object that represents the current selection on the contenteditable
|
||||||
|
###
|
||||||
|
@onKeyDown: (event, editableNode, selection) ->
|
||||||
|
|
||||||
|
###
|
||||||
|
Public: Override onInput to mutate the contenteditable DOM node whenever the
|
||||||
|
onInput event is fired on it.You may mutate the contenteditable in place, we
|
||||||
|
not expect any return value from this method.
|
||||||
|
|
||||||
|
- event: DOM event fired on the contenteditable
|
||||||
|
- editableNode: DOM node that represents the current contenteditable.This object
|
||||||
|
can be mutated in place to modify the Contenteditable's content
|
||||||
|
- selection: [Selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection)
|
||||||
|
object that represents the current selection on the contenteditable
|
||||||
|
- menu: [Menu](https://github.com/atom/electron/blob/master/docs/api/menu.md)
|
||||||
|
object you can mutate in order to add new [MenuItems](https://github.com/atom/electron/blob/master/docs/api/menu-item.md)
|
||||||
|
to the context menu that will be displayed when you right click the contenteditable.
|
||||||
|
###
|
||||||
|
@onShowContextMenu: (event, editableNode, selection, menu) ->
|
||||||
|
|
||||||
|
module.exports = ContenteditableExtension
|
34
src/flux/extensions/message-view-extension.coffee
Normal file
34
src/flux/extensions/message-view-extension.coffee
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
###
|
||||||
|
Public: To create MessageViewExtension that customize message viewing, you
|
||||||
|
should create objects that implement the interface defined at {MessageViewExtension}.
|
||||||
|
|
||||||
|
To register your extension with the ExtensionRegistry, call {ExtensionRegistry::MessageView::registerExtension}.
|
||||||
|
When your package is being unloaded, you *must* call the corresponding
|
||||||
|
{ExtensionRegistry::MessageView::unregisterExtension} to unhook your extension.
|
||||||
|
|
||||||
|
```coffee
|
||||||
|
activate: ->
|
||||||
|
ExtensionRegistry.MessageView.register(MyExtension)
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
deactivate: ->
|
||||||
|
ExtensionRegistry.MessageView.unregister(MyExtension)
|
||||||
|
```
|
||||||
|
|
||||||
|
The MessageViewExtension API does not currently expose any asynchronous or {Promise}-based APIs.
|
||||||
|
This will likely change in the future. If you have a use-case for a Message Store extension that
|
||||||
|
is not possible with the current API, please let us know.
|
||||||
|
|
||||||
|
Section: Extensions
|
||||||
|
###
|
||||||
|
class MessageViewExtension
|
||||||
|
|
||||||
|
###
|
||||||
|
Public: Transform the message body HTML provided in `body` and return HTML
|
||||||
|
that should be displayed for the message.
|
||||||
|
###
|
||||||
|
@formatMessageBody: (body) ->
|
||||||
|
return body
|
||||||
|
|
||||||
|
module.exports = MessageViewExtension
|
|
@ -1,174 +1,7 @@
|
||||||
###
|
###
|
||||||
Public: DraftStoreExtension is an abstract base class. To create DraftStoreExtensions
|
Public: DraftStoreExtension is deprecated. Use {ComposerExtension} instead.
|
||||||
that enhance the composer experience, you should subclass {DraftStoreExtension} and
|
Section: Extensions
|
||||||
implement the class methods your plugin needs.
|
|
||||||
|
|
||||||
To register your extension with the DraftStore, call {DraftStore::registerExtension}.
|
|
||||||
When your package is being unloaded, you *must* call the corresponding
|
|
||||||
{DraftStore::unregisterExtension} to unhook your extension.
|
|
||||||
|
|
||||||
```coffee
|
|
||||||
activate: ->
|
|
||||||
DraftStore.registerExtension(MyExtension)
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
deactivate: ->
|
|
||||||
DraftStore.unregisterExtension(MyExtension)
|
|
||||||
```
|
|
||||||
|
|
||||||
Your DraftStoreExtension subclass should be stateless. The user may have multiple drafts
|
|
||||||
open at any time, and the methods of your DraftStoreExtension may be called for different
|
|
||||||
drafts at any time. You should not expect that the session you receive in
|
|
||||||
{::finalizeSessionBeforeSending} is for the same draft you previously received in
|
|
||||||
{::warningsForSending}, etc.
|
|
||||||
|
|
||||||
The DraftStoreExtension API does not currently expose any asynchronous or {Promise}-based APIs.
|
|
||||||
This will likely change in the future. If you have a use-case for a Draft Store extension that
|
|
||||||
is not possible with the current API, please let us know.
|
|
||||||
|
|
||||||
Section: Drafts
|
|
||||||
###
|
###
|
||||||
class DraftStoreExtension
|
class DraftStoreExtension
|
||||||
|
|
||||||
###
|
|
||||||
Public: Inspect the draft, and return any warnings that need to be displayed before
|
|
||||||
the draft is sent. Warnings should be string phrases, such as "without an attachment"
|
|
||||||
that fit into a message of the form: "Send #{phase1} and #{phase2}?"
|
|
||||||
|
|
||||||
- `draft`: A fully populated {Message} object that is about to be sent.
|
|
||||||
|
|
||||||
Returns a list of warning strings, or an empty array if no warnings need to be displayed.
|
|
||||||
###
|
|
||||||
@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
|
|
||||||
to modify the draft object you're given directly to add participants to the draft,
|
|
||||||
add a signature, etc.
|
|
||||||
|
|
||||||
By default, new drafts are considered `pristine`. If the user leaves the composer
|
|
||||||
without making any changes, the draft is discarded. If your extension populates
|
|
||||||
the draft in a way that makes it "populated" in a valuable way, you should set
|
|
||||||
`draft.pristine = false` so the draft saves, even if no further changes are made.
|
|
||||||
###
|
|
||||||
@prepareNewDraft: (draft) ->
|
|
||||||
return
|
|
||||||
|
|
||||||
###
|
|
||||||
Public: Override finalizeSessionBeforeSending in your DraftStoreExtension subclass to transform
|
|
||||||
the {DraftStoreProxy} editing session just before the draft is sent. This method
|
|
||||||
gives you an opportunity to make any final substitutions or changes after any
|
|
||||||
{::warningsForSending} have been displayed.
|
|
||||||
|
|
||||||
- `session`: A {DraftStoreProxy} for the draft.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```coffee
|
|
||||||
# Remove any <code> tags found in the draft body
|
|
||||||
finalizeSessionBeforeSending: (session) ->
|
|
||||||
body = session.draft().body
|
|
||||||
clean = body.replace(/<\/?code[^>]*>/g, '')
|
|
||||||
if body != clean
|
|
||||||
session.changes.add(body: clean)
|
|
||||||
```
|
|
||||||
###
|
|
||||||
@finalizeSessionBeforeSending: (session) ->
|
|
||||||
return
|
|
||||||
|
|
||||||
###
|
|
||||||
Public: Override onMouseUp in your DraftStoreExtension subclass to
|
|
||||||
listen for mouse up events sent to the composer's body text area. This
|
|
||||||
hook provides the contenteditable DOM Node itself, allowing you to
|
|
||||||
adjust selection ranges and change content as necessary.
|
|
||||||
|
|
||||||
- `editableNode` The composer's contenteditable {Node}
|
|
||||||
that received the event.
|
|
||||||
|
|
||||||
- `range`: The currently selected {Range} in the `editableNode`
|
|
||||||
|
|
||||||
- `event`: The mouse up event.
|
|
||||||
###
|
|
||||||
@onMouseUp: (editableNode, range, event) ->
|
|
||||||
return
|
|
||||||
|
|
||||||
###
|
|
||||||
Public: Called when the user presses `Tab` while focused on the composer's body field.
|
|
||||||
Override onTabDown in your DraftStoreExtension to adjust the selection or perform
|
|
||||||
other actions. If your package implements Tab behavior in a particular scenario, you
|
|
||||||
should prevent the default behavior of Tab via `event.preventDefault()`.
|
|
||||||
|
|
||||||
Important: You should prevent the default tab behavior with great care.
|
|
||||||
|
|
||||||
- `editableNode` The composer's contenteditable {Node} that received the event.
|
|
||||||
|
|
||||||
- `range`: The currently selected {Range} in the `editableNode`
|
|
||||||
|
|
||||||
- `event`: The mouse up event.
|
|
||||||
|
|
||||||
###
|
|
||||||
@onTabDown: (editableNode, range, event) ->
|
|
||||||
return
|
|
||||||
|
|
||||||
###
|
|
||||||
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:
|
|
||||||
|
|
||||||
The Nylas `templates` package uses this method to see if the user has populated a
|
|
||||||
`<code>` tag placed in the body and change it's CSS class to reflect that it is no
|
|
||||||
longer empty.
|
|
||||||
|
|
||||||
```coffee
|
|
||||||
onInput: (editableNode, event) ->
|
|
||||||
selection = document.getSelection()
|
|
||||||
|
|
||||||
isWithinNode = (node) ->
|
|
||||||
test = selection.baseNode
|
|
||||||
while test isnt editableNode
|
|
||||||
return true if test is node
|
|
||||||
test = test.parentNode
|
|
||||||
return false
|
|
||||||
|
|
||||||
codeTags = editableNode.querySelectorAll('code.var.empty')
|
|
||||||
for codeTag in codeTags
|
|
||||||
if selection.containsNode(codeTag) or isWithinNode(codeTag)
|
|
||||||
codeTag.classList.remove('empty')
|
|
||||||
```
|
|
||||||
|
|
||||||
###
|
|
||||||
@onInput: (editableNode, event) ->
|
|
||||||
return
|
|
||||||
|
|
||||||
module.exports = DraftStoreExtension
|
module.exports = DraftStoreExtension
|
||||||
|
|
|
@ -29,6 +29,9 @@ CoffeeHelpers = require '../coffee-helpers'
|
||||||
DOMUtils = require '../../dom-utils'
|
DOMUtils = require '../../dom-utils'
|
||||||
RegExpUtils = require '../../regexp-utils'
|
RegExpUtils = require '../../regexp-utils'
|
||||||
|
|
||||||
|
ExtensionRegistry = require '../../extension-registry'
|
||||||
|
{deprecate} = require '../../deprecate-utils'
|
||||||
|
|
||||||
###
|
###
|
||||||
Public: DraftStore responds to Actions that interact with Drafts and exposes
|
Public: DraftStore responds to Actions that interact with Drafts and exposes
|
||||||
public getter methods to return Draft objects and sessions.
|
public getter methods to return Draft objects and sessions.
|
||||||
|
@ -72,7 +75,6 @@ class DraftStore
|
||||||
NylasEnv.onBeforeUnload @_onBeforeUnload
|
NylasEnv.onBeforeUnload @_onBeforeUnload
|
||||||
|
|
||||||
@_draftSessions = {}
|
@_draftSessions = {}
|
||||||
@_extensions = []
|
|
||||||
|
|
||||||
@_inlineStylePromises = {}
|
@_inlineStylePromises = {}
|
||||||
@_inlineStyleResolvers = {}
|
@_inlineStyleResolvers = {}
|
||||||
|
@ -131,23 +133,25 @@ class DraftStore
|
||||||
|
|
||||||
# Public: Returns the extensions registered with the DraftStore.
|
# Public: Returns the extensions registered with the DraftStore.
|
||||||
extensions: =>
|
extensions: =>
|
||||||
@_extensions
|
ExtensionRegistry.Composer.extensions()
|
||||||
|
|
||||||
# Public: Registers a new extension with the DraftStore. DraftStore extensions
|
# Public: Deprecated, use {ExtensionRegistry.Composer.register} instead.
|
||||||
|
# Registers a new extension with the DraftStore. DraftStore extensions
|
||||||
# make it possible to extend the editor experience, modify draft contents,
|
# make it possible to extend the editor experience, modify draft contents,
|
||||||
# display warnings before draft are sent, and more.
|
# display warnings before draft are sent, and more.
|
||||||
#
|
#
|
||||||
# - `ext` A {DraftStoreExtension} instance.
|
# - `ext` A {ComposerExtension} instance.
|
||||||
#
|
#
|
||||||
registerExtension: (ext) =>
|
registerExtension: (ext) =>
|
||||||
@_extensions.push(ext)
|
ExtensionRegistry.Composer.register(ext)
|
||||||
|
|
||||||
# Public: Unregisters the extension provided from the DraftStore.
|
# Public: Deprecated, use {ExtensionRegistry.Composer.unregister} instead.
|
||||||
|
# Unregisters the extension provided from the DraftStore.
|
||||||
#
|
#
|
||||||
# - `ext` A {DraftStoreExtension} instance.
|
# - `ext` A {ComposerExtension} instance.
|
||||||
#
|
#
|
||||||
unregisterExtension: (ext) =>
|
unregisterExtension: (ext) =>
|
||||||
@_extensions = _.without(@_extensions, ext)
|
ExtensionRegistry.Composer.unregister(ext)
|
||||||
|
|
||||||
########### PRIVATE ####################################################
|
########### PRIVATE ####################################################
|
||||||
|
|
||||||
|
@ -228,7 +232,7 @@ class DraftStore
|
||||||
|
|
||||||
_finalizeAndPersistNewMessage: (draft) =>
|
_finalizeAndPersistNewMessage: (draft) =>
|
||||||
# Give extensions an opportunity to perform additional setup to the draft
|
# Give extensions an opportunity to perform additional setup to the draft
|
||||||
for extension in @_extensions
|
for extension in @extensions()
|
||||||
continue unless extension.prepareNewDraft
|
continue unless extension.prepareNewDraft
|
||||||
extension.prepareNewDraft(draft)
|
extension.prepareNewDraft(draft)
|
||||||
|
|
||||||
|
@ -516,7 +520,7 @@ class DraftStore
|
||||||
|
|
||||||
# Give third-party plugins an opportunity to sanitize draft data
|
# Give third-party plugins an opportunity to sanitize draft data
|
||||||
_runExtensionsBeforeSend: (session) ->
|
_runExtensionsBeforeSend: (session) ->
|
||||||
for extension in @_extensions
|
for extension in @extensions()
|
||||||
continue unless extension.finalizeSessionBeforeSending
|
continue unless extension.finalizeSessionBeforeSending
|
||||||
extension.finalizeSessionBeforeSending(session)
|
extension.finalizeSessionBeforeSending(session)
|
||||||
|
|
||||||
|
@ -537,4 +541,19 @@ class DraftStore
|
||||||
NylasEnv.showErrorDialog(errorMessage)
|
NylasEnv.showErrorDialog(errorMessage)
|
||||||
, 100
|
, 100
|
||||||
|
|
||||||
module.exports = new DraftStore()
|
|
||||||
|
# Deprecations
|
||||||
|
store = new DraftStore()
|
||||||
|
store.registerExtension = deprecate(
|
||||||
|
'DraftStore.registerExtension',
|
||||||
|
'ExtensionRegistry.Composer.register',
|
||||||
|
store,
|
||||||
|
store.registerExtension
|
||||||
|
)
|
||||||
|
store.unregisterExtension = deprecate(
|
||||||
|
'DraftStore.unregisterExtension',
|
||||||
|
'ExtensionRegistry.Composer.unregister',
|
||||||
|
store,
|
||||||
|
store.unregisterExtension
|
||||||
|
)
|
||||||
|
module.exports = store
|
||||||
|
|
|
@ -1,35 +1,7 @@
|
||||||
###
|
###
|
||||||
Public: MessageStoreExtension is an abstract base class. To create MessageStoreExtension
|
Public: MessageStoreExtension is deprecated. Use {MessageViewExtension} instead.
|
||||||
that customize message viewing, you should subclass {MessageStoreExtension} and
|
Section: Extensions
|
||||||
implement the class methods your plugin needs.
|
|
||||||
|
|
||||||
To register your extension with the MessageStore, call {MessageStore::registerExtension}.
|
|
||||||
When your package is being unloaded, you *must* call the corresponding
|
|
||||||
{MessageStore::unregisterExtension} to unhook your extension.
|
|
||||||
|
|
||||||
```coffee
|
|
||||||
activate: ->
|
|
||||||
MessageStore.registerExtension(MyExtension)
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
deactivate: ->
|
|
||||||
MessageStore.unregisterExtension(MyExtension)
|
|
||||||
```
|
|
||||||
|
|
||||||
The MessageStoreExtension API does not currently expose any asynchronous or {Promise}-based APIs.
|
|
||||||
This will likely change in the future. If you have a use-case for a Message Store extension that
|
|
||||||
is not possible with the current API, please let us know.
|
|
||||||
|
|
||||||
Section: Stores
|
|
||||||
###
|
###
|
||||||
class MessageStoreExtension
|
class MessageStoreExtension
|
||||||
|
|
||||||
###
|
|
||||||
Public: Transform the message body HTML provided in `body` and return HTML
|
|
||||||
that should be displayed for the message.
|
|
||||||
###
|
|
||||||
@formatMessageBody: (body) ->
|
|
||||||
return body
|
|
||||||
|
|
||||||
module.exports = MessageStoreExtension
|
module.exports = MessageStoreExtension
|
||||||
|
|
|
@ -8,6 +8,8 @@ AccountStore = require "./account-store"
|
||||||
FocusedContentStore = require "./focused-content-store"
|
FocusedContentStore = require "./focused-content-store"
|
||||||
ChangeUnreadTask = require '../tasks/change-unread-task'
|
ChangeUnreadTask = require '../tasks/change-unread-task'
|
||||||
NylasAPI = require '../nylas-api'
|
NylasAPI = require '../nylas-api'
|
||||||
|
ExtensionRegistry = require '../../extension-registry'
|
||||||
|
{deprecate} = require '../../deprecate-utils'
|
||||||
async = require 'async'
|
async = require 'async'
|
||||||
_ = require 'underscore'
|
_ = require 'underscore'
|
||||||
|
|
||||||
|
@ -43,25 +45,27 @@ class MessageStore extends NylasStore
|
||||||
|
|
||||||
# Public: Returns the extensions registered with the MessageStore.
|
# Public: Returns the extensions registered with the MessageStore.
|
||||||
extensions: =>
|
extensions: =>
|
||||||
@_extensions
|
ExtensionRegistry.MessageView.extensions()
|
||||||
|
|
||||||
# Public: Registers a new extension with the MessageStore. MessageStore extensions
|
# Public: Deprecated, use {ExtensionRegistry.MessageView.register} instead.
|
||||||
|
# Registers a new extension with the MessageStore. MessageStore extensions
|
||||||
# make it possible to customize message body parsing, and will do more in
|
# make it possible to customize message body parsing, and will do more in
|
||||||
# the future.
|
# the future.
|
||||||
#
|
#
|
||||||
# - `ext` A {MessageStoreExtension} instance.
|
# - `ext` A {MessageViewExtension} instance.
|
||||||
#
|
#
|
||||||
registerExtension: (ext) =>
|
registerExtension: (ext) =>
|
||||||
@_extensions.push(ext)
|
ExtensionRegistry.MessageView.register(ext)
|
||||||
MessageBodyProcessor = require './message-body-processor'
|
|
||||||
MessageBodyProcessor.resetCache()
|
|
||||||
|
|
||||||
# Public: Unregisters the extension provided from the MessageStore.
|
# Public: Deprecated, use {ExtensionRegistry.MessageView.unregister} instead.
|
||||||
|
# Unregisters the extension provided from the MessageStore.
|
||||||
#
|
#
|
||||||
# - `ext` A {MessageStoreExtension} instance.
|
# - `ext` A {MessageViewExtension} instance.
|
||||||
#
|
#
|
||||||
unregisterExtension: (ext) =>
|
unregisterExtension: (ext) =>
|
||||||
@_extensions = _.without(@_extensions, ext)
|
ExtensionRegistry.MessageView.unregister(ext)
|
||||||
|
|
||||||
|
_onExtensionsChanged: (role) ->
|
||||||
MessageBodyProcessor = require './message-body-processor'
|
MessageBodyProcessor = require './message-body-processor'
|
||||||
MessageBodyProcessor.resetCache()
|
MessageBodyProcessor.resetCache()
|
||||||
|
|
||||||
|
@ -73,10 +77,10 @@ class MessageStore extends NylasStore
|
||||||
@_itemsExpanded = {}
|
@_itemsExpanded = {}
|
||||||
@_itemsLoading = false
|
@_itemsLoading = false
|
||||||
@_thread = null
|
@_thread = null
|
||||||
@_extensions = []
|
|
||||||
@_inflight = {}
|
@_inflight = {}
|
||||||
|
|
||||||
_registerListeners: ->
|
_registerListeners: ->
|
||||||
|
@listenTo ExtensionRegistry.MessageView, @_onExtensionsChanged
|
||||||
@listenTo DatabaseStore, @_onDataChanged
|
@listenTo DatabaseStore, @_onDataChanged
|
||||||
@listenTo FocusedContentStore, @_onFocusChanged
|
@listenTo FocusedContentStore, @_onFocusChanged
|
||||||
@listenTo Actions.toggleMessageIdExpanded, @_onToggleMessageIdExpanded
|
@listenTo Actions.toggleMessageIdExpanded, @_onToggleMessageIdExpanded
|
||||||
|
@ -287,4 +291,17 @@ class MessageStore extends NylasStore
|
||||||
|
|
||||||
items
|
items
|
||||||
|
|
||||||
module.exports = new MessageStore()
|
store = new MessageStore()
|
||||||
|
store.registerExtension = deprecate(
|
||||||
|
'MessageStore.registerExtension',
|
||||||
|
'ExtensionRegistry.MessageView.register',
|
||||||
|
store,
|
||||||
|
store.registerExtension
|
||||||
|
)
|
||||||
|
store.unregisterExtension = deprecate(
|
||||||
|
'MessageStore.unregisterExtension',
|
||||||
|
'ExtensionRegistry.MessageView.unregister',
|
||||||
|
store,
|
||||||
|
store.unregisterExtension
|
||||||
|
)
|
||||||
|
module.exports = store
|
||||||
|
|
|
@ -33,6 +33,15 @@ class NylasExports
|
||||||
NylasExports.registerSerializable(exported)
|
NylasExports.registerSerializable(exported)
|
||||||
@[prop] = exported
|
@[prop] = exported
|
||||||
|
|
||||||
|
@requireDeprecated = (prop, path, {instead} = {}) ->
|
||||||
|
{deprecate} = require '../deprecate-utils'
|
||||||
|
Object.defineProperty @, prop,
|
||||||
|
get: deprecate prop, instead, @, ->
|
||||||
|
exported = require "../#{path}"
|
||||||
|
NylasExports.registerSerializable(exported)
|
||||||
|
return exported
|
||||||
|
enumerable: true
|
||||||
|
|
||||||
# Actions
|
# Actions
|
||||||
@load "Actions", 'flux/actions'
|
@load "Actions", 'flux/actions'
|
||||||
|
|
||||||
|
@ -108,14 +117,24 @@ class NylasExports
|
||||||
@require "ThreadCountsStore", 'flux/stores/thread-counts-store'
|
@require "ThreadCountsStore", 'flux/stores/thread-counts-store'
|
||||||
@require "UnreadBadgeStore", 'flux/stores/unread-badge-store'
|
@require "UnreadBadgeStore", 'flux/stores/unread-badge-store'
|
||||||
@require "FileDownloadStore", 'flux/stores/file-download-store'
|
@require "FileDownloadStore", 'flux/stores/file-download-store'
|
||||||
@require "DraftStoreExtension", 'flux/stores/draft-store-extension'
|
|
||||||
@require "FocusedContentStore", 'flux/stores/focused-content-store'
|
@require "FocusedContentStore", 'flux/stores/focused-content-store'
|
||||||
@require "FocusedMailViewStore", 'flux/stores/focused-mail-view-store'
|
@require "FocusedMailViewStore", 'flux/stores/focused-mail-view-store'
|
||||||
@require "FocusedContactsStore", 'flux/stores/focused-contacts-store'
|
@require "FocusedContactsStore", 'flux/stores/focused-contacts-store'
|
||||||
@require "MessageBodyProcessor", 'flux/stores/message-body-processor'
|
@require "MessageBodyProcessor", 'flux/stores/message-body-processor'
|
||||||
@require "MessageStoreExtension", 'flux/stores/message-store-extension'
|
|
||||||
@require "PreferencesUIStore", 'flux/stores/preferences-ui-store'
|
@require "PreferencesUIStore", 'flux/stores/preferences-ui-store'
|
||||||
|
|
||||||
|
# Deprecated
|
||||||
|
@requireDeprecated "DraftStoreExtension", 'flux/stores/draft-store-extension',
|
||||||
|
instead: 'ComposerExtension'
|
||||||
|
@requireDeprecated "MessageStoreExtension", 'flux/stores/message-store-extension',
|
||||||
|
instead: 'MessageViewExtension'
|
||||||
|
|
||||||
|
# Extensions
|
||||||
|
@require "ExtensionRegistry", 'extension-registry'
|
||||||
|
@require "ContenteditableExtension", 'flux/extensions/contenteditable-extension'
|
||||||
|
@require "ComposerExtension", 'flux/extensions/composer-extension'
|
||||||
|
@require "MessageViewExtension", 'flux/extensions/message-view-extension'
|
||||||
|
|
||||||
# React Components
|
# React Components
|
||||||
@get "React", -> require 'react' # Our version of React for 3rd party use
|
@get "React", -> require 'react' # Our version of React for 3rd party use
|
||||||
@load "ReactRemote", 'react-remote/react-remote-parent'
|
@load "ReactRemote", 'react-remote/react-remote-parent'
|
||||||
|
@ -146,9 +165,6 @@ class NylasExports
|
||||||
@load "BufferedProcess", 'buffered-process'
|
@load "BufferedProcess", 'buffered-process'
|
||||||
@get "APMWrapper", -> require('../apm-wrapper')
|
@get "APMWrapper", -> require('../apm-wrapper')
|
||||||
|
|
||||||
# Contenteditable
|
|
||||||
@load "ContenteditablePlugin", 'components/contenteditable/contenteditable-plugin'
|
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
@get "NylasTestUtils", -> require '../../spec/nylas-test-utils'
|
@get "NylasTestUtils", -> require '../../spec/nylas-test-utils'
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue