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
This commit is contained in:
Juan Tejada 2015-12-30 11:36:47 -05:00
parent 4628643195
commit 6315bc9d80
33 changed files with 374 additions and 151 deletions

View file

@ -14,6 +14,7 @@
"eqeqeq": [2, "smart"], "eqeqeq": [2, "smart"],
"id-length": [0], "id-length": [0],
"no-loop-func": [0], "no-loop-func": [0],
"new-cap": [2, {"capIsNew": false}] "new-cap": [2, {"capIsNew": false}],
"no-shadow": [1]
} }
} }

View file

@ -27,7 +27,7 @@ This extension displays a warning before sending a draft that contains the names
class ProductsExtension extends ComposerExtension class ProductsExtension extends ComposerExtension
@warningsForSending: (draft) -> @warningsForSending: ({draft}) ->
words = ['acme', 'anvil', 'tunnel', 'rocket', 'dynamite'] words = ['acme', 'anvil', 'tunnel', 'rocket', 'dynamite']
body = draft.body.toLowercase() body = draft.body.toLowercase()
for word in words for word in words
@ -35,9 +35,9 @@ class ProductsExtension extends ComposerExtension
return ["with the word '#{word}'?"] return ["with the word '#{word}'?"]
return [] return []
@finalizeSessionBeforeSending: (session) -> @finalizeSessionBeforeSending: ({session}) ->
draft = session.draft() draft = session.draft()
if @warningsForSending(draft) if @warningsForSending({draft})
bodyWithWarning = draft.body += "<br>This email \ bodyWithWarning = draft.body += "<br>This email \
contains competitor's product names \ contains competitor's product names \
or trademarks used in context." or trademarks used in context."

View file

@ -2,7 +2,7 @@ import {DOMUtils, ComposerExtension} from 'nylas-exports';
class TemplatesComposerExtension extends ComposerExtension { class TemplatesComposerExtension extends ComposerExtension {
static warningsForSending(draft) { static warningsForSending({draft}) {
const warnings = []; const warnings = [];
if (draft.body.search(/<code[^>]*empty[^>]*>/i) > 0) { if (draft.body.search(/<code[^>]*empty[^>]*>/i) > 0) {
warnings.push('with an empty template area'); warnings.push('with an empty template area');
@ -10,7 +10,7 @@ class TemplatesComposerExtension extends ComposerExtension {
return warnings; return warnings;
} }
static finalizeSessionBeforeSending(session) { static finalizeSessionBeforeSending({session}) {
const body = session.draft().body; const body = session.draft().body;
const clean = body.replace(/<\/?code[^>]*>/g, ''); const clean = body.replace(/<\/?code[^>]*>/g, '');
if (body !== clean) { if (body !== clean) {
@ -18,32 +18,33 @@ class TemplatesComposerExtension extends ComposerExtension {
} }
} }
static onClick(editor, event) { static onClick({editor, event}) {
var node = event.target; const node = event.target;
if(node.nodeName === "CODE" && node.classList.contains("var") && node.classList.contains("empty")) { if (node.nodeName === 'CODE' && node.classList.contains('var') && node.classList.contains('empty')) {
editor.selectAllChildren(node) editor.selectAllChildren(node);
} }
} }
static onKeyDown(editor, event) { static onKeyDown({editor, event}) {
const editableNode = editor.rootNode; const editableNode = editor.rootNode;
if (event.key === 'Tab') { if (event.key === 'Tab') {
const nodes = editableNode.querySelectorAll('code.var'); const nodes = editableNode.querySelectorAll('code.var');
if (nodes.length > 0) { if (nodes.length > 0) {
let sel = editor.currentSelection(); const sel = editor.currentSelection();
let found = false; let found = false;
// First, try to find a <code> that the selection is within. If found, // First, try to find a <code> that the selection is within. If found,
// select the next/prev node if the selection ends at the end of the // select the next/prev node if the selection ends at the end of the
// <code>'s text, otherwise select the <code>'s contents. // <code>'s text, otherwise select the <code>'s contents.
for (let i = 0; i < nodes.length; i++) { for (let i = 0; i < nodes.length; i++) {
let node = nodes[i]; const node = nodes[i];
if (DOMUtils.selectionIsWithin(node)) { if (DOMUtils.selectionIsWithin(node)) {
let selIndex = editor.getSelectionTextIndex(node); const selIndex = editor.getSelectionTextIndex(node);
let length = DOMUtils.getIndexedTextContent(node).slice(-1)[0].end; const length = DOMUtils.getIndexedTextContent(node).slice(-1)[0].end;
let nextIndex = i; let nextIndex = i;
if(selIndex.endIndex === length) if (selIndex.endIndex === length) {
nextIndex = event.shiftKey ? i - 1 : i + 1; nextIndex = event.shiftKey ? i - 1 : i + 1;
}
nextIndex = (nextIndex + nodes.length) % nodes.length; // allow wraparound in both directions nextIndex = (nextIndex + nodes.length) % nodes.length; // allow wraparound in both directions
sel.selectAllChildren(nodes[nextIndex]); sel.selectAllChildren(nodes[nextIndex]);
found = true; found = true;
@ -54,13 +55,13 @@ class TemplatesComposerExtension extends ComposerExtension {
// If we failed to find a <code> that the selection is within, select the // If we failed to find a <code> that the selection is within, select the
// nearest <code> before/after the selection (depending on shift). // nearest <code> before/after the selection (depending on shift).
if (!found) { if (!found) {
let treeWalker = document.createTreeWalker(editableNode, NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT); const treeWalker = document.createTreeWalker(editableNode, NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT);
let curIndex = 0, nextIndex = null; let curIndex = 0;
let nextIndex = null;
let node;
while (node = treeWalker.nextNode()) { while (node = treeWalker.nextNode()) {
if(sel.anchorNode === node || sel.focusNode === node) if (sel.anchorNode === node || sel.focusNode === node) break;
break; if (node.nodeName === 'CODE' && node.classList.contains('var')) curIndex++;
if(node.nodeName === "CODE" && node.classList.contains("var"))
curIndex++
} }
nextIndex = event.shiftKey ? curIndex - 1 : curIndex; nextIndex = event.shiftKey ? curIndex - 1 : curIndex;
nextIndex = (nextIndex + nodes.length) % nodes.length; // allow wraparound in both directions nextIndex = (nextIndex + nodes.length) % nodes.length; // allow wraparound in both directions
@ -70,8 +71,7 @@ class TemplatesComposerExtension extends ComposerExtension {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
} }
} } else if (event.key === 'Enter') {
else if(event.key === 'Enter') {
const nodes = editableNode.querySelectorAll('code.var'); const nodes = editableNode.querySelectorAll('code.var');
for (let i = 0; i < nodes.length; i++) { for (let i = 0; i < nodes.length; i++) {
if (DOMUtils.selectionStartsOrEndsIn(nodes[i])) { if (DOMUtils.selectionStartsOrEndsIn(nodes[i])) {
@ -83,9 +83,9 @@ class TemplatesComposerExtension extends ComposerExtension {
} }
} }
static onContentChanged(editor) { static onContentChanged({editor}) {
editableNode = editor.rootNode; const editableNode = editor.rootNode;
selection = editor.currentSelection().rawSelection; const selection = editor.currentSelection().rawSelection;
const isWithinNode = (node)=> { const isWithinNode = (node)=> {
let test = selection.baseNode; let test = selection.baseNode;
while (test !== editableNode) { while (test !== editableNode) {

View file

@ -6,7 +6,7 @@ class AvailabilityComposerExtension extends ComposerExtension
# When subclassing the ComposerExtension, you can add your own custom logic # When subclassing the ComposerExtension, you can add your own custom logic
# to execute before a draft is sent in the @finalizeSessionBeforeSending # to execute before a draft is sent in the @finalizeSessionBeforeSending
# method. Here, we're registering the events before we send the draft. # method. Here, we're registering the events before we send the draft.
@finalizeSessionBeforeSending: (session) -> @finalizeSessionBeforeSending: ({session}) ->
body = session.draft().body body = session.draft().body
participants = session.draft().participants() participants = session.draft().participants()
sender = session.draft().from sender = session.draft().from

View file

@ -1,7 +1,7 @@
{ComposerExtension, AccountStore} = require 'nylas-exports' {ComposerExtension, AccountStore} = require 'nylas-exports'
class SignatureComposerExtension extends ComposerExtension class SignatureComposerExtension extends ComposerExtension
@prepareNewDraft: (draft) -> @prepareNewDraft: ({draft}) ->
accountId = AccountStore.current().id accountId = AccountStore.current().id
signature = NylasEnv.config.get("nylas.account-#{accountId}.signature") signature = NylasEnv.config.get("nylas.account-#{accountId}.signature")
return unless signature return unless signature

View file

@ -18,9 +18,9 @@ describe "SignatureComposerExtension", ->
draft: true draft: true
body: 'This is a another test.' body: 'This is a another test.'
SignatureComposerExtension.prepareNewDraft(a) SignatureComposerExtension.prepareNewDraft(draft: a)
expect(a.body).toEqual('This is a test! <br/><div class="nylas-n1-signature"><div id="signature">This is my signature.</div></div><blockquote>Hello world</blockquote>') expect(a.body).toEqual('This is a test! <br/><div class="nylas-n1-signature"><div id="signature">This is my signature.</div></div><blockquote>Hello world</blockquote>')
SignatureComposerExtension.prepareNewDraft(b) SignatureComposerExtension.prepareNewDraft(draft: b)
expect(b.body).toEqual('This is a another test.<br/><div class="nylas-n1-signature"><div id="signature">This is my signature.</div></div>') expect(b.body).toEqual('This is a another test.<br/><div class="nylas-n1-signature"><div id="signature">This is my signature.</div></div>')
describe "when a signature is not defined", -> describe "when a signature is not defined", ->
@ -32,5 +32,5 @@ describe "SignatureComposerExtension", ->
a = new Message a = new Message
draft: true draft: true
body: 'This is a test! <blockquote>Hello world</blockquote>' body: 'This is a test! <blockquote>Hello world</blockquote>'
SignatureComposerExtension.prepareNewDraft(a) SignatureComposerExtension.prepareNewDraft(draft: a)
expect(a.body).toEqual('This is a test! <blockquote>Hello world</blockquote>') expect(a.body).toEqual('This is a test! <blockquote>Hello world</blockquote>')

View file

@ -12,10 +12,10 @@ class SpellcheckComposerExtension extends ComposerExtension
SpellcheckCache[word] ?= spellchecker.isMisspelled(word) SpellcheckCache[word] ?= spellchecker.isMisspelled(word)
SpellcheckCache[word] SpellcheckCache[word]
@onInput: (editableNode) => @onContentChanged: ({editor}) =>
@walkTree(editableNode) @walkTree(editor.rootNode)
@onShowContextMenu: (editor, event, menu) => @onShowContextMenu: ({editor, event, menu}) =>
selection = editor.currentSelection() selection = editor.currentSelection()
editableNode = editor.rootNode editableNode = editor.rootNode
range = DOMUtils.Mutating.getRangeAtAndSelectWord(selection, 0) range = DOMUtils.Mutating.getRangeAtAndSelectWord(selection, 0)
@ -119,7 +119,7 @@ class SpellcheckComposerExtension extends ComposerExtension
if selectionImpacted if selectionImpacted
selection.setBaseAndExtent(selectionSnapshot.anchorNode, selectionSnapshot.anchorOffset, selectionSnapshot.focusNode, selectionSnapshot.focusOffset) selection.setBaseAndExtent(selectionSnapshot.anchorNode, selectionSnapshot.anchorOffset, selectionSnapshot.focusNode, selectionSnapshot.focusOffset)
@finalizeSessionBeforeSending: (session) -> @finalizeSessionBeforeSending: ({session}) ->
body = session.draft().body body = session.draft().body
clean = body.replace(/<\/?spelling[^>]*>/g, '') clean = body.replace(/<\/?spelling[^>]*>/g, '')
if body != clean if body != clean

View file

@ -27,7 +27,7 @@ describe "SpellcheckComposerExtension", ->
changes: changes:
add: jasmine.createSpy('add') add: jasmine.createSpy('add')
SpellcheckComposerExtension.finalizeSessionBeforeSending(session) SpellcheckComposerExtension.finalizeSessionBeforeSending({session})
expect(session.changes.add).toHaveBeenCalledWith(body: initialHTML) expect(session.changes.add).toHaveBeenCalledWith(body: initialHTML)
module.exports = SpellcheckComposerExtension module.exports = SpellcheckComposerExtension

View file

@ -739,7 +739,7 @@ class ComposerView extends React.Component
# Check third party warnings added via Composer extensions # Check third party warnings added via Composer extensions
for extension in ExtensionRegistry.Composer.extensions() for extension in ExtensionRegistry.Composer.extensions()
continue unless extension.warningsForSending continue unless extension.warningsForSending
warnings = warnings.concat(extension.warningsForSending(draft)) warnings = warnings.concat(extension.warningsForSending({draft}))
if warnings.length > 0 and not options.force if warnings.length > 0 and not options.force
response = dialog.showMessageBox(remote.getCurrentWindow(), { response = dialog.showMessageBox(remote.getCurrentWindow(), {

View file

@ -3,7 +3,7 @@ AutoloadImagesStore = require './autoload-images-store'
class AutoloadImagesExtension extends MessageViewExtension class AutoloadImagesExtension extends MessageViewExtension
@formatMessageBody: (message) -> @formatMessageBody: ({message}) ->
if AutoloadImagesStore.shouldBlockImagesIn(message) if AutoloadImagesStore.shouldBlockImagesIn(message)
message.body = message.body.replace AutoloadImagesStore.ImagesRegexp, (match, prefix, imageUrl) -> message.body = message.body.replace AutoloadImagesStore.ImagesRegexp, (match, prefix, imageUrl) ->
"#{prefix}#" "#{prefix}#"

View file

@ -18,7 +18,7 @@ describe "AutoloadImagesExtension", ->
spyOn(AutoloadImagesStore, 'shouldBlockImagesIn').andReturn(true) spyOn(AutoloadImagesStore, 'shouldBlockImagesIn').andReturn(true)
message = message =
body: scenario.in body: scenario.in
AutoloadImagesExtension.formatMessageBody(message) AutoloadImagesExtension.formatMessageBody({message})
expect(message.body == scenario.out).toBe(true) expect(message.body == scenario.out).toBe(true)
module.exports = AutoloadImagesExtension module.exports = AutoloadImagesExtension

View file

@ -3,7 +3,7 @@ Autolinker = require 'autolinker'
class AutolinkerExtension extends MessageViewExtension class AutolinkerExtension extends MessageViewExtension
@formatMessageBody: (message) -> @formatMessageBody: ({message}) ->
# Apply the autolinker pass to make emails and links clickable # Apply the autolinker pass to make emails and links clickable
message.body = Autolinker.link(message.body, {twitter: false}) message.body = Autolinker.link(message.body, {twitter: false})

View file

@ -100,7 +100,7 @@ TrackingBlacklist = [{
class TrackingPixelsExtension extends MessageViewExtension class TrackingPixelsExtension extends MessageViewExtension
@formatMessageBody: (message) -> @formatMessageBody: ({message}) ->
return unless message.isFromMe() return unless message.isFromMe()
regex = RegExpUtils.imageTagRegex() regex = RegExpUtils.imageTagRegex()

View file

@ -6,7 +6,7 @@ describe "AutolinkerExtension", ->
spyOn(Autolinker, 'link').andCallFake (txt) => txt spyOn(Autolinker, 'link').andCallFake (txt) => txt
it "should call through to Autolinker", -> it "should call through to Autolinker", ->
AutolinkerExtension.formatMessageBody({body:'body'}) AutolinkerExtension.formatMessageBody(message: {body:'body'})
expect(Autolinker.link).toHaveBeenCalledWith('body', {twitter: false}) expect(Autolinker.link).toHaveBeenCalledWith('body', {twitter: false})
it "should add a title to everything with an href", -> it "should add a title to everything with an href", ->
@ -24,5 +24,5 @@ describe "AutolinkerExtension", ->
<a href ='http://apple.com' title='http://apple.com' >hello world!</a> <a href ='http://apple.com' title='http://apple.com' >hello world!</a>
<a href ='mailto://' title='mailto://' >hello world!</a> <a href ='mailto://' title='mailto://' >hello world!</a>
""" """
AutolinkerExtension.formatMessageBody(message) AutolinkerExtension.formatMessageBody({message})
expect(message.body).toEqual(expected.body) expect(message.body).toEqual(expected.body)

View file

@ -22,10 +22,10 @@ describe "TrackingPixelsExtension", ->
it "should splice tracking pixels and only run on messages by the current user", -> it "should splice tracking pixels and only run on messages by the current user", ->
message = new Message(body: testBody) message = new Message(body: testBody)
spyOn(message, 'isFromMe').andCallFake -> false spyOn(message, 'isFromMe').andCallFake -> false
TrackingPixelsExtension.formatMessageBody(message) TrackingPixelsExtension.formatMessageBody({message})
expect(message.body).toEqual(testBody) expect(message.body).toEqual(testBody)
message = new Message(body: testBody) message = new Message(body: testBody)
spyOn(message, 'isFromMe').andCallFake -> true spyOn(message, 'isFromMe').andCallFake -> true
TrackingPixelsExtension.formatMessageBody(message) TrackingPixelsExtension.formatMessageBody({message})
expect(message.body).toEqual(testBodyProcessed) expect(message.body).toEqual(testBodyProcessed)

View file

@ -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, []);
});
});
});

View file

@ -32,7 +32,7 @@ fakeMessageWithFiles = null
msgWithReplyToDuplicates = null msgWithReplyToDuplicates = null
class TestExtension extends ComposerExtension class TestExtension extends ComposerExtension
@prepareNewDraft: (draft) -> @prepareNewDraft: ({draft}) ->
draft.body = "Edited by TestExtension!" + draft.body draft.body = "Edited by TestExtension!" + draft.body
describe "DraftStore", -> describe "DraftStore", ->

View file

@ -81,7 +81,7 @@ class Contenteditable extends React.Component
Edits made within the editing function will eventually fire _onDOMMutated Edits made within the editing function will eventually fire _onDOMMutated
### ###
atomicEdit: (editingFunction, extraArgs...) => atomicEdit: (editingFunction, extraArgsObj={}) =>
@_teardownListeners() @_teardownListeners()
editor = new EditorAPI(@_editableNode()) editor = new EditorAPI(@_editableNode())
@ -89,14 +89,14 @@ class Contenteditable extends React.Component
if not editor.currentSelection().isInScope() if not editor.currentSelection().isInScope()
editor.importSelection(@innerState.exportedSelection) editor.importSelection(@innerState.exportedSelection)
args = [editor, extraArgs...] argsObj = _.extend(extraArgsObj, {editor})
editingFunction.apply(null, args) editingFunction(argsObj)
@_setupListeners() @_setupListeners()
focus: => @_editableNode().focus() focus: => @_editableNode().focus()
selectEnd: => @atomicEdit (editor) -> editor.selectEnd() selectEnd: => @atomicEdit ({editor}) -> editor.selectEnd()
######################################################################## ########################################################################
@ -202,7 +202,7 @@ class Contenteditable extends React.Component
_keymapHandlers: -> _keymapHandlers: ->
atomicEditWrap = (command) => atomicEditWrap = (command) =>
(event) => (event) =>
@atomicEdit(((editor) -> editor[command]()), event) @atomicEdit((({editor}) -> editor[command]()), event)
keymapHandlers = { keymapHandlers = {
'contenteditable:bold': atomicEditWrap("bold") 'contenteditable:bold': atomicEditWrap("bold")
@ -253,7 +253,7 @@ class Contenteditable extends React.Component
@setInnerState dragging: false if @innerState.dragging @setInnerState dragging: false if @innerState.dragging
@setInnerState doubleDown: false if @innerState.doubleDown @setInnerState doubleDown: false if @innerState.doubleDown
@_runCallbackOnExtensions("onContentChanged", mutations) @_runCallbackOnExtensions("onContentChanged", {mutations})
@_saveSelectionState() @_saveSelectionState()
@ -306,7 +306,7 @@ class Contenteditable extends React.Component
menu = new Menu() menu = new Menu()
@dispatchEventToExtensions("onShowContextMenu", event, menu) @dispatchEventToExtensions("onShowContextMenu", event, {menu})
menu.append(new MenuItem({ label: 'Cut', role: 'cut'})) menu.append(new MenuItem({ label: 'Cut', role: 'cut'}))
menu.append(new MenuItem({ label: 'Copy', role: 'copy'})) menu.append(new MenuItem({ label: 'Copy', role: 'copy'}))
menu.append(new MenuItem({ label: 'Paste', role: 'paste'})) menu.append(new MenuItem({ label: 'Paste', role: 'paste'}))
@ -320,9 +320,9 @@ class Contenteditable extends React.Component
############################# Extensions ############################### ############################# Extensions ###############################
######################################################################## ########################################################################
_runCallbackOnExtensions: (method, args...) => _runCallbackOnExtensions: (method, argsObj={}) =>
for extension in @props.extensions.concat(@coreExtensions) 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 # Will execute the event handlers on each of the registerd and core
# extensions In this context, event.preventDefault and # 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 # basically means preventing the core extension handlers from being
# called. If any of the extensions calls event.stopPropagation(), it # called. If any of the extensions calls event.stopPropagation(), it
# will prevent any other extension handlers from being called. # 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 for extension in @props.extensions
break if event?.isPropagationStopped() break if event?.isPropagationStopped()
@_runExtensionMethod(extension, method, event, args...) @_runExtensionMethod(extension, method, argsObj)
return if event?.defaultPrevented or event?.isPropagationStopped() return if event?.defaultPrevented or event?.isPropagationStopped()
for extension in @coreExtensions for extension in @coreExtensions
break if event?.isPropagationStopped() 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]? return if not extension[method]?
editingFunction = extension[method].bind(extension) editingFunction = extension[method].bind(extension)
@atomicEdit(editingFunction, args...) @atomicEdit(editingFunction, argsObj)
######################################################################## ########################################################################

View file

@ -9,7 +9,7 @@ class DOMNormalizer extends ContenteditableExtension
# structures, a simple replacement of the DOM is not easy. There are a # 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 # variety of edge cases that we need to correct for and prepare both the
# HTML and the selection to be serialized without error. # HTML and the selection to be serialized without error.
@onContentChanged: (editor, mutations) -> @onContentChanged: ({editor, mutations}) ->
@_cleanHTML(editor) @_cleanHTML(editor)
@_cleanSelection(editor) @_cleanSelection(editor)

View file

@ -94,7 +94,7 @@ class FloatingToolbarContainer extends React.Component
onDoneWithLink={@_onDoneWithLink} /> onDoneWithLink={@_onDoneWithLink} />
_onSaveUrl: (url, linkToModify) => _onSaveUrl: (url, linkToModify) =>
@props.atomicEdit (editor) -> @props.atomicEdit ({editor}) ->
if linkToModify? if linkToModify?
equivalentNode = DOMUtils.findSimilarNodes(editor.rootNode, linkToModify)?[0] equivalentNode = DOMUtils.findSimilarNodes(editor.rootNode, linkToModify)?[0]
return unless equivalentNode? return unless equivalentNode?
@ -118,8 +118,9 @@ class FloatingToolbarContainer extends React.Component
# core actions and user-defined plugins. The FloatingToolbar simply # core actions and user-defined plugins. The FloatingToolbar simply
# renders them. # renders them.
_toolbarButtonConfigs: -> _toolbarButtonConfigs: ->
atomicEditWrap = (command) => (event) => atomicEditWrap = (command) =>
@props.atomicEdit(((editor) -> editor[command]()), event) (event) =>
@props.atomicEdit((({editor}) -> editor[command]()), event)
extensionButtonConfigs = [] extensionButtonConfigs = []
ExtensionRegistry.Composer.extensions().forEach (ext) -> ExtensionRegistry.Composer.extensions().forEach (ext) ->

View file

@ -2,13 +2,13 @@ _str = require 'underscore.string'
{DOMUtils, ContenteditableExtension} = require 'nylas-exports' {DOMUtils, ContenteditableExtension} = require 'nylas-exports'
class ListManager extends ContenteditableExtension class ListManager extends ContenteditableExtension
@onContentChanged: (editor, mutations) -> @onContentChanged: ({editor, mutations}) ->
if @_spaceEntered and @hasListStartSignature(editor.currentSelection()) if @_spaceEntered and @hasListStartSignature(editor.currentSelection())
@createList(editor) @createList(editor)
@_collapseAdjacentLists(editor) @_collapseAdjacentLists(editor)
@onKeyDown: (editor, event) -> @onKeyDown: ({editor, event}) ->
@_spaceEntered = event.key is " " @_spaceEntered = event.key is " "
if DOMUtils.isInList() if DOMUtils.isInList()
if event.key is "Backspace" and DOMUtils.atStartOfList() if event.key is "Backspace" and DOMUtils.atStartOfList()

View file

@ -1,7 +1,7 @@
{DOMUtils, ContenteditableExtension} = require 'nylas-exports' {DOMUtils, ContenteditableExtension} = require 'nylas-exports'
class TabManager extends ContenteditableExtension 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 # 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 # the keymap manager if the extension prevented the default behavior
if event.defaultPrevented if event.defaultPrevented

View file

@ -1,7 +1,8 @@
import _ from 'underscore'; import _ from 'underscore';
import {Listener, Publisher} from './flux/modules/reflux-coffee'; import {Listener, Publisher} from './flux/modules/reflux-coffee';
import {includeModule} from './flux/coffee-helpers'; import {includeModule} from './flux/coffee-helpers';
import composerExtAdapter from './extensions/composer-extension-adapter';
import messageViewExtAdapter from './extensions/message-view-extension-adapter';
export class Registry { export class Registry {
@ -55,9 +56,10 @@ Registry.include(Listener);
export const Composer = new Registry( export const Composer = new Registry(
'Composer', 'Composer',
require('./extensions/composer-extension-adapter') composerExtAdapter
); );
export const MessageView = new Registry( export const MessageView = new Registry(
'MessageView', 'MessageView',
messageViewExtAdapter
); );

View file

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

View file

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

View file

@ -47,7 +47,7 @@ class ComposerExtension extends ContenteditableExtension
Returns a list of warning strings, or an empty array if no warnings need Returns a list of warning strings, or an empty array if no warnings need
to be displayed. 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 valuable way, you should set `draft.pristine = false` so the draft
saves, even if no further changes are made. saves, even if no further changes are made.
### ###
@prepareNewDraft: (draft) -> @prepareNewDraft: ({draft}) ->
return return
### ###
@ -103,14 +103,14 @@ class ComposerExtension extends ContenteditableExtension
```coffee ```coffee
# Remove any <code> tags found in the draft body # Remove any <code> tags found in the draft body
finalizeSessionBeforeSending: (session) -> finalizeSessionBeforeSending: ({session}) ->
body = session.draft().body body = session.draft().body
clean = body.replace(/<\/?code[^>]*>/g, '') clean = body.replace(/<\/?code[^>]*>/g, '')
if body != clean if body != clean
session.changes.add(body: clean) session.changes.add(body: clean)
``` ```
### ###
@finalizeSessionBeforeSending: (session) -> @finalizeSessionBeforeSending: ({session}) ->
return return
module.exports = ComposerExtension module.exports = ComposerExtension

View file

@ -64,7 +64,7 @@ class ContenteditableExtension
reflect that it is no longer empty. reflect that it is no longer empty.
```coffee ```coffee
onContentChanged: (editor, mutations) -> onContentChanged: ({editor, mutations}) ->
isWithinNode = (node) -> isWithinNode = (node) ->
test = selection.baseNode test = selection.baseNode
while test isnt editableNode while test isnt editableNode
@ -78,9 +78,9 @@ class ContenteditableExtension
codeTag.classList.remove('empty') 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 Public: Override onBlur to mutate the contenteditable DOM node whenever the
@ -91,7 +91,7 @@ class ContenteditableExtension
methods for manipulating the selection and DOM methods for manipulating the selection and DOM
- event: DOM event fired on the contenteditable - event: DOM event fired on the contenteditable
### ###
@onBlur: (editor, event) -> @onBlur: ({editor, event}) ->
### ###
Public: Override onFocus to mutate the contenteditable DOM node whenever the Public: Override onFocus to mutate the contenteditable DOM node whenever the
@ -102,7 +102,7 @@ class ContenteditableExtension
methods for manipulating the selection and DOM methods for manipulating the selection and DOM
- event: DOM event fired on the contenteditable - event: DOM event fired on the contenteditable
### ###
@onFocus: (editor, event) -> @onFocus: ({editor, event}) ->
### ###
Public: Override onClick to mutate the contenteditable DOM node whenever the Public: Override onClick to mutate the contenteditable DOM node whenever the
@ -113,7 +113,7 @@ class ContenteditableExtension
methods for manipulating the selection and DOM methods for manipulating the selection and DOM
- event: DOM event fired on the contenteditable - event: DOM event fired on the contenteditable
### ###
@onClick: (editor, event) -> @onClick: ({editor, event}) ->
### ###
Public: Override onKeyDown to mutate the contenteditable DOM node whenever the Public: Override onKeyDown to mutate the contenteditable DOM node whenever the
@ -133,12 +133,11 @@ class ContenteditableExtension
methods for manipulating the selection and DOM methods for manipulating the selection and DOM
- event: DOM event fired on the contenteditable - event: DOM event fired on the contenteditable
### ###
@onKeyDown: (editor, event) -> @onKeyDown: ({editor, event}) ->
### ###
Public: Override onInput to mutate the contenteditable DOM node whenever the Public: Override onShowContextMenu to add new menu items to the right click menu
onInput event is fired on it.You may mutate the contenteditable in place, we inside the contenteditable.
not expect any return value from this method.
- editor: The {Editor} controller that provides a host of convenience - editor: The {Editor} controller that provides a host of convenience
methods for manipulating the selection and DOM 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) 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. 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 module.exports = ContenteditableExtension

View file

@ -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*/);
}

View file

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

View file

@ -28,7 +28,7 @@ class MessageViewExtension
Public: Transform the message body HTML provided in `body` and return HTML Public: Transform the message body HTML provided in `body` and return HTML
that should be displayed for the message. that should be displayed for the message.
### ###
@formatMessageBody: (body) -> @formatMessageBody: ({message}) ->
return body return body
module.exports = MessageViewExtension module.exports = MessageViewExtension

View file

@ -238,7 +238,7 @@ class DraftStore
# Give extensions an opportunity to perform additional setup to the draft # Give extensions an opportunity to perform additional setup to the draft
for extension in @extensions() for extension in @extensions()
continue unless extension.prepareNewDraft continue unless extension.prepareNewDraft
extension.prepareNewDraft(draft) extension.prepareNewDraft({draft})
# Optimistically create a draft session and hand it the draft so that it # 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. # 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) -> _runExtensionsBeforeSend: (session) ->
for extension in @extensions() for extension in @extensions()
continue unless extension.finalizeSessionBeforeSending continue unless extension.finalizeSessionBeforeSending
extension.finalizeSessionBeforeSending(session) extension.finalizeSessionBeforeSending({session})
_onRemoveFile: ({file, messageClientId}) => _onRemoveFile: ({file, messageClientId}) =>
@sessionForClientId(messageClientId).then (session) -> @sessionForClientId(messageClientId).then (session) ->

View file

@ -51,7 +51,7 @@ class MessageBodyProcessor
continue unless extension.formatMessageBody continue unless extension.formatMessageBody
virtual = message.clone() virtual = message.clone()
virtual.body = body virtual.body = body
extension.formatMessageBody(virtual) extension.formatMessageBody({message: virtual})
body = virtual.body body = virtual.body
# Find inline images and give them a calculated CSS height based on # Find inline images and give them a calculated CSS height based on

View file

@ -29,4 +29,8 @@ RegExpUtils =
looseStyleTag: -> /<style/gim looseStyleTag: -> /<style/gim
# Regular expression matching javasript function arguments:
# https://regex101.com/r/pZ6zF0/1
functionArgs: -> /\(\s*([^)]+?)\s*\)/
module.exports = RegExpUtils module.exports = RegExpUtils