From 6315bc9d80321aa1d0602a9c9484d9bd5c5cd597 Mon Sep 17 00:00:00 2001 From: Juan Tejada Date: Wed, 30 Dec 2015 11:36:47 -0500 Subject: [PATCH] fix(extension-adapter): Update adapter to support all versions of extension api we've used Summary: - Rewrites composer extension adpater to support all versions of the ComposerExtension API we've ever declared. This will allow old plugins (or plugins that haven't been reinstalled after update) to keep functioning without breaking N1 - Adds specs Test Plan: - Unit tests Reviewers: evan, bengotow Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D2399 --- build/config/eslint.json | 3 +- docs/ComposerExtensions.md | 6 +- .../lib/template-composer-extension.es6 | 68 +++++----- .../lib/availability-composer-extension.cjsx | 2 +- .../lib/signature-composer-extension.coffee | 2 +- .../signature-composer-extension-spec.coffee | 6 +- .../lib/spellcheck-composer-extension.coffee | 8 +- .../spellcheck-composer-extension-spec.coffee | 2 +- .../composer/lib/composer-view.cjsx | 2 +- .../lib/autoload-images-extension.coffee | 2 +- .../autoload-images-extension-spec.coffee | 2 +- .../lib/plugins/autolinker-extension.coffee | 2 +- .../plugins/tracking-pixels-extension.coffee | 2 +- .../spec/autolinker-extension-spec.coffee | 4 +- .../tracking-pixels-extension-spec.coffee | 4 +- .../composer-extension-adapter-spec.es6 | 124 ++++++++++++++++++ spec/stores/draft-store-spec.coffee | 2 +- .../contenteditable/contenteditable.cjsx | 29 ++-- .../contenteditable/dom-normalizer.coffee | 2 +- .../floating-toolbar-container.cjsx | 7 +- .../contenteditable/list-manager.coffee | 4 +- .../contenteditable/tab-manager.coffee | 2 +- src/extension-registry.es6 | 6 +- .../composer-extension-adapter.coffee | 51 ------- src/extensions/composer-extension-adapter.es6 | 114 ++++++++++++++++ src/extensions/composer-extension.coffee | 8 +- .../contenteditable-extension.coffee | 21 ++- src/extensions/extension-utils.es6 | 7 + .../message-view-extension-adapter.es6 | 21 +++ src/extensions/message-view-extension.coffee | 2 +- src/flux/stores/draft-store.coffee | 4 +- src/flux/stores/message-body-processor.coffee | 2 +- src/regexp-utils.coffee | 4 + 33 files changed, 374 insertions(+), 151 deletions(-) create mode 100644 spec/extensions/composer-extension-adapter-spec.es6 delete mode 100644 src/extensions/composer-extension-adapter.coffee create mode 100644 src/extensions/composer-extension-adapter.es6 create mode 100644 src/extensions/extension-utils.es6 create mode 100644 src/extensions/message-view-extension-adapter.es6 diff --git a/build/config/eslint.json b/build/config/eslint.json index 61724c4d2..93ebd5e57 100644 --- a/build/config/eslint.json +++ b/build/config/eslint.json @@ -14,6 +14,7 @@ "eqeqeq": [2, "smart"], "id-length": [0], "no-loop-func": [0], - "new-cap": [2, {"capIsNew": false}] + "new-cap": [2, {"capIsNew": false}], + "no-shadow": [1] } } diff --git a/docs/ComposerExtensions.md b/docs/ComposerExtensions.md index 15efc87e3..528c0abf2 100644 --- a/docs/ComposerExtensions.md +++ b/docs/ComposerExtensions.md @@ -27,7 +27,7 @@ This extension displays a warning before sending a draft that contains the names class ProductsExtension extends ComposerExtension - @warningsForSending: (draft) -> + @warningsForSending: ({draft}) -> words = ['acme', 'anvil', 'tunnel', 'rocket', 'dynamite'] body = draft.body.toLowercase() for word in words @@ -35,9 +35,9 @@ class ProductsExtension extends ComposerExtension return ["with the word '#{word}'?"] return [] - @finalizeSessionBeforeSending: (session) -> + @finalizeSessionBeforeSending: ({session}) -> draft = session.draft() - if @warningsForSending(draft) + if @warningsForSending({draft}) bodyWithWarning = draft.body += "
This email \ contains competitor's product names \ or trademarks used in context." diff --git a/examples/N1-Composer-Templates/lib/template-composer-extension.es6 b/examples/N1-Composer-Templates/lib/template-composer-extension.es6 index 3ade86d85..2093248fb 100644 --- a/examples/N1-Composer-Templates/lib/template-composer-extension.es6 +++ b/examples/N1-Composer-Templates/lib/template-composer-extension.es6 @@ -2,7 +2,7 @@ import {DOMUtils, ComposerExtension} from 'nylas-exports'; class TemplatesComposerExtension extends ComposerExtension { - static warningsForSending(draft) { + static warningsForSending({draft}) { const warnings = []; if (draft.body.search(/]*empty[^>]*>/i) > 0) { warnings.push('with an empty template area'); @@ -10,7 +10,7 @@ class TemplatesComposerExtension extends ComposerExtension { return warnings; } - static finalizeSessionBeforeSending(session) { + static finalizeSessionBeforeSending({session}) { const body = session.draft().body; const clean = body.replace(/<\/?code[^>]*>/g, ''); if (body !== clean) { @@ -18,33 +18,34 @@ class TemplatesComposerExtension extends ComposerExtension { } } - static onClick(editor, event) { - var node = event.target; - if(node.nodeName === "CODE" && node.classList.contains("var") && node.classList.contains("empty")) { - editor.selectAllChildren(node) + static onClick({editor, event}) { + const node = event.target; + if (node.nodeName === 'CODE' && node.classList.contains('var') && node.classList.contains('empty')) { + editor.selectAllChildren(node); } } - static onKeyDown(editor, event) { + static onKeyDown({editor, event}) { const editableNode = editor.rootNode; if (event.key === 'Tab') { const nodes = editableNode.querySelectorAll('code.var'); - if(nodes.length>0) { - let sel = editor.currentSelection(); + if (nodes.length > 0) { + const sel = editor.currentSelection(); let found = false; // First, try to find a that the selection is within. If found, // select the next/prev node if the selection ends at the end of the // 's text, otherwise select the 's contents. - for (let i=0; i that the selection is within, select the // nearest before/after the selection (depending on shift). - if(!found) { - let treeWalker = document.createTreeWalker(editableNode, NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT); - let curIndex = 0, nextIndex = null; + if (!found) { + const treeWalker = document.createTreeWalker(editableNode, NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT); + let curIndex = 0; + let nextIndex = null; + let node; while (node = treeWalker.nextNode()) { - if(sel.anchorNode === node || sel.focusNode === node) - break; - if(node.nodeName === "CODE" && node.classList.contains("var")) - curIndex++ + if (sel.anchorNode === node || sel.focusNode === node) break; + if (node.nodeName === 'CODE' && node.classList.contains('var')) curIndex++; } - nextIndex = event.shiftKey ? curIndex-1 : curIndex; - nextIndex = (nextIndex+nodes.length) % nodes.length; //allow wraparound in both directions + nextIndex = event.shiftKey ? curIndex - 1 : curIndex; + nextIndex = (nextIndex + nodes.length) % nodes.length; // allow wraparound in both directions sel.selectAllChildren(nodes[nextIndex]); } event.preventDefault(); event.stopPropagation(); } - } - else if(event.key === 'Enter') { + } else if (event.key === 'Enter') { const nodes = editableNode.querySelectorAll('code.var'); - for (let i=0; i { let test = selection.baseNode; while (test !== editableNode) { @@ -100,7 +100,7 @@ class TemplatesComposerExtension extends ComposerExtension { const result = []; for (let i = 0, codeTag; i < codeTags.length; i++) { codeTag = codeTags[i]; - codeTag.textContent = codeTag.textContent; //sets node contents to just its textContent, strips HTML + codeTag.textContent = codeTag.textContent; // sets node contents to just its textContent, strips HTML result.push((() => { if (selection.containsNode(codeTag) || isWithinNode(codeTag)) { return codeTag.classList.remove('empty'); diff --git a/examples/N1-Quick-Schedule/lib/availability-composer-extension.cjsx b/examples/N1-Quick-Schedule/lib/availability-composer-extension.cjsx index d065bd8f6..78d7da2f7 100644 --- a/examples/N1-Quick-Schedule/lib/availability-composer-extension.cjsx +++ b/examples/N1-Quick-Schedule/lib/availability-composer-extension.cjsx @@ -6,7 +6,7 @@ class AvailabilityComposerExtension extends ComposerExtension # When subclassing the ComposerExtension, you can add your own custom logic # to execute before a draft is sent in the @finalizeSessionBeforeSending # method. Here, we're registering the events before we send the draft. - @finalizeSessionBeforeSending: (session) -> + @finalizeSessionBeforeSending: ({session}) -> body = session.draft().body participants = session.draft().participants() sender = session.draft().from diff --git a/internal_packages/composer-signature/lib/signature-composer-extension.coffee b/internal_packages/composer-signature/lib/signature-composer-extension.coffee index 8b449b467..83b800d03 100644 --- a/internal_packages/composer-signature/lib/signature-composer-extension.coffee +++ b/internal_packages/composer-signature/lib/signature-composer-extension.coffee @@ -1,7 +1,7 @@ {ComposerExtension, AccountStore} = require 'nylas-exports' class SignatureComposerExtension extends ComposerExtension - @prepareNewDraft: (draft) -> + @prepareNewDraft: ({draft}) -> accountId = AccountStore.current().id signature = NylasEnv.config.get("nylas.account-#{accountId}.signature") return unless signature diff --git a/internal_packages/composer-signature/spec/signature-composer-extension-spec.coffee b/internal_packages/composer-signature/spec/signature-composer-extension-spec.coffee index 92e66f91f..84ad6d2f1 100644 --- a/internal_packages/composer-signature/spec/signature-composer-extension-spec.coffee +++ b/internal_packages/composer-signature/spec/signature-composer-extension-spec.coffee @@ -18,9 +18,9 @@ describe "SignatureComposerExtension", -> draft: true body: 'This is a another test.' - SignatureComposerExtension.prepareNewDraft(a) + SignatureComposerExtension.prepareNewDraft(draft: a) expect(a.body).toEqual('This is a test!
This is my signature.
Hello world
') - SignatureComposerExtension.prepareNewDraft(b) + SignatureComposerExtension.prepareNewDraft(draft: 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 "SignatureComposerExtension", -> a = new Message draft: true body: 'This is a test!
Hello world
' - SignatureComposerExtension.prepareNewDraft(a) + SignatureComposerExtension.prepareNewDraft(draft: a) expect(a.body).toEqual('This is a test!
Hello world
') diff --git a/internal_packages/composer-spellcheck/lib/spellcheck-composer-extension.coffee b/internal_packages/composer-spellcheck/lib/spellcheck-composer-extension.coffee index 7b2a6ce78..b8fa9c66a 100644 --- a/internal_packages/composer-spellcheck/lib/spellcheck-composer-extension.coffee +++ b/internal_packages/composer-spellcheck/lib/spellcheck-composer-extension.coffee @@ -12,10 +12,10 @@ class SpellcheckComposerExtension extends ComposerExtension SpellcheckCache[word] ?= spellchecker.isMisspelled(word) SpellcheckCache[word] - @onInput: (editableNode) => - @walkTree(editableNode) + @onContentChanged: ({editor}) => + @walkTree(editor.rootNode) - @onShowContextMenu: (editor, event, menu) => + @onShowContextMenu: ({editor, event, menu}) => selection = editor.currentSelection() editableNode = editor.rootNode range = DOMUtils.Mutating.getRangeAtAndSelectWord(selection, 0) @@ -119,7 +119,7 @@ class SpellcheckComposerExtension extends ComposerExtension if selectionImpacted selection.setBaseAndExtent(selectionSnapshot.anchorNode, selectionSnapshot.anchorOffset, selectionSnapshot.focusNode, selectionSnapshot.focusOffset) - @finalizeSessionBeforeSending: (session) -> + @finalizeSessionBeforeSending: ({session}) -> body = session.draft().body clean = body.replace(/<\/?spelling[^>]*>/g, '') if body != clean diff --git a/internal_packages/composer-spellcheck/spec/spellcheck-composer-extension-spec.coffee b/internal_packages/composer-spellcheck/spec/spellcheck-composer-extension-spec.coffee index 661134be6..e63709758 100644 --- a/internal_packages/composer-spellcheck/spec/spellcheck-composer-extension-spec.coffee +++ b/internal_packages/composer-spellcheck/spec/spellcheck-composer-extension-spec.coffee @@ -27,7 +27,7 @@ describe "SpellcheckComposerExtension", -> changes: add: jasmine.createSpy('add') - SpellcheckComposerExtension.finalizeSessionBeforeSending(session) + SpellcheckComposerExtension.finalizeSessionBeforeSending({session}) expect(session.changes.add).toHaveBeenCalledWith(body: initialHTML) module.exports = SpellcheckComposerExtension diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx index 130232dfb..0853e079d 100644 --- a/internal_packages/composer/lib/composer-view.cjsx +++ b/internal_packages/composer/lib/composer-view.cjsx @@ -739,7 +739,7 @@ class ComposerView extends React.Component # Check third party warnings added via Composer extensions for extension in ExtensionRegistry.Composer.extensions() continue unless extension.warningsForSending - warnings = warnings.concat(extension.warningsForSending(draft)) + warnings = warnings.concat(extension.warningsForSending({draft})) if warnings.length > 0 and not options.force response = dialog.showMessageBox(remote.getCurrentWindow(), { 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 6feb6a37f..b547fa620 100644 --- a/internal_packages/message-autoload-images/lib/autoload-images-extension.coffee +++ b/internal_packages/message-autoload-images/lib/autoload-images-extension.coffee @@ -3,7 +3,7 @@ AutoloadImagesStore = require './autoload-images-store' class AutoloadImagesExtension extends MessageViewExtension - @formatMessageBody: (message) -> + @formatMessageBody: ({message}) -> if AutoloadImagesStore.shouldBlockImagesIn(message) message.body = message.body.replace AutoloadImagesStore.ImagesRegexp, (match, prefix, imageUrl) -> "#{prefix}#" diff --git a/internal_packages/message-autoload-images/spec/autoload-images-extension-spec.coffee b/internal_packages/message-autoload-images/spec/autoload-images-extension-spec.coffee index 5b524cd1d..7976c96de 100644 --- a/internal_packages/message-autoload-images/spec/autoload-images-extension-spec.coffee +++ b/internal_packages/message-autoload-images/spec/autoload-images-extension-spec.coffee @@ -18,7 +18,7 @@ describe "AutoloadImagesExtension", -> spyOn(AutoloadImagesStore, 'shouldBlockImagesIn').andReturn(true) message = body: scenario.in - AutoloadImagesExtension.formatMessageBody(message) + AutoloadImagesExtension.formatMessageBody({message}) expect(message.body == scenario.out).toBe(true) module.exports = AutoloadImagesExtension diff --git a/internal_packages/message-list/lib/plugins/autolinker-extension.coffee b/internal_packages/message-list/lib/plugins/autolinker-extension.coffee index 8ae3a562b..d59e8460c 100644 --- a/internal_packages/message-list/lib/plugins/autolinker-extension.coffee +++ b/internal_packages/message-list/lib/plugins/autolinker-extension.coffee @@ -3,7 +3,7 @@ Autolinker = require 'autolinker' class AutolinkerExtension extends MessageViewExtension - @formatMessageBody: (message) -> + @formatMessageBody: ({message}) -> # Apply the autolinker pass to make emails and links clickable message.body = Autolinker.link(message.body, {twitter: false}) 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 13d18ce56..1e6ee6486 100644 --- a/internal_packages/message-list/lib/plugins/tracking-pixels-extension.coffee +++ b/internal_packages/message-list/lib/plugins/tracking-pixels-extension.coffee @@ -100,7 +100,7 @@ TrackingBlacklist = [{ class TrackingPixelsExtension extends MessageViewExtension - @formatMessageBody: (message) -> + @formatMessageBody: ({message}) -> return unless message.isFromMe() regex = RegExpUtils.imageTagRegex() diff --git a/internal_packages/message-list/spec/autolinker-extension-spec.coffee b/internal_packages/message-list/spec/autolinker-extension-spec.coffee index f4202aa16..3329419fb 100644 --- a/internal_packages/message-list/spec/autolinker-extension-spec.coffee +++ b/internal_packages/message-list/spec/autolinker-extension-spec.coffee @@ -6,7 +6,7 @@ describe "AutolinkerExtension", -> spyOn(Autolinker, 'link').andCallFake (txt) => txt it "should call through to Autolinker", -> - AutolinkerExtension.formatMessageBody({body:'body'}) + AutolinkerExtension.formatMessageBody(message: {body:'body'}) expect(Autolinker.link).toHaveBeenCalledWith('body', {twitter: false}) it "should add a title to everything with an href", -> @@ -24,5 +24,5 @@ describe "AutolinkerExtension", -> hello world! hello world! """ - AutolinkerExtension.formatMessageBody(message) + AutolinkerExtension.formatMessageBody({message}) expect(message.body).toEqual(expected.body) diff --git a/internal_packages/message-list/spec/tracking-pixels-extension-spec.coffee b/internal_packages/message-list/spec/tracking-pixels-extension-spec.coffee index 6e9397f49..44075b297 100644 --- a/internal_packages/message-list/spec/tracking-pixels-extension-spec.coffee +++ b/internal_packages/message-list/spec/tracking-pixels-extension-spec.coffee @@ -22,10 +22,10 @@ describe "TrackingPixelsExtension", -> it "should splice tracking pixels and only run on messages by the current user", -> message = new Message(body: testBody) spyOn(message, 'isFromMe').andCallFake -> false - TrackingPixelsExtension.formatMessageBody(message) + TrackingPixelsExtension.formatMessageBody({message}) expect(message.body).toEqual(testBody) message = new Message(body: testBody) spyOn(message, 'isFromMe').andCallFake -> true - TrackingPixelsExtension.formatMessageBody(message) + TrackingPixelsExtension.formatMessageBody({message}) expect(message.body).toEqual(testBodyProcessed) diff --git a/spec/extensions/composer-extension-adapter-spec.es6 b/spec/extensions/composer-extension-adapter-spec.es6 new file mode 100644 index 000000000..be9983512 --- /dev/null +++ b/spec/extensions/composer-extension-adapter-spec.es6 @@ -0,0 +1,124 @@ +import * as adapter from '../../src/extensions/composer-extension-adapter'; +import {DOMUtils} from 'nylas-exports'; + +const selection = 'selection'; +const node = 'node'; +const event = 'event'; +const extra = 'extra'; +const editor = { + rootNode: node, + currentSelection() { + return selection; + }, +}; + +describe('ComposerExtensionAdapter', ()=> { + describe('adaptOnInput', ()=> { + it('adapts correctly if onContentChanged already defined', ()=> { + const onInputSpy = jasmine.createSpy('onInput'); + const extension = { + onContentChanged() {}, + onInput(ev, editableNode, sel) { + onInputSpy(ev, editableNode, sel); + }, + }; + adapter.adaptOnInput(extension); + extension.onContentChanged({editor, mutations: []}); + expect(onInputSpy).not.toHaveBeenCalled(); + }); + + it('adapts correctly when signature is (event, ...)', ()=> { + const onInputSpy = jasmine.createSpy('onInput'); + const extension = { + onInput(ev, editableNode, sel) { + onInputSpy(ev, editableNode, sel); + }, + }; + adapter.adaptOnInput(extension); + expect(extension.onContentChanged).toBeDefined(); + extension.onContentChanged({editor, mutations: []}); + expect(onInputSpy).toHaveBeenCalledWith([], node, selection); + }); + + it('adapts correctly when signature is (editableNode, selection, ...)', ()=> { + const onInputSpy = jasmine.createSpy('onInput'); + const extension = { + onInput(editableNode, sel, ev) { + onInputSpy(editableNode, sel, ev); + }, + }; + adapter.adaptOnInput(extension); + expect(extension.onContentChanged).toBeDefined(); + extension.onContentChanged({editor, mutations: []}); + expect(onInputSpy).toHaveBeenCalledWith(node, selection, []); + }); + }); + + describe('adaptOnTabDown', ()=> { + it('adapts onTabDown correctly', ()=> { + const onTabDownSpy = jasmine.createSpy('onTabDownSpy'); + const mockEvent = {key: 'Tab'}; + const range = 'range'; + spyOn(DOMUtils, 'getRangeInScope').andReturn(range); + const extension = { + onTabDown(editableNode, rn, ev) { + onTabDownSpy(editableNode, rn, ev); + }, + }; + adapter.adaptOnTabDown(extension, 'method'); + expect(extension.onKeyDown).toBeDefined(); + extension.onKeyDown({editor, event: mockEvent}); + expect(onTabDownSpy).toHaveBeenCalledWith(node, range, mockEvent); + }); + }); + + describe('adaptMethod', ()=> { + it('adapts correctly when signature is (editor, ...)', ()=> { + const methodSpy = jasmine.createSpy('methodSpy'); + const extension = { + method(editor, ev, other) { + methodSpy(editor, ev, other); + }, + }; + adapter.adaptMethod(extension, 'method'); + extension.method({editor, event, extra}); + expect(methodSpy).toHaveBeenCalledWith(editor, event, extra); + }); + + it('adapts correctly when signature is (event, ...)', ()=> { + const methodSpy = jasmine.createSpy('methodSpy'); + const extension = { + method(ev, editableNode, sel, other) { + methodSpy(ev, editableNode, sel, other); + }, + }; + adapter.adaptMethod(extension, 'method'); + extension.method({editor, event, extra}); + expect(methodSpy).toHaveBeenCalledWith(event, node, selection, extra); + }); + + it('adapts correctly when signature is (editableNode, selection, ...)', ()=> { + const methodSpy = jasmine.createSpy('methodSpy'); + const extension = { + method(editableNode, sel, ev, other) { + methodSpy(editableNode, sel, ev, other); + }, + }; + adapter.adaptMethod(extension, 'method'); + extension.method({editor, event, extra}); + expect(methodSpy).toHaveBeenCalledWith(node, selection, event, extra); + }); + + it('adapts correctly when using mutations instead of an event', ()=> { + const methodSpy = jasmine.createSpy('methodSpy'); + const extension = { + method(editor, mutations) { + methodSpy(editor, mutations); + }, + }; + adapter.adaptMethod(extension, 'method'); + extension.method({editor, mutations: []}); + expect(methodSpy).toHaveBeenCalledWith(editor, []); + }); + }); +}); diff --git a/spec/stores/draft-store-spec.coffee b/spec/stores/draft-store-spec.coffee index ba0e5ddbd..5d065c252 100644 --- a/spec/stores/draft-store-spec.coffee +++ b/spec/stores/draft-store-spec.coffee @@ -32,7 +32,7 @@ fakeMessageWithFiles = null msgWithReplyToDuplicates = null class TestExtension extends ComposerExtension - @prepareNewDraft: (draft) -> + @prepareNewDraft: ({draft}) -> draft.body = "Edited by TestExtension!" + draft.body describe "DraftStore", -> diff --git a/src/components/contenteditable/contenteditable.cjsx b/src/components/contenteditable/contenteditable.cjsx index b9ec09a46..cd6900de3 100644 --- a/src/components/contenteditable/contenteditable.cjsx +++ b/src/components/contenteditable/contenteditable.cjsx @@ -81,7 +81,7 @@ class Contenteditable extends React.Component Edits made within the editing function will eventually fire _onDOMMutated ### - atomicEdit: (editingFunction, extraArgs...) => + atomicEdit: (editingFunction, extraArgsObj={}) => @_teardownListeners() editor = new EditorAPI(@_editableNode()) @@ -89,14 +89,14 @@ class Contenteditable extends React.Component if not editor.currentSelection().isInScope() editor.importSelection(@innerState.exportedSelection) - args = [editor, extraArgs...] - editingFunction.apply(null, args) + argsObj = _.extend(extraArgsObj, {editor}) + editingFunction(argsObj) @_setupListeners() focus: => @_editableNode().focus() - selectEnd: => @atomicEdit (editor) -> editor.selectEnd() + selectEnd: => @atomicEdit ({editor}) -> editor.selectEnd() ######################################################################## @@ -202,7 +202,7 @@ class Contenteditable extends React.Component _keymapHandlers: -> atomicEditWrap = (command) => (event) => - @atomicEdit(((editor) -> editor[command]()), event) + @atomicEdit((({editor}) -> editor[command]()), event) keymapHandlers = { 'contenteditable:bold': atomicEditWrap("bold") @@ -253,7 +253,7 @@ class Contenteditable extends React.Component @setInnerState dragging: false if @innerState.dragging @setInnerState doubleDown: false if @innerState.doubleDown - @_runCallbackOnExtensions("onContentChanged", mutations) + @_runCallbackOnExtensions("onContentChanged", {mutations}) @_saveSelectionState() @@ -306,7 +306,7 @@ class Contenteditable extends React.Component menu = new Menu() - @dispatchEventToExtensions("onShowContextMenu", event, menu) + @dispatchEventToExtensions("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'})) @@ -320,9 +320,9 @@ class Contenteditable extends React.Component ############################# Extensions ############################### ######################################################################## - _runCallbackOnExtensions: (method, args...) => + _runCallbackOnExtensions: (method, argsObj={}) => for extension in @props.extensions.concat(@coreExtensions) - @_runExtensionMethod(extension, method, args...) + @_runExtensionMethod(extension, method, argsObj) # Will execute the event handlers on each of the registerd and core # extensions In this context, event.preventDefault and @@ -334,20 +334,21 @@ class Contenteditable extends React.Component # basically means preventing 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. - dispatchEventToExtensions: (method, event, args...) => + dispatchEventToExtensions: (method, event, args={}) => + argsObj = _.extend(args, {event}) for extension in @props.extensions break if event?.isPropagationStopped() - @_runExtensionMethod(extension, method, event, args...) + @_runExtensionMethod(extension, method, argsObj) return if event?.defaultPrevented or event?.isPropagationStopped() for extension in @coreExtensions break if event?.isPropagationStopped() - @_runExtensionMethod(extension, method, event, args...) + @_runExtensionMethod(extension, method, argsObj) - _runExtensionMethod: (extension, method, args...) => + _runExtensionMethod: (extension, method, argsObj={}) => return if not extension[method]? editingFunction = extension[method].bind(extension) - @atomicEdit(editingFunction, args...) + @atomicEdit(editingFunction, argsObj) ######################################################################## diff --git a/src/components/contenteditable/dom-normalizer.coffee b/src/components/contenteditable/dom-normalizer.coffee index 8b6c6662c..92f6617ca 100644 --- a/src/components/contenteditable/dom-normalizer.coffee +++ b/src/components/contenteditable/dom-normalizer.coffee @@ -9,7 +9,7 @@ class DOMNormalizer extends ContenteditableExtension # structures, a simple replacement of the DOM is not easy. There are a # variety of edge cases that we need to correct for and prepare both the # HTML and the selection to be serialized without error. - @onContentChanged: (editor, mutations) -> + @onContentChanged: ({editor, mutations}) -> @_cleanHTML(editor) @_cleanSelection(editor) diff --git a/src/components/contenteditable/floating-toolbar-container.cjsx b/src/components/contenteditable/floating-toolbar-container.cjsx index 20cce7981..3f81fbb96 100644 --- a/src/components/contenteditable/floating-toolbar-container.cjsx +++ b/src/components/contenteditable/floating-toolbar-container.cjsx @@ -94,7 +94,7 @@ class FloatingToolbarContainer extends React.Component onDoneWithLink={@_onDoneWithLink} /> _onSaveUrl: (url, linkToModify) => - @props.atomicEdit (editor) -> + @props.atomicEdit ({editor}) -> if linkToModify? equivalentNode = DOMUtils.findSimilarNodes(editor.rootNode, linkToModify)?[0] return unless equivalentNode? @@ -118,8 +118,9 @@ class FloatingToolbarContainer extends React.Component # core actions and user-defined plugins. The FloatingToolbar simply # renders them. _toolbarButtonConfigs: -> - atomicEditWrap = (command) => (event) => - @props.atomicEdit(((editor) -> editor[command]()), event) + atomicEditWrap = (command) => + (event) => + @props.atomicEdit((({editor}) -> editor[command]()), event) extensionButtonConfigs = [] ExtensionRegistry.Composer.extensions().forEach (ext) -> diff --git a/src/components/contenteditable/list-manager.coffee b/src/components/contenteditable/list-manager.coffee index 950682ba3..8fdd15ee0 100644 --- a/src/components/contenteditable/list-manager.coffee +++ b/src/components/contenteditable/list-manager.coffee @@ -2,13 +2,13 @@ _str = require 'underscore.string' {DOMUtils, ContenteditableExtension} = require 'nylas-exports' class ListManager extends ContenteditableExtension - @onContentChanged: (editor, mutations) -> + @onContentChanged: ({editor, mutations}) -> if @_spaceEntered and @hasListStartSignature(editor.currentSelection()) @createList(editor) @_collapseAdjacentLists(editor) - @onKeyDown: (editor, event) -> + @onKeyDown: ({editor, event}) -> @_spaceEntered = event.key is " " if DOMUtils.isInList() if event.key is "Backspace" and DOMUtils.atStartOfList() diff --git a/src/components/contenteditable/tab-manager.coffee b/src/components/contenteditable/tab-manager.coffee index 148b2752b..a5e87bdc5 100644 --- a/src/components/contenteditable/tab-manager.coffee +++ b/src/components/contenteditable/tab-manager.coffee @@ -1,7 +1,7 @@ {DOMUtils, ContenteditableExtension} = require 'nylas-exports' class TabManager extends ContenteditableExtension - @onKeyDown: (editor, event) -> + @onKeyDown: ({editor, event}) -> # This is a special case where we don't want to bubble up the event to # the keymap manager if the extension prevented the default behavior if event.defaultPrevented diff --git a/src/extension-registry.es6 b/src/extension-registry.es6 index bd4a10396..f35310ed6 100644 --- a/src/extension-registry.es6 +++ b/src/extension-registry.es6 @@ -1,7 +1,8 @@ - import _ from 'underscore'; import {Listener, Publisher} from './flux/modules/reflux-coffee'; import {includeModule} from './flux/coffee-helpers'; +import composerExtAdapter from './extensions/composer-extension-adapter'; +import messageViewExtAdapter from './extensions/message-view-extension-adapter'; export class Registry { @@ -55,9 +56,10 @@ Registry.include(Listener); export const Composer = new Registry( 'Composer', - require('./extensions/composer-extension-adapter') + composerExtAdapter ); export const MessageView = new Registry( 'MessageView', + messageViewExtAdapter ); diff --git a/src/extensions/composer-extension-adapter.coffee b/src/extensions/composer-extension-adapter.coffee deleted file mode 100644 index 7c45e483a..000000000 --- a/src/extensions/composer-extension-adapter.coffee +++ /dev/null @@ -1,51 +0,0 @@ -_ = require 'underscore' -{deprecate} = require '../deprecate-utils' -DOMUtils = require '../dom-utils' - -ComposerExtensionAdapter = (extension) -> - - if extension.onInput? - origInput = extension.onInput - extension.onContentChanged = (editor, mutations) -> - origInput(editor.rootNode) - - extension.onInput = deprecate( - "DraftStoreExtension.onInput", - "ComposerExtension.onContentChanged", - extension, - extension.onContentChanged - ) - - if extension.onTabDown? - origKeyDown = extension.onKeyDown - extension.onKeyDown = (editor, event) -> - if event.key is "Tab" - range = DOMUtils.getRangeInScope(editor.rootNode) - extension.onTabDown(editor.rootNode, range, event) - else - origKeyDown?(event, editor.rootNode, editor.currentSelection()) - - extension.onKeyDown = deprecate( - "DraftStoreExtension.onTabDown", - "ComposerExtension.onKeyDown", - extension, - extension.onKeyDown - ) - - if extension.onMouseUp? - origOnClick = extension.onClick - extension.onClick = (editor, event) -> - range = DOMUtils.getRangeInScope(editor.rootNode) - extension.onMouseUp(editor.rootNode, range, event) - origOnClick?(event, editor.rootNode, editor.currentSelection()) - - extension.onClick = deprecate( - "DraftStoreExtension.onMouseUp", - "ComposerExtension.onClick", - extension, - extension.onClick - ) - - return extension - -module.exports = ComposerExtensionAdapter diff --git a/src/extensions/composer-extension-adapter.es6 b/src/extensions/composer-extension-adapter.es6 new file mode 100644 index 000000000..0abdd8c01 --- /dev/null +++ b/src/extensions/composer-extension-adapter.es6 @@ -0,0 +1,114 @@ +import _ from 'underscore'; +import DOMUtils from '../dom-utils'; +import {deprecate} from '../deprecate-utils'; +import {getFunctionArgs} from './extension-utils'; + +export function isUsingOutdatedAPI(func) { + // Might not always be true, but it is our best guess + const firstArg = getFunctionArgs(func)[0]; + if (func.length > 1) return true; // Not using a named arguments hash + return ( + firstArg.includes('ev') || + firstArg.includes('node') || + firstArg.includes('Node') + ); +} + +export function adaptMethod(extension, method, original = extension[method]) { + // Check if it is using old API + if (!original || !isUsingOutdatedAPI(original)) return; + + let deprecatedArgs = ''; + extension[method] = (argsObj)=> { + const {editor, event, mutations} = argsObj; + const eventOrMutations = event || mutations || {}; + const extraArgs = _.keys(_.omit(argsObj, ['editor', 'event', 'mutations'])).map( + key => argsObj[key] + ); + + // This is our best guess at the function signature that is being used + const firstArg = getFunctionArgs(original)[0]; + if (firstArg.includes('editor')) { + deprecatedArgs = '(editor, ...)'; + original(editor, eventOrMutations, ...extraArgs); + } else if (firstArg.includes('ev')) { + deprecatedArgs = '(event, editableNode, selection, ...)'; + original(eventOrMutations, editor.rootNode, editor.currentSelection(), ...extraArgs); + } else { + deprecatedArgs = '(editableNode, selection, ...)'; + original(editor.rootNode, editor.currentSelection(), eventOrMutations, ...extraArgs); + } + }; + + extension[method] = deprecate( + `ComposerExtension.${method}${deprecatedArgs}`, + `ComposerExtension.${method}(args = {editor, ...})`, + extension, + extension[method] + ); +} + +export function adaptOnInput(extension) { + if (extension.onContentChanged != null) return; + adaptMethod(extension, 'onContentChanged', extension.onInput); +} + +export function adaptOnTabDown(extension) { + if (!extension.onTabDown) return; + const origOnKeyDown = extension.onKeyDown; + extension.onKeyDown = ({editor, event})=> { + if (event.key === 'Tab') { + const range = DOMUtils.getRangeInScope(editor.rootNode); + extension.onTabDown(editor.rootNode, range, event); + } else { + // At this point, onKeyDown should have already been adapted + if (origOnKeyDown != null) origOnKeyDown(editor, event); + } + }; + + extension.onKeyDown = deprecate( + 'DraftStoreExtension.onTabDown', + 'ComposerExtension.onKeyDown', + extension, + extension.onKeyDown + ); +} + +export function adaptOnMouseUp(extension) { + if (!extension.onMouseUp) return; + const origOnClick = extension.onClick; + extension.onClick = ({editor, event})=> { + const range = DOMUtils.getRangeInScope(editor.rootNode); + extension.onMouseUp(editor.rootNode, range, event); + // At this point, onClick should have already been adapted + if (origOnClick != null) origOnClick(editor, event); + }; + + extension.onClick = deprecate( + 'DraftStoreExtension.onMouseUp', + 'ComposerExtension.onClick', + extension, + extension.onClick + ); +} + +export default function adaptExtension(extension) { + const standardMethods = [ + 'onContentChanged', + 'onBlur', + 'onFocus', + 'onClick', + 'onKeyDown', + 'onShowContextMenu', + ]; + standardMethods.forEach( + method => adaptMethod(extension, method) + ); + + // Special cases + adaptOnInput(extension); + adaptOnTabDown(extension); + adaptOnMouseUp(extension); + + return extension; +} diff --git a/src/extensions/composer-extension.coffee b/src/extensions/composer-extension.coffee index 8f34d0411..183bb5668 100644 --- a/src/extensions/composer-extension.coffee +++ b/src/extensions/composer-extension.coffee @@ -47,7 +47,7 @@ class ComposerExtension extends ContenteditableExtension Returns a list of warning strings, or an empty array if no warnings need to be displayed. ### - @warningsForSending: (draft) -> + @warningsForSending: ({draft}) -> [] ### @@ -87,7 +87,7 @@ class ComposerExtension extends ContenteditableExtension valuable way, you should set `draft.pristine = false` so the draft saves, even if no further changes are made. ### - @prepareNewDraft: (draft) -> + @prepareNewDraft: ({draft}) -> return ### @@ -103,14 +103,14 @@ class ComposerExtension extends ContenteditableExtension ```coffee # Remove any tags found in the draft body - finalizeSessionBeforeSending: (session) -> + finalizeSessionBeforeSending: ({session}) -> body = session.draft().body clean = body.replace(/<\/?code[^>]*>/g, '') if body != clean session.changes.add(body: clean) ``` ### - @finalizeSessionBeforeSending: (session) -> + @finalizeSessionBeforeSending: ({session}) -> return module.exports = ComposerExtension diff --git a/src/extensions/contenteditable-extension.coffee b/src/extensions/contenteditable-extension.coffee index 6cfaa6bfa..09e947754 100644 --- a/src/extensions/contenteditable-extension.coffee +++ b/src/extensions/contenteditable-extension.coffee @@ -64,7 +64,7 @@ class ContenteditableExtension reflect that it is no longer empty. ```coffee - onContentChanged: (editor, mutations) -> + onContentChanged: ({editor, mutations}) -> isWithinNode = (node) -> test = selection.baseNode while test isnt editableNode @@ -78,9 +78,9 @@ class ContenteditableExtension codeTag.classList.remove('empty') ``` ### - @onContentChanged: (editor, mutations) -> + @onContentChanged: ({editor, mutations}) -> - @onContentStoppedChanging: (editor, mutations) -> + @onContentStoppedChanging: ({editor, mutations}) -> ### Public: Override onBlur to mutate the contenteditable DOM node whenever the @@ -91,7 +91,7 @@ class ContenteditableExtension methods for manipulating the selection and DOM - event: DOM event fired on the contenteditable ### - @onBlur: (editor, event) -> + @onBlur: ({editor, event}) -> ### Public: Override onFocus to mutate the contenteditable DOM node whenever the @@ -102,7 +102,7 @@ class ContenteditableExtension methods for manipulating the selection and DOM - event: DOM event fired on the contenteditable ### - @onFocus: (editor, event) -> + @onFocus: ({editor, event}) -> ### Public: Override onClick to mutate the contenteditable DOM node whenever the @@ -113,7 +113,7 @@ class ContenteditableExtension methods for manipulating the selection and DOM - event: DOM event fired on the contenteditable ### - @onClick: (editor, event) -> + @onClick: ({editor, event}) -> ### Public: Override onKeyDown to mutate the contenteditable DOM node whenever the @@ -133,12 +133,11 @@ class ContenteditableExtension methods for manipulating the selection and DOM - event: DOM event fired on the contenteditable ### - @onKeyDown: (editor, event) -> + @onKeyDown: ({editor, event}) -> ### - 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. + Public: Override onShowContextMenu to add new menu items to the right click menu + inside the contenteditable. - editor: The {Editor} controller that provides a host of convenience methods for manipulating the selection and DOM @@ -147,6 +146,6 @@ class ContenteditableExtension 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: (editor, event, menu) -> + @onShowContextMenu: ({editor, event, menu}) -> module.exports = ContenteditableExtension diff --git a/src/extensions/extension-utils.es6 b/src/extensions/extension-utils.es6 new file mode 100644 index 000000000..4c86292a7 --- /dev/null +++ b/src/extensions/extension-utils.es6 @@ -0,0 +1,7 @@ +import RegExpUtils from '../regexp-utils'; + +export function getFunctionArgs(func) { + const match = func.toString().match(RegExpUtils.functionArgs()); + if (!match) return null; + return match[1].split(/\s*,\s*/); +} diff --git a/src/extensions/message-view-extension-adapter.es6 b/src/extensions/message-view-extension-adapter.es6 new file mode 100644 index 000000000..2ab30ebe9 --- /dev/null +++ b/src/extensions/message-view-extension-adapter.es6 @@ -0,0 +1,21 @@ +import {getFunctionArgs} from './extension-utils'; + +export function isUsingOutdatedAPI(func) { + // Might not always be true, but it is our best guess + const firstArg = getFunctionArgs(func)[0]; + return ( + firstArg.includes('mes') || + firstArg.includes('msg') || + firstArg.includes('body') || + firstArg.includes('draft') + ); +} + +export default function adaptExtension(extension) { + const original = extension.formatMessageBody; + if (!original || !isUsingOutdatedAPI(original)) return extension; + extension.formatMessageBody = ({message})=> { + original(message); + }; + return extension; +} diff --git a/src/extensions/message-view-extension.coffee b/src/extensions/message-view-extension.coffee index 73e176a81..f47dcb72f 100644 --- a/src/extensions/message-view-extension.coffee +++ b/src/extensions/message-view-extension.coffee @@ -28,7 +28,7 @@ class MessageViewExtension Public: Transform the message body HTML provided in `body` and return HTML that should be displayed for the message. ### - @formatMessageBody: (body) -> + @formatMessageBody: ({message}) -> return body module.exports = MessageViewExtension diff --git a/src/flux/stores/draft-store.coffee b/src/flux/stores/draft-store.coffee index 149336051..3d1a80ed9 100644 --- a/src/flux/stores/draft-store.coffee +++ b/src/flux/stores/draft-store.coffee @@ -238,7 +238,7 @@ class DraftStore # Give extensions an opportunity to perform additional setup to the draft for extension in @extensions() continue unless extension.prepareNewDraft - extension.prepareNewDraft(draft) + extension.prepareNewDraft({draft}) # Optimistically create a draft session and hand it the draft so that it # doesn't need to do a query for it a second from now when the composer wants it. @@ -535,7 +535,7 @@ class DraftStore _runExtensionsBeforeSend: (session) -> for extension in @extensions() continue unless extension.finalizeSessionBeforeSending - extension.finalizeSessionBeforeSending(session) + extension.finalizeSessionBeforeSending({session}) _onRemoveFile: ({file, messageClientId}) => @sessionForClientId(messageClientId).then (session) -> diff --git a/src/flux/stores/message-body-processor.coffee b/src/flux/stores/message-body-processor.coffee index 5ba2c17a5..a6669c14e 100644 --- a/src/flux/stores/message-body-processor.coffee +++ b/src/flux/stores/message-body-processor.coffee @@ -51,7 +51,7 @@ class MessageBodyProcessor continue unless extension.formatMessageBody virtual = message.clone() virtual.body = body - extension.formatMessageBody(virtual) + extension.formatMessageBody({message: virtual}) body = virtual.body # Find inline images and give them a calculated CSS height based on diff --git a/src/regexp-utils.coffee b/src/regexp-utils.coffee index e321d4ff3..ac50430ec 100644 --- a/src/regexp-utils.coffee +++ b/src/regexp-utils.coffee @@ -29,4 +29,8 @@ RegExpUtils = looseStyleTag: -> /