From cfdc401c54028f09ea9d4a81f8556cc24418e6e4 Mon Sep 17 00:00:00 2001 From: Juan Tejada Date: Fri, 27 Nov 2015 11:49:24 -0800 Subject: [PATCH] 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 --- ...oreExtensions.md => ComposerExtensions.md} | 8 +- .../lib/template-draft-extension.es6 | 1 - .../composer-signature/lib/main.coffee | 8 +- ...ee => signature-composer-extension.coffee} | 6 +- ... signature-composer-extension-spec.coffee} | 10 +- .../composer-spellcheck/lib/main.coffee | 8 +- ...e => spellcheck-composer-extension.coffee} | 12 +- ...spellcheck-composer-extension-spec.coffee} | 12 +- .../lib/composer-extensions-plugin.coffee | 27 --- .../composer/lib/composer-view.cjsx | 14 +- .../lib/autoload-images-extension.coffee | 4 +- .../message-autoload-images/lib/main.coffee | 6 +- internal_packages/message-list/lib/main.cjsx | 10 +- .../lib/plugins/autolinker-extension.coffee | 4 +- .../plugins/tracking-pixels-extension.coffee | 4 +- spec/extension-registry-spec.coffee | 63 +++++++ spec/stores/draft-store-spec.coffee | 4 +- src/component-registry.coffee | 1 - .../contenteditable-plugin.coffee | 26 --- .../contenteditable/contenteditable.cjsx | 69 ++++--- .../contenteditable/floating-toolbar.cjsx | 14 +- .../contenteditable/list-manager.coffee | 5 +- src/deprecate-utils.coffee | 21 +++ src/extension-registry.es6 | 63 +++++++ src/flux/actions.coffee | 2 +- .../composer-extension-adapter.coffee | 51 ++++++ src/flux/extensions/composer-extension.coffee | 109 +++++++++++ .../contenteditable-extension.coffee | 146 +++++++++++++++ .../extensions/message-view-extension.coffee | 34 ++++ src/flux/stores/draft-store-extension.coffee | 171 +----------------- src/flux/stores/draft-store.coffee | 41 +++-- .../stores/message-store-extension.coffee | 32 +--- src/flux/stores/message-store.coffee | 39 ++-- src/global/nylas-exports.coffee | 26 ++- 34 files changed, 682 insertions(+), 369 deletions(-) rename docs/{DraftStoreExtensions.md => ComposerExtensions.md} (82%) rename internal_packages/composer-signature/lib/{signature-draft-extension.coffee => signature-composer-extension.coffee} (70%) rename internal_packages/composer-signature/spec/{draft-extension-spec.coffee => signature-composer-extension-spec.coffee} (81%) rename internal_packages/composer-spellcheck/lib/{draft-extension.coffee => spellcheck-composer-extension.coffee} (92%) rename internal_packages/composer-spellcheck/spec/{draft-extension-spec.coffee => spellcheck-composer-extension-spec.coffee} (73%) delete mode 100644 internal_packages/composer/lib/composer-extensions-plugin.coffee create mode 100644 spec/extension-registry-spec.coffee delete mode 100644 src/components/contenteditable/contenteditable-plugin.coffee create mode 100644 src/deprecate-utils.coffee create mode 100644 src/extension-registry.es6 create mode 100644 src/flux/extensions/composer-extension-adapter.coffee create mode 100644 src/flux/extensions/composer-extension.coffee create mode 100644 src/flux/extensions/contenteditable-extension.coffee create mode 100644 src/flux/extensions/message-view-extension.coffee diff --git a/docs/DraftStoreExtensions.md b/docs/ComposerExtensions.md similarity index 82% rename from docs/DraftStoreExtensions.md rename to docs/ComposerExtensions.md index 0c1c78327..15efc87e3 100644 --- a/docs/DraftStoreExtensions.md +++ b/docs/ComposerExtensions.md @@ -14,18 +14,18 @@ This API allows your package to: - 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 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 -{DraftStoreExtension} = require 'nylas-exports' +{ComposerExtension} = require 'nylas-exports' -class ProductsExtension extends DraftStoreExtension +class ProductsExtension extends ComposerExtension @warningsForSending: (draft) -> words = ['acme', 'anvil', 'tunnel', 'rocket', 'dynamite'] diff --git a/examples/N1-Composer-Templates/lib/template-draft-extension.es6 b/examples/N1-Composer-Templates/lib/template-draft-extension.es6 index 3d7bdc976..2ace59802 100644 --- a/examples/N1-Composer-Templates/lib/template-draft-extension.es6 +++ b/examples/N1-Composer-Templates/lib/template-draft-extension.es6 @@ -43,7 +43,6 @@ class TemplatesDraftStoreExtension extends DraftStoreExtension { } static onTabDown(editableNode, range, event) { - console.log('tabbed'); if (event.shiftKey) { return this.onTabSelectNextVar(editableNode, range, event, -1); } diff --git a/internal_packages/composer-signature/lib/main.coffee b/internal_packages/composer-signature/lib/main.coffee index f9ab91895..7e199d920 100644 --- a/internal_packages/composer-signature/lib/main.coffee +++ b/internal_packages/composer-signature/lib/main.coffee @@ -1,5 +1,5 @@ -{PreferencesUIStore, DraftStore} = require 'nylas-exports' -SignatureDraftExtension = require './signature-draft-extension' +{PreferencesUIStore, ExtensionRegistry} = require 'nylas-exports' +SignatureComposerExtension = require './signature-composer-extension' module.exports = activate: (@state={}) -> @@ -8,11 +8,11 @@ module.exports = displayName: "Signatures" component: require "./preferences-signatures" - DraftStore.registerExtension(SignatureDraftExtension) + ExtensionRegistry.Composer.register(SignatureComposerExtension) PreferencesUIStore.registerPreferencesTab(@preferencesTab) deactivate: -> - DraftStore.unregisterExtension(SignatureDraftExtension) + ExtensionRegistry.Composer.unregister(SignatureComposerExtension) PreferencesUIStore.unregisterPreferencesTab(@preferencesTab.sectionId) serialize: -> @state diff --git a/internal_packages/composer-signature/lib/signature-draft-extension.coffee b/internal_packages/composer-signature/lib/signature-composer-extension.coffee similarity index 70% rename from internal_packages/composer-signature/lib/signature-draft-extension.coffee rename to internal_packages/composer-signature/lib/signature-composer-extension.coffee index 288884e40..b34d37128 100644 --- a/internal_packages/composer-signature/lib/signature-draft-extension.coffee +++ b/internal_packages/composer-signature/lib/signature-composer-extension.coffee @@ -1,6 +1,6 @@ -{DraftStoreExtension, AccountStore} = require 'nylas-exports' +{ComposerExtension, AccountStore} = require 'nylas-exports' -class SignatureDraftStoreExtension extends DraftStoreExtension +class SignatureComposerExtension extends ComposerExtension @prepareNewDraft: (draft) -> accountId = AccountStore.current().id signature = NylasEnv.config.get("nylas.account-#{accountId}.signature") @@ -11,4 +11,4 @@ class SignatureDraftStoreExtension extends DraftStoreExtension insertionPoint = draft.body.length draft.body = draft.body.substr(0, insertionPoint-1) + "
" + signature + draft.body.substr(insertionPoint) -module.exports = SignatureDraftStoreExtension +module.exports = SignatureComposerExtension diff --git a/internal_packages/composer-signature/spec/draft-extension-spec.coffee b/internal_packages/composer-signature/spec/signature-composer-extension-spec.coffee similarity index 81% rename from internal_packages/composer-signature/spec/draft-extension-spec.coffee rename to internal_packages/composer-signature/spec/signature-composer-extension-spec.coffee index 1d783f59e..20feba5b4 100644 --- a/internal_packages/composer-signature/spec/draft-extension-spec.coffee +++ b/internal_packages/composer-signature/spec/signature-composer-extension-spec.coffee @@ -1,8 +1,8 @@ {Message} = require 'nylas-exports' -SignatureDraftStoreExtension = require '../lib/signature-draft-extension' +SignatureComposerExtension = require '../lib/signature-composer-extension' -describe "SignatureDraftStoreExtension", -> +describe "SignatureComposerExtension", -> describe "prepareNewDraft", -> describe "when a signature is defined", -> beforeEach -> @@ -18,9 +18,9 @@ describe "SignatureDraftStoreExtension", -> draft: true body: 'This is a another test.' - SignatureDraftStoreExtension.prepareNewDraft(a) + SignatureComposerExtension.prepareNewDraft(a) expect(a.body).toEqual("This is a test!
This is my signature.
Hello world
") - SignatureDraftStoreExtension.prepareNewDraft(b) + SignatureComposerExtension.prepareNewDraft(b) expect(b.body).toEqual("This is a another test
This is my signature.
") describe "when a signature is not defined", -> @@ -32,5 +32,5 @@ describe "SignatureDraftStoreExtension", -> a = new Message draft: true body: 'This is a test!
Hello world
' - SignatureDraftStoreExtension.prepareNewDraft(a) + SignatureComposerExtension.prepareNewDraft(a) expect(a.body).toEqual('This is a test!
Hello world
') diff --git a/internal_packages/composer-spellcheck/lib/main.coffee b/internal_packages/composer-spellcheck/lib/main.coffee index 9572b04a9..a3d4fa341 100644 --- a/internal_packages/composer-spellcheck/lib/main.coffee +++ b/internal_packages/composer-spellcheck/lib/main.coffee @@ -1,11 +1,11 @@ -{ComponentRegistry, DraftStore} = require 'nylas-exports' -Extension = require './draft-extension' +{ExtensionRegistry} = require 'nylas-exports' +SpellcheckComposerExtension = require './spellcheck-composer-extension' module.exports = activate: (@state={}) -> - DraftStore.registerExtension(Extension) + ExtensionRegistry.Composer.register(SpellcheckComposerExtension) deactivate: -> - DraftStore.unregisterExtension(Extension) + ExtensionRegistry.Composer.unregister(SpellcheckComposerExtension) serialize: -> @state diff --git a/internal_packages/composer-spellcheck/lib/draft-extension.coffee b/internal_packages/composer-spellcheck/lib/spellcheck-composer-extension.coffee similarity index 92% rename from internal_packages/composer-spellcheck/lib/draft-extension.coffee rename to internal_packages/composer-spellcheck/lib/spellcheck-composer-extension.coffee index ba7a10899..f7b9600cc 100644 --- a/internal_packages/composer-spellcheck/lib/draft-extension.coffee +++ b/internal_packages/composer-spellcheck/lib/spellcheck-composer-extension.coffee @@ -1,4 +1,4 @@ -{DraftStoreExtension, AccountStore, DOMUtils} = require 'nylas-exports' +{ComposerExtension, AccountStore, DOMUtils} = require 'nylas-exports' _ = require 'underscore' spellchecker = require('spellchecker') remote = require('remote') @@ -6,16 +6,16 @@ MenuItem = remote.require('menu-item') SpellcheckCache = {} -class SpellcheckDraftStoreExtension extends DraftStoreExtension +class SpellcheckComposerExtension extends ComposerExtension @isMisspelled: (word) -> SpellcheckCache[word] ?= spellchecker.isMisspelled(word) SpellcheckCache[word] - @onInput: (editableNode) -> + @onInput: (editableNode) => @walkTree(editableNode) - @onShowContextMenu: (event, editableNode, selection, innerStateProxy, menu) => + @onShowContextMenu: (event, editableNode, selection, menu) => range = DOMUtils.Mutating.getRangeAtAndSelectWord(selection, 0) word = range.toString() if @isMisspelled(word) @@ -123,6 +123,6 @@ class SpellcheckDraftStoreExtension extends DraftStoreExtension if body != clean session.changes.add(body: clean) -SpellcheckDraftStoreExtension.SpellcheckCache = SpellcheckCache +SpellcheckComposerExtension.SpellcheckCache = SpellcheckCache -module.exports = SpellcheckDraftStoreExtension +module.exports = SpellcheckComposerExtension diff --git a/internal_packages/composer-spellcheck/spec/draft-extension-spec.coffee b/internal_packages/composer-spellcheck/spec/spellcheck-composer-extension-spec.coffee similarity index 73% rename from internal_packages/composer-spellcheck/spec/draft-extension-spec.coffee rename to internal_packages/composer-spellcheck/spec/spellcheck-composer-extension-spec.coffee index 1234d9414..661134be6 100644 --- a/internal_packages/composer-spellcheck/spec/draft-extension-spec.coffee +++ b/internal_packages/composer-spellcheck/spec/spellcheck-composer-extension-spec.coffee @@ -1,22 +1,22 @@ -SpellcheckDraftStoreExtension = require '../lib/draft-extension' +SpellcheckComposerExtension = require '../lib/spellcheck-composer-extension' fs = require 'fs' _ = require 'underscore' initialHTML = fs.readFileSync(__dirname + '/fixtures/california-with-misspellings-before.html').toString() expectedHTML = fs.readFileSync(__dirname + '/fixtures/california-with-misspellings-after.html').toString() -describe "SpellcheckDraftStoreExtension", -> +describe "SpellcheckComposerExtension", -> beforeEach -> # Avoid differences between node-spellcheck on different platforms spellings = JSON.parse(fs.readFileSync(__dirname + '/fixtures/california-spelling-lookup.json')) - spyOn(SpellcheckDraftStoreExtension, 'isMisspelled').andCallFake (word) -> + spyOn(SpellcheckComposerExtension, 'isMisspelled').andCallFake (word) -> spellings[word] describe "walkTree", -> it "correctly walks a DOM tree and surrounds mispelled words", -> dom = document.createElement('div') dom.innerHTML = initialHTML - SpellcheckDraftStoreExtension.walkTree(dom) + SpellcheckComposerExtension.walkTree(dom) expect(dom.innerHTML).toEqual(expectedHTML) describe "finalizeSessionBeforeSending", -> @@ -27,7 +27,7 @@ describe "SpellcheckDraftStoreExtension", -> changes: add: jasmine.createSpy('add') - SpellcheckDraftStoreExtension.finalizeSessionBeforeSending(session) + SpellcheckComposerExtension.finalizeSessionBeforeSending(session) expect(session.changes.add).toHaveBeenCalledWith(body: initialHTML) -module.exports = SpellcheckDraftStoreExtension +module.exports = SpellcheckComposerExtension diff --git a/internal_packages/composer/lib/composer-extensions-plugin.coffee b/internal_packages/composer/lib/composer-extensions-plugin.coffee deleted file mode 100644 index 468800538..000000000 --- a/internal_packages/composer/lib/composer-extensions-plugin.coffee +++ /dev/null @@ -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 diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx index 4447665ef..7e025a262 100644 --- a/internal_packages/composer/lib/composer-view.cjsx +++ b/internal_packages/composer/lib/composer-view.cjsx @@ -10,7 +10,8 @@ React = require 'react' AccountStore, FileUploadStore, QuotedHTMLParser, - FileDownloadStore} = require 'nylas-exports' + FileDownloadStore, + ExtensionRegistry} = require 'nylas-exports' {DropZone, RetinaImg, @@ -29,8 +30,6 @@ CollapsedParticipants = require './collapsed-participants' Fields = require './fields' -ComposerExtensionsPlugin = require './composer-extensions-plugin' - # 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 # Composer with new props. @@ -68,6 +67,7 @@ class ComposerView extends React.Component enabledFields: [] # Gets updated in @_initiallyEnabledFields showQuotedText: false uploads: FileUploadStore.uploadsForMessage(@props.draftClientId) ? [] + extensions: ExtensionRegistry.Composer.extensions() componentWillMount: => @_prepareForDraft(@props.draftClientId) @@ -80,6 +80,7 @@ class ComposerView extends React.Component @_usubs = [] @_usubs.push FileUploadStore.listen @_onFileUploadStoreChange @_usubs.push AccountStore.listen @_onAccountStoreChanged + @_usubs.push ExtensionRegistry.Composer.listen @_onExtensionsChanged @_applyFieldFocus() componentWillUnmount: => @@ -300,7 +301,7 @@ class ComposerView extends React.Component onScrollTo={@props.onRequestScrollTo} onFilePaste={@_onFilePaste} onScrollToBottom={@_onScrollToBottom()} - plugins={[ComposerExtensionsPlugin]} + extensions={@state.extensions} getComposerBoundingRect={@_getComposerBoundingRect} initialSelectionSnapshot={@_recoveredSelection} /> @@ -529,6 +530,9 @@ class ComposerView extends React.Component enabledFields.push Fields.Body return enabledFields + _onExtensionsChanged: => + @setState extensions: ExtensionRegistry.Composer.extensions() + # 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. _onAccountStoreChanged: => @@ -685,7 +689,7 @@ class ComposerView extends React.Component warnings.push('without a body') # Check third party warnings added via DraftStore extensions - for extension in DraftStore.extensions() + for extension in @state.extensions continue unless extension.warningsForSending warnings = warnings.concat(extension.warningsForSending(draft)) diff --git a/internal_packages/message-autoload-images/lib/autoload-images-extension.coffee b/internal_packages/message-autoload-images/lib/autoload-images-extension.coffee index c75479f94..6c0465657 100644 --- a/internal_packages/message-autoload-images/lib/autoload-images-extension.coffee +++ b/internal_packages/message-autoload-images/lib/autoload-images-extension.coffee @@ -1,7 +1,7 @@ AutoloadImagesStore = require './autoload-images-store' -{MessageStoreExtension} = require 'nylas-exports' +{MessageViewExtension} = require 'nylas-exports' -class AutoloadImagesExtension extends MessageStoreExtension +class AutoloadImagesExtension extends MessageViewExtension @formatMessageBody: (message) -> if AutoloadImagesStore.shouldBlockImagesIn(message) diff --git a/internal_packages/message-autoload-images/lib/main.coffee b/internal_packages/message-autoload-images/lib/main.coffee index c9e9075ca..6b909defb 100644 --- a/internal_packages/message-autoload-images/lib/main.coffee +++ b/internal_packages/message-autoload-images/lib/main.coffee @@ -1,5 +1,5 @@ {ComponentRegistry, - MessageStore, + ExtensionRegistry, WorkspaceStore} = require 'nylas-exports' AutoloadImagesExtension = require './autoload-images-extension' @@ -10,12 +10,12 @@ module.exports = activate: (@state={}) -> # Register Message List Actions we provide globally - MessageStore.registerExtension(AutoloadImagesExtension) + ExtensionRegistry.MessageView.register AutoloadImagesExtension ComponentRegistry.register AutoloadImagesHeader, role: 'message:BodyHeader' deactivate: -> - MessageStore.unregisterExtension(AutoloadImagesExtension) + ExtensionRegistry.MessageView.unregister AutoloadImagesExtension ComponentRegistry.unregister(AutoloadImagesHeader) serialize: -> @state diff --git a/internal_packages/message-list/lib/main.cjsx b/internal_packages/message-list/lib/main.cjsx index fb0273258..f4716d8ae 100644 --- a/internal_packages/message-list/lib/main.cjsx +++ b/internal_packages/message-list/lib/main.cjsx @@ -1,7 +1,7 @@ MessageList = require "./message-list" MessageToolbarItems = require "./message-toolbar-items" {ComponentRegistry, - MessageStore, + ExtensionRegistry, WorkspaceStore} = require 'nylas-exports' {SidebarContactCard, @@ -46,8 +46,8 @@ module.exports = ComponentRegistry.register ThreadToggleUnreadButton, role: 'message:Toolbar' - MessageStore.registerExtension(AutolinkerExtension) - MessageStore.registerExtension(TrackingPixelsExtension) + ExtensionRegistry.MessageView.register AutolinkerExtension + ExtensionRegistry.MessageView.register TrackingPixelsExtension deactivate: -> ComponentRegistry.unregister MessageList @@ -59,7 +59,7 @@ module.exports = ComponentRegistry.unregister SidebarContactCard ComponentRegistry.unregister SidebarSpacer ComponentRegistry.unregister SidebarContactList - MessageStore.unregisterExtension(AutolinkerExtension) - MessageStore.unregisterExtension(TrackingPixelsExtension) + ExtensionRegistry.MessageView.unregister AutolinkerExtension + ExtensionRegistry.MessageView.unregister TrackingPixelsExtension serialize: -> @state diff --git a/internal_packages/message-list/lib/plugins/autolinker-extension.coffee b/internal_packages/message-list/lib/plugins/autolinker-extension.coffee index 6c2f5060d..8ae3a562b 100644 --- a/internal_packages/message-list/lib/plugins/autolinker-extension.coffee +++ b/internal_packages/message-list/lib/plugins/autolinker-extension.coffee @@ -1,7 +1,7 @@ Autolinker = require 'autolinker' -{MessageStoreExtension} = require 'nylas-exports' +{MessageViewExtension} = require 'nylas-exports' -class AutolinkerExtension extends MessageStoreExtension +class AutolinkerExtension extends MessageViewExtension @formatMessageBody: (message) -> # Apply the autolinker pass to make emails and links clickable diff --git a/internal_packages/message-list/lib/plugins/tracking-pixels-extension.coffee b/internal_packages/message-list/lib/plugins/tracking-pixels-extension.coffee index 2b06fdf2d..13d18ce56 100644 --- a/internal_packages/message-list/lib/plugins/tracking-pixels-extension.coffee +++ b/internal_packages/message-list/lib/plugins/tracking-pixels-extension.coffee @@ -1,4 +1,4 @@ -{MessageStoreExtension, RegExpUtils} = require 'nylas-exports' +{MessageViewExtension, RegExpUtils} = require 'nylas-exports' TrackingBlacklist = [{ name: 'Sidekick', @@ -98,7 +98,7 @@ TrackingBlacklist = [{ homepage: 'http://salesloft.com' }] -class TrackingPixelsExtension extends MessageStoreExtension +class TrackingPixelsExtension extends MessageViewExtension @formatMessageBody: (message) -> return unless message.isFromMe() diff --git a/spec/extension-registry-spec.coffee b/spec/extension-registry-spec.coffee new file mode 100644 index 000000000..0f718b822 --- /dev/null +++ b/spec/extension-registry-spec.coffee @@ -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() diff --git a/spec/stores/draft-store-spec.coffee b/spec/stores/draft-store-spec.coffee index 0d3d422bf..4b6eee14a 100644 --- a/spec/stores/draft-store-spec.coffee +++ b/spec/stores/draft-store-spec.coffee @@ -6,7 +6,7 @@ ModelQuery = require '../../src/flux/models/query' AccountStore = require '../../src/flux/stores/account-store' DatabaseStore = require '../../src/flux/stores/database-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' DestroyDraftTask = require '../../src/flux/tasks/destroy-draft' SoundRegistry = require '../../src/sound-registry' @@ -26,7 +26,7 @@ messageWithStyleTags = null fakeMessages = null fakeMessageWithFiles = null -class TestExtension extends DraftStoreExtension +class TestExtension extends ComposerExtension @prepareNewDraft: (draft) -> draft.body = "Edited by TestExtension!" + draft.body diff --git a/src/component-registry.coffee b/src/component-registry.coffee index bf2999e2a..d0eee7698 100644 --- a/src/component-registry.coffee +++ b/src/component-registry.coffee @@ -1,5 +1,4 @@ _ = require 'underscore' -Actions = require './flux/actions' {Listener, Publisher} = require './flux/modules/reflux-coffee' CoffeeHelpers = require './flux/coffee-helpers' diff --git a/src/components/contenteditable/contenteditable-plugin.coffee b/src/components/contenteditable/contenteditable-plugin.coffee deleted file mode 100644 index bc3cb85e3..000000000 --- a/src/components/contenteditable/contenteditable-plugin.coffee +++ /dev/null @@ -1,26 +0,0 @@ -### -ContenteditablePlugin is an abstract base class. Implementations of this -are used to make additional changes to a component -beyond a user's input intents. - -While some ContenteditablePlugins are included with the core - 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) -> diff --git a/src/components/contenteditable/contenteditable.cjsx b/src/components/contenteditable/contenteditable.cjsx index 61288209d..d2e005148 100644 --- a/src/components/contenteditable/contenteditable.cjsx +++ b/src/components/contenteditable/contenteditable.cjsx @@ -36,25 +36,34 @@ class Contenteditable extends React.Component initialSelectionSnapshot: React.PropTypes.object - # Passes an absolute top coordinate to scroll to. + # Handlers onChange: React.PropTypes.func.isRequired onFilePaste: React.PropTypes.func + # Passes an absolute top coordinate to scroll to. onScrollTo: React.PropTypes.func onScrollToBottom: React.PropTypes.func - # A list of objects that extend {ContenteditablePlugin} - plugins: React.PropTypes.array + # Extension DOM Mutating handlers. See {ContenteditableExtension} + 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 floatingToolbar: React.PropTypes.bool @defaultProps: - plugins: [] + extensions: [] spellcheck: true floatingToolbar: true - corePlugins: [ListManager] + coreExtensions: [ListManager] # We allow extensions to read, and mutate the: # @@ -70,7 +79,7 @@ class Contenteditable extends React.Component innerStateProxy = get: => return @innerState set: (newInnerState) => @setInnerState(newInnerState) - args = [event, @_editableNode(), document.getSelection(), innerStateProxy, extraArgs...] + args = [event, @_editableNode(), document.getSelection(), extraArgs..., innerStateProxy] editingFunction.apply(null, args) @_setupSelectionListeners() @@ -162,7 +171,7 @@ class Contenteditable extends React.Component selection.addRange(range) # 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 # object (the `_editableNode()`) and will do mutations to it. Once those # mutations are done, we need to be sure to notify that changes @@ -201,38 +210,42 @@ class Contenteditable extends React.Component @_setupSelectionListeners() @_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 # to stopping default DOM behavior or prevent event bubbling through the DOM, # but rather prevent our own Contenteditable default behavior, and preventing - # other plugins from being called. - # If any of the plugins calls event.preventDefault() it will prevent the + # other extensions from being called. + # If any of the extensions calls event.preventDefault() it will prevent the # default behavior for the Contenteditable, which basically means preventing - # the core plugin handlers from being called. - # If any of the plugins calls event.stopPropagation(), it will prevent any - # other plugin handlers from being called. + # the core extension handlers from being called. + # If any of the extensions calls event.stopPropagation(), it will prevent any + # other extension handlers from being called. # # NOTE: It's possible for there to be no `event` passed in. - _runPluginHandlersForEvent: (method, event, args...) => - executeCallback = (plugin) => - return if not plugin[method]? - callback = plugin[method].bind(plugin) + _runExtensionHandlersForEvent: (method, event, args...) => + executeCallback = (extension) => + return if not extension[method]? + callback = extension[method].bind(extension) @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() - executeCallback(plugin) + executeCallback(extension) return if event?.defaultPrevented or event?.isPropagationStopped() - for plugin in @corePlugins + for extension in @coreExtensions break if event?.isPropagationStopped() - executeCallback(plugin) + executeCallback(extension) _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 - # keymap manager if the plugin prevented the default behavior + # keymap manager if the extension prevented the default behavior if event.defaultPrevented event.stopPropagation() return @@ -259,7 +272,7 @@ class Contenteditable extends React.Component @_ignoreInputChanges = true @_resetInnerStateOnInput() - @_runPluginHandlersForEvent("onInput", event) + @_runExtensionHandlersForEvent("onInput", event) @_normalize() @@ -413,11 +426,12 @@ class Contenteditable extends React.Component _onBlur: (event) => @setInnerState dragging: false return if @_editableNode().parentElement.contains event.relatedTarget + @_runExtensionHandlersForEvent("onBlur", event) @setInnerState editableFocused: false _onFocus: (event) => @setInnerState editableFocused: true - @props.onFocus?(event) + @_runExtensionHandlersForEvent("onFocus", event) _editableNode: => React.findDOMNode(@refs.contenteditable) @@ -616,7 +630,8 @@ class Contenteditable extends React.Component MenuItem = remote.require('menu-item') menu = new Menu() - @_runPluginHandlersForEvent("onShowContextMenu", event, menu) + + @_runExtensionHandlersForEvent("onShowContextMenu", event, menu) menu.append(new MenuItem({ label: 'Cut', role: 'cut'})) menu.append(new MenuItem({ label: 'Copy', role: 'copy'})) menu.append(new MenuItem({ label: 'Paste', role: 'paste'})) @@ -660,7 +675,7 @@ class Contenteditable extends React.Component selection = document.getSelection() return event unless DOMUtils.selectionInScope(selection, editableNode) - @_runPluginHandlersForEvent("onClick", event) + @_runExtensionHandlersForEvent("onClick", event) return event _onDragStart: (event) => diff --git a/src/components/contenteditable/floating-toolbar.cjsx b/src/components/contenteditable/floating-toolbar.cjsx index 5dca93423..e69782bea 100644 --- a/src/components/contenteditable/floating-toolbar.cjsx +++ b/src/components/contenteditable/floating-toolbar.cjsx @@ -3,7 +3,7 @@ React = require 'react/addons' classNames = require 'classnames' {CompositeDisposable} = require 'event-kit' {RetinaImg} = require 'nylas-component-kit' -{DraftStore} = require 'nylas-exports' +{ExtensionRegistry} = require 'nylas-exports' class FloatingToolbar extends React.Component @displayName = "FloatingToolbar" @@ -29,9 +29,11 @@ class FloatingToolbar extends React.Component @state = urlInputValue: @_initialUrl() ? "" componentWidth: 0 + extensions: ExtensionRegistry.Composer.extensions() componentDidMount: => @subscriptions = new CompositeDisposable() + @usubExtensions = ExtensionRegistry.Composer.listen @_onExtensionsChanged componentWillReceiveProps: (nextProps) => @setState @@ -39,6 +41,7 @@ class FloatingToolbar extends React.Component componentWillUnmount: => @subscriptions?.dispose() + @usubExtensions() componentDidUpdate: => if @props.mode is "edit-link" and not @props.linkToModify @@ -88,12 +91,12 @@ class FloatingToolbar extends React.Component - {@_toolbarExtensions()} + {@_toolbarExtensions(@state.extensions)} - _toolbarExtensions: -> + _toolbarExtensions: (extensions) -> buttons = [] - for extension in DraftStore.extensions() + for extension in extensions toolbarItem = extension.composerToolbar?() if toolbarItem buttons.push( @@ -102,6 +105,9 @@ class FloatingToolbar extends React.Component title="#{toolbarItem.tooltip}">) return buttons + _onExtensionsChanged: => + @setState extensions: ExtensionRegistry.Composer.extensions() + _extensionMutateDom: (mutator) => @props.onDomMutator(mutator) diff --git a/src/components/contenteditable/list-manager.coffee b/src/components/contenteditable/list-manager.coffee index c37133fcf..45c1915d5 100644 --- a/src/components/contenteditable/list-manager.coffee +++ b/src/components/contenteditable/list-manager.coffee @@ -1,8 +1,7 @@ _str = require 'underscore.string' -{DOMUtils} = require 'nylas-exports' -ContenteditablePlugin = require './contenteditable-plugin' +{DOMUtils, ContenteditableExtension} = require 'nylas-exports' -class ListManager extends ContenteditablePlugin +class ListManager extends ContenteditableExtension @onInput: (event, editableNode, selection) -> if @_spaceEntered and @hasListStartSignature(selection) @createList(event, selection) diff --git a/src/deprecate-utils.coffee b/src/deprecate-utils.coffee new file mode 100644 index 000000000..cab91d881 --- /dev/null +++ b/src/deprecate-utils.coffee @@ -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 diff --git a/src/extension-registry.es6 b/src/extension-registry.es6 new file mode 100644 index 000000000..b72ba1192 --- /dev/null +++ b/src/extension-registry.es6 @@ -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', +); diff --git a/src/flux/actions.coffee b/src/flux/actions.coffee index 8eff7df8f..67cec323f 100644 --- a/src/flux/actions.coffee +++ b/src/flux/actions.coffee @@ -296,7 +296,7 @@ class Actions ### 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 one of these objects rather than listening for the {sendDraft} action. diff --git a/src/flux/extensions/composer-extension-adapter.coffee b/src/flux/extensions/composer-extension-adapter.coffee new file mode 100644 index 000000000..7a0f48599 --- /dev/null +++ b/src/flux/extensions/composer-extension-adapter.coffee @@ -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 diff --git a/src/flux/extensions/composer-extension.coffee b/src/flux/extensions/composer-extension.coffee new file mode 100644 index 000000000..e716fe486 --- /dev/null +++ b/src/flux/extensions/composer-extension.coffee @@ -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 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 diff --git a/src/flux/extensions/contenteditable-extension.coffee b/src/flux/extensions/contenteditable-extension.coffee new file mode 100644 index 000000000..49788fcb6 --- /dev/null +++ b/src/flux/extensions/contenteditable-extension.coffee @@ -0,0 +1,146 @@ +### +Public: ContenteditableExtension is an abstract base class. Implementations of this +are used to make additional changes to a 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( +
+ +
+ ); +} +``` + +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 + `` 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 diff --git a/src/flux/extensions/message-view-extension.coffee b/src/flux/extensions/message-view-extension.coffee new file mode 100644 index 000000000..73e176a81 --- /dev/null +++ b/src/flux/extensions/message-view-extension.coffee @@ -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 diff --git a/src/flux/stores/draft-store-extension.coffee b/src/flux/stores/draft-store-extension.coffee index ec9003d37..5b6f3f4c2 100644 --- a/src/flux/stores/draft-store-extension.coffee +++ b/src/flux/stores/draft-store-extension.coffee @@ -1,174 +1,7 @@ ### -Public: DraftStoreExtension is an abstract base class. To create DraftStoreExtensions -that enhance the composer experience, you should subclass {DraftStoreExtension} and -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 +Public: DraftStoreExtension is deprecated. Use {ComposerExtension} instead. +Section: Extensions ### 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 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 - `` 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 diff --git a/src/flux/stores/draft-store.coffee b/src/flux/stores/draft-store.coffee index 29138e217..23c2aec8a 100644 --- a/src/flux/stores/draft-store.coffee +++ b/src/flux/stores/draft-store.coffee @@ -29,6 +29,9 @@ CoffeeHelpers = require '../coffee-helpers' DOMUtils = require '../../dom-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 getter methods to return Draft objects and sessions. @@ -72,7 +75,6 @@ class DraftStore NylasEnv.onBeforeUnload @_onBeforeUnload @_draftSessions = {} - @_extensions = [] @_inlineStylePromises = {} @_inlineStyleResolvers = {} @@ -131,23 +133,25 @@ class DraftStore # Public: Returns the extensions registered with the DraftStore. 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, # display warnings before draft are sent, and more. # - # - `ext` A {DraftStoreExtension} instance. + # - `ext` A {ComposerExtension} instance. # 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) => - @_extensions = _.without(@_extensions, ext) + ExtensionRegistry.Composer.unregister(ext) ########### PRIVATE #################################################### @@ -228,7 +232,7 @@ class DraftStore _finalizeAndPersistNewMessage: (draft) => # Give extensions an opportunity to perform additional setup to the draft - for extension in @_extensions + for extension in @extensions() continue unless extension.prepareNewDraft extension.prepareNewDraft(draft) @@ -516,7 +520,7 @@ class DraftStore # Give third-party plugins an opportunity to sanitize draft data _runExtensionsBeforeSend: (session) -> - for extension in @_extensions + for extension in @extensions() continue unless extension.finalizeSessionBeforeSending extension.finalizeSessionBeforeSending(session) @@ -537,4 +541,19 @@ class DraftStore NylasEnv.showErrorDialog(errorMessage) , 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 diff --git a/src/flux/stores/message-store-extension.coffee b/src/flux/stores/message-store-extension.coffee index c12df48d8..3aafdaa8e 100644 --- a/src/flux/stores/message-store-extension.coffee +++ b/src/flux/stores/message-store-extension.coffee @@ -1,35 +1,7 @@ ### -Public: MessageStoreExtension is an abstract base class. To create MessageStoreExtension -that customize message viewing, you should subclass {MessageStoreExtension} and -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 +Public: MessageStoreExtension is deprecated. Use {MessageViewExtension} instead. +Section: Extensions ### 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 diff --git a/src/flux/stores/message-store.coffee b/src/flux/stores/message-store.coffee index 1d9dc0490..1cf2b0e06 100644 --- a/src/flux/stores/message-store.coffee +++ b/src/flux/stores/message-store.coffee @@ -8,6 +8,8 @@ AccountStore = require "./account-store" FocusedContentStore = require "./focused-content-store" ChangeUnreadTask = require '../tasks/change-unread-task' NylasAPI = require '../nylas-api' +ExtensionRegistry = require '../../extension-registry' +{deprecate} = require '../../deprecate-utils' async = require 'async' _ = require 'underscore' @@ -43,25 +45,27 @@ class MessageStore extends NylasStore # Public: Returns the extensions registered with the MessageStore. 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 # the future. # - # - `ext` A {MessageStoreExtension} instance. + # - `ext` A {MessageViewExtension} instance. # registerExtension: (ext) => - @_extensions.push(ext) - MessageBodyProcessor = require './message-body-processor' - MessageBodyProcessor.resetCache() + ExtensionRegistry.MessageView.register(ext) - # 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) => - @_extensions = _.without(@_extensions, ext) + ExtensionRegistry.MessageView.unregister(ext) + + _onExtensionsChanged: (role) -> MessageBodyProcessor = require './message-body-processor' MessageBodyProcessor.resetCache() @@ -73,10 +77,10 @@ class MessageStore extends NylasStore @_itemsExpanded = {} @_itemsLoading = false @_thread = null - @_extensions = [] @_inflight = {} _registerListeners: -> + @listenTo ExtensionRegistry.MessageView, @_onExtensionsChanged @listenTo DatabaseStore, @_onDataChanged @listenTo FocusedContentStore, @_onFocusChanged @listenTo Actions.toggleMessageIdExpanded, @_onToggleMessageIdExpanded @@ -287,4 +291,17 @@ class MessageStore extends NylasStore 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 diff --git a/src/global/nylas-exports.coffee b/src/global/nylas-exports.coffee index cf059ca39..01a9fa6e8 100644 --- a/src/global/nylas-exports.coffee +++ b/src/global/nylas-exports.coffee @@ -33,6 +33,15 @@ class NylasExports NylasExports.registerSerializable(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 @load "Actions", 'flux/actions' @@ -108,14 +117,24 @@ class NylasExports @require "ThreadCountsStore", 'flux/stores/thread-counts-store' @require "UnreadBadgeStore", 'flux/stores/unread-badge-store' @require "FileDownloadStore", 'flux/stores/file-download-store' - @require "DraftStoreExtension", 'flux/stores/draft-store-extension' @require "FocusedContentStore", 'flux/stores/focused-content-store' @require "FocusedMailViewStore", 'flux/stores/focused-mail-view-store' @require "FocusedContactsStore", 'flux/stores/focused-contacts-store' @require "MessageBodyProcessor", 'flux/stores/message-body-processor' - @require "MessageStoreExtension", 'flux/stores/message-store-extension' @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 @get "React", -> require 'react' # Our version of React for 3rd party use @load "ReactRemote", 'react-remote/react-remote-parent' @@ -146,9 +165,6 @@ class NylasExports @load "BufferedProcess", 'buffered-process' @get "APMWrapper", -> require('../apm-wrapper') - # Contenteditable - @load "ContenteditablePlugin", 'components/contenteditable/contenteditable-plugin' - # Testing @get "NylasTestUtils", -> require '../../spec/nylas-test-utils'