From 94bdcc69001b30452832e2e2033dd8332cd2b9f4 Mon Sep 17 00:00:00 2001 From: Ben Gotow Date: Sat, 3 Oct 2015 14:05:47 -0700 Subject: [PATCH] feat(templates): Final examples package is in - templates! --- .../assets/Welcome to Templates.html | 20 +++ .../assets/icon-composer-templates@2x.png | Bin 0 -> 1234 bytes examples/N1-Composer-Templates/icon.png | Bin 0 -> 17486 bytes examples/N1-Composer-Templates/lib/main.cjsx | 23 +++ .../lib/template-draft-extension.coffee | 92 ++++++++++++ .../lib/template-picker.cjsx | 86 +++++++++++ .../lib/template-status-bar.cjsx | 45 ++++++ .../lib/template-store.coffee | 109 ++++++++++++++ examples/N1-Composer-Templates/package.json | 22 +++ .../spec/template-store-spec.coffee | 139 ++++++++++++++++++ .../stylesheets/message-templates.less | 41 ++++++ examples/N1-Composer-Translate/package.json | 4 +- .../package.json | 2 +- .../lib/contenteditable-component.cjsx | 27 ++-- src/flux/stores/draft-store-extension.coffee | 23 +-- .../composer/icon-composer-templates@2x.png | Bin 1244 -> 0 bytes 16 files changed, 595 insertions(+), 38 deletions(-) create mode 100644 examples/N1-Composer-Templates/assets/Welcome to Templates.html create mode 100644 examples/N1-Composer-Templates/assets/icon-composer-templates@2x.png create mode 100644 examples/N1-Composer-Templates/icon.png create mode 100644 examples/N1-Composer-Templates/lib/main.cjsx create mode 100644 examples/N1-Composer-Templates/lib/template-draft-extension.coffee create mode 100644 examples/N1-Composer-Templates/lib/template-picker.cjsx create mode 100644 examples/N1-Composer-Templates/lib/template-status-bar.cjsx create mode 100644 examples/N1-Composer-Templates/lib/template-store.coffee create mode 100755 examples/N1-Composer-Templates/package.json create mode 100644 examples/N1-Composer-Templates/spec/template-store-spec.coffee create mode 100755 examples/N1-Composer-Templates/stylesheets/message-templates.less delete mode 100644 static/images/composer/icon-composer-templates@2x.png diff --git a/examples/N1-Composer-Templates/assets/Welcome to Templates.html b/examples/N1-Composer-Templates/assets/Welcome to Templates.html new file mode 100644 index 000000000..3cb97efab --- /dev/null +++ b/examples/N1-Composer-Templates/assets/Welcome to Templates.html @@ -0,0 +1,20 @@ +

+ Hi there First Name, +

+

+ Welcome to the templates package! Templates live in the ~/.nylas/templates + directory on your computer. Each template is an HTML file - the name of the + file is the name of the template, and it's contents are the default message body. +

+

+ If you include HTML <code> tags in your template, you can create + regions that you can jump between and fill easily. Check out the source of + the template for a super awesome example! +

+

+ Give <code> tags the `var` class to mark them as template regions. Add + the `empty` class to make them dark yellow. When you send your message, <code> + tags are always stripped so the recipient never sees any highlighting. +

+ - Nylas Team +

diff --git a/examples/N1-Composer-Templates/assets/icon-composer-templates@2x.png b/examples/N1-Composer-Templates/assets/icon-composer-templates@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ec53deedb9fa4a63411753fd08ad1ac57ab6a9d9 GIT binary patch literal 1234 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 literal 0 HcmV?d00001 diff --git a/examples/N1-Composer-Templates/lib/main.cjsx b/examples/N1-Composer-Templates/lib/main.cjsx new file mode 100644 index 000000000..bb6b73142 --- /dev/null +++ b/examples/N1-Composer-Templates/lib/main.cjsx @@ -0,0 +1,23 @@ +{ComponentRegistry, DraftStore, React} = require 'nylas-exports' +TemplatePicker = require './template-picker' +TemplateStatusBar = require './template-status-bar' +Extension = require './template-draft-extension' + +module.exports = + item: null # The DOM item the main React component renders into + + activate: (@state={}) -> + ComponentRegistry.register TemplatePicker, + role: 'Composer:ActionButton' + + ComponentRegistry.register TemplateStatusBar, + role: 'Composer:Footer' + + DraftStore.registerExtension(Extension) + + deactivate: -> + ComponentRegistry.unregister(TemplatePicker) + ComponentRegistry.unregister(TemplateStatusBar) + DraftStore.unregisterExtension(Extension) + + serialize: -> @state diff --git a/examples/N1-Composer-Templates/lib/template-draft-extension.coffee b/examples/N1-Composer-Templates/lib/template-draft-extension.coffee new file mode 100644 index 000000000..7325a2ec2 --- /dev/null +++ b/examples/N1-Composer-Templates/lib/template-draft-extension.coffee @@ -0,0 +1,92 @@ +{DraftStoreExtension} = require 'nylas-exports' + +class TemplatesDraftStoreExtension extends DraftStoreExtension + + @warningsForSending: (draft) -> + warnings = [] + if draft.body.search(/]*empty[^>]*>/i) > 0 + warnings.push("with an empty template area") + warnings + + @finalizeSessionBeforeSending: (session) -> + body = session.draft().body + clean = body.replace(/<\/?code[^>]*>/g, '') + if body != clean + session.changes.add(body: clean) + + @onMouseUp: (editableNode, range, event) -> + parent = range.startContainer?.parentNode + parentCodeNode = null + + while parent and parent isnt editableNode + if parent.classList?.contains('var') and parent.tagName is 'CODE' + parentCodeNode = parent + break + parent = parent.parentNode + + isSinglePoint = range.startContainer is range.endContainer and range.startOffset is range.endOffset + + if isSinglePoint and parentCodeNode + range.selectNode(parentCodeNode) + selection = document.getSelection() + selection.removeAllRanges() + selection.addRange(range) + + @onTabDown: (editableNode, range, event) -> + if event.shiftKey + @onTabSelectNextVar(editableNode, range, event, -1) + else + @onTabSelectNextVar(editableNode, range, event, 1) + + @onTabSelectNextVar: (editableNode, range, event, delta) -> + return unless range + + # Try to find the node that the selection range is + # currently intersecting with (inside, or around) + parentCodeNode = null + nodes = editableNode.querySelectorAll('code.var') + for node in nodes + if range.intersectsNode(node) + parentCodeNode = node + + if parentCodeNode + if range.startOffset is range.endOffset and parentCodeNode.classList.contains('empty') + # If the current node is empty and it's a single insertion point, + # select the current node rather than advancing to the next node + selectNode = parentCodeNode + else + # advance to the next code node + matches = editableNode.querySelectorAll('code.var') + matchIndex = -1 + for match, idx in matches + if match is parentCodeNode + matchIndex = idx + break + if matchIndex != -1 and matchIndex + delta >= 0 and matchIndex + delta < matches.length + selectNode = matches[matchIndex+delta] + + if selectNode + range.selectNode(selectNode) + selection = document.getSelection() + selection.removeAllRanges() + selection.addRange(range) + event.preventDefault() + event.stopPropagation() + + @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') + + +module.exports = TemplatesDraftStoreExtension diff --git a/examples/N1-Composer-Templates/lib/template-picker.cjsx b/examples/N1-Composer-Templates/lib/template-picker.cjsx new file mode 100644 index 000000000..c2b03408c --- /dev/null +++ b/examples/N1-Composer-Templates/lib/template-picker.cjsx @@ -0,0 +1,86 @@ +{Actions, Message, DatabaseStore, React} = require 'nylas-exports' +{Popover, Menu, RetinaImg} = require 'nylas-component-kit' + +TemplateStore = require './template-store' + +class TemplatePicker extends React.Component + @displayName: 'TemplatePicker' + + @containerStyles: + order:2 + + constructor: (@props) -> + @state = + searchValue: "" + templates: TemplateStore.items() + + componentDidMount: => + @unsubscribe = TemplateStore.listen @_onStoreChange + + componentWillUnmount: => + @unsubscribe() if @unsubscribe + + render: => + button = + + headerComponents = [ + + ] + + footerComponents = [ +
Save Draft as Template...
+
Open Templates Folder...
+ ] + + + item.id } + itemContent={ (item) -> item.name } + onSelect={@_onChooseTemplate} + /> + + + + _filteredTemplates: (search) => + search ?= @state.searchValue + items = TemplateStore.items() + + return items unless search.length + + items.filter (t) -> + t.name.toLowerCase().indexOf(search.toLowerCase()) == 0 + + _onStoreChange: => + @setState + templates: @_filteredTemplates() + + _onSearchValueChange: => + newSearch = event.target.value + @setState + searchValue: newSearch + templates: @_filteredTemplates(newSearch) + + _onChooseTemplate: (template) => + Actions.insertTemplateId({templateId:template.id, draftClientId: @props.draftClientId}) + @refs.popover.close() + + _onManageTemplates: => + Actions.showTemplates() + + _onNewTemplate: => + Actions.createTemplate({draftClientId: @props.draftClientId}) + + +module.exports = TemplatePicker diff --git a/examples/N1-Composer-Templates/lib/template-status-bar.cjsx b/examples/N1-Composer-Templates/lib/template-status-bar.cjsx new file mode 100644 index 000000000..140c6f7e2 --- /dev/null +++ b/examples/N1-Composer-Templates/lib/template-status-bar.cjsx @@ -0,0 +1,45 @@ +{Actions, Message, DraftStore, React} = require 'nylas-exports' + +class TemplateStatusBar extends React.Component + @displayName: 'TemplateStatusBar' + + @containerStyles: + textAlign:'center' + width:530 + margin:'auto' + + @propTypes: + draftClientId: React.PropTypes.string + + constructor: (@props) -> + @state = draft: null + + componentDidMount: => + DraftStore.sessionForClientId(@props.draftClientId).then (_proxy) => + return if @_unmounted + return unless _proxy.draftClientId is @props.draftClientId + @_proxy = _proxy + @unsubscribe = @_proxy.listen(@_onDraftChange, @) + @_onDraftChange() + + componentWillUnmount: => + @_unmounted = true + @unsubscribe() if @unsubscribe + + render: => + if @_draftUsesTemplate() +
+ Press "tab" to quickly fill in the blanks - highlighting will not be visible to recipients. +
+ else +
+ + _onDraftChange: => + @setState(draft: @_proxy.draft()) + + _draftUsesTemplate: => + return unless @state.draft + @state.draft.body.search(/]*class="var[^>]*>/i) > 0 + + +module.exports = TemplateStatusBar diff --git a/examples/N1-Composer-Templates/lib/template-store.coffee b/examples/N1-Composer-Templates/lib/template-store.coffee new file mode 100644 index 000000000..bf29e23db --- /dev/null +++ b/examples/N1-Composer-Templates/lib/template-store.coffee @@ -0,0 +1,109 @@ +{DatabaseStore, DraftStore, Actions, Message, React} = require 'nylas-exports' +NylasStore = require 'nylas-store' +shell = require 'shell' +path = require 'path' +fs = require 'fs' + +class TemplateStore extends NylasStore + constructor: -> + @_setStoreDefaults() + @_registerListeners() + + @_templatesDir = path.join(atom.getConfigDirPath(), 'templates') + @_welcomeName = 'Welcome to Templates.html' + @_welcomePath = path.join(__dirname, '..', 'assets', @_welcomeName) + + # I know this is a bit of pain but don't do anything that + # could possibly slow down app launch + fs.exists @_templatesDir, (exists) => + if exists + @_populate() + fs.watch @_templatesDir, => @_populate() + else + fs.mkdir @_templatesDir, => + fs.readFile @_welcomePath, (err, welcome) => + fs.writeFile path.join(@_templatesDir, @_welcomeName), welcome, (err) => + fs.watch @_templatesDir, => @_populate() + + + ########### PUBLIC ##################################################### + + items: => + @_items + + templatesDirectory: => + @_templatesDir + + + ########### PRIVATE #################################################### + + _setStoreDefaults: => + @_items = [] + + _registerListeners: => + @listenTo Actions.insertTemplateId, @_onInsertTemplateId + @listenTo Actions.createTemplate, @_onCreateTemplate + @listenTo Actions.showTemplates, @_onShowTemplates + + _populate: => + fs.readdir @_templatesDir, (err, filenames) => + @_items = [] + for filename in filenames + continue if filename[0] is '.' + displayname = path.basename(filename, path.extname(filename)) + @_items.push + id: filename, + name: displayname, + path: path.join(@_templatesDir, filename) + @trigger(@) + + _onCreateTemplate: ({draftClientId, name, contents} = {}) => + if draftClientId + DraftStore.sessionForClientId(draftClientId).then (session) => + draft = session.draft() + name ?= draft.subject + contents ?= draft.body + if not name or name.length is 0 + return @_displayError("Give your draft a subject to name your template.") + if not contents or contents.length is 0 + return @_displayError("To create a template you need to fill the body of the current draft.") + @_writeTemplate(name, contents) + + else + if not name or name.length is 0 + return @_displayError("You must provide a name for your template.") + if not contents or contents.length is 0 + return @_displayError("You must provide contents for your template.") + @_writeTemplate(name, contents) + + _onShowTemplates: => + shell.showItemInFolder(@_items[0]?.path || @_templatesDir) + + _displayError: (message) => + dialog = require('remote').require('dialog') + dialog.showErrorBox('Template Creation Error', message) + + _writeTemplate: (name, contents) => + filename = "#{name}.html" + templatePath = path.join(@_templatesDir, filename) + fs.writeFile templatePath, contents, (err) => + @_displayError(err) if err + shell.showItemInFolder(templatePath) + @_items.push + id: filename, + name: name, + path: templatePath + @trigger(@) + + _onInsertTemplateId: ({templateId, draftClientId} = {}) => + template = null + for item in @_items + template = item if item.id is templateId + return unless template + + fs.readFile template.path, (err, data) -> + body = data.toString() + DraftStore.sessionForClientId(draftClientId).then (session) -> + session.changes.add(body: body) + +module.exports = new TemplateStore() diff --git a/examples/N1-Composer-Templates/package.json b/examples/N1-Composer-Templates/package.json new file mode 100755 index 000000000..74fadb595 --- /dev/null +++ b/examples/N1-Composer-Templates/package.json @@ -0,0 +1,22 @@ +{ + "name": "N1-Composer-Templates", + "version": "0.1.0", + "main": "./lib/main", + + "isStarterPackage": true, + "title": "Templates", + "description": "Create templates you can use to pre-fill the composer - never type the same email again!", + "icon": "./icon.png", + + "license": "Proprietary", + "private": true, + "engines": { + "atom": "*" + }, + "dependencies": { + }, + "windowTypes": { + "default": true, + "composer": true + } +} diff --git a/examples/N1-Composer-Templates/spec/template-store-spec.coffee b/examples/N1-Composer-Templates/spec/template-store-spec.coffee new file mode 100644 index 000000000..7b02a25bc --- /dev/null +++ b/examples/N1-Composer-Templates/spec/template-store-spec.coffee @@ -0,0 +1,139 @@ +{Message, Actions, DatabaseStore, DraftStore} = require 'nylas-exports' +TemplateStore = require '../lib/template-store' +fs = require 'fs-plus' +shell = require 'shell' + +stubTemplatesDir = TemplateStore.templatesDirectory() + +stubTemplateFiles = { + 'template1.html': '

bla1

', + 'template2.html': '

bla2

' +} + +stubTemplates = [ + {id: 'template1.html', name: 'template1', path: "#{stubTemplatesDir}/template1.html"}, + {id: 'template2.html', name: 'template2', path: "#{stubTemplatesDir}/template2.html"}, +] + +describe "TemplateStore", -> + beforeEach -> + spyOn(fs, 'mkdir') + spyOn(shell, 'showItemInFolder').andCallFake -> + spyOn(fs, 'writeFile').andCallFake (path, contents, callback) -> + callback(null) + spyOn(fs, 'readFile').andCallFake (path, callback) -> + filename = path.split('/').pop() + callback(null, stubTemplateFiles[filename]) + + it "should create the templates folder if it does not exist", -> + spyOn(fs, 'exists').andCallFake (path, callback) -> callback(false) + TemplateStore.init() + expect(fs.mkdir).toHaveBeenCalled() + + it "should expose templates in the templates directory", -> + spyOn(fs, 'exists').andCallFake (path, callback) -> callback(true) + spyOn(fs, 'readdir').andCallFake (path, callback) -> callback(null, Object.keys(stubTemplateFiles)) + TemplateStore.init() + expect(TemplateStore.items()).toEqual(stubTemplates) + + it "should watch the templates directory and reflect changes", -> + watchCallback = null + watchFired = false + + spyOn(fs, 'exists').andCallFake (path, callback) -> callback(true) + spyOn(fs, 'watch').andCallFake (path, callback) -> watchCallback = callback + spyOn(fs, 'readdir').andCallFake (path, callback) -> + if watchFired + callback(null, Object.keys(stubTemplateFiles)) + else + callback(null, []) + + TemplateStore.init() + expect(TemplateStore.items()).toEqual([]) + + watchFired = true + watchCallback() + expect(TemplateStore.items()).toEqual(stubTemplates) + + describe "insertTemplateId", -> + it "should insert the template with the given id into the draft with the given id", -> + + add = jasmine.createSpy('add') + spyOn(DraftStore, 'sessionForClientId').andCallFake -> + Promise.resolve(changes: {add}) + + runs -> + TemplateStore._onInsertTemplateId + templateId: 'template1.html', + draftClientId: 'localid-draft' + waitsFor -> + add.calls.length > 0 + runs -> + expect(add).toHaveBeenCalledWith + body: stubTemplateFiles['template1.html'] + + describe "onCreateTemplate", -> + beforeEach -> + TemplateStore.init() + spyOn(DraftStore, 'sessionForClientId').andCallFake (draftClientId) -> + if draftClientId is 'localid-nosubject' + d = new Message(subject: '', body: '

Body

') + else + d = new Message(subject: 'Subject', body: '

Body

') + session = + draft: -> d + Promise.resolve(session) + + it "should create a template with the given name and contents", -> + TemplateStore._onCreateTemplate({name: '123', contents: 'bla'}) + item = TemplateStore.items()?[0] + expect(item.id).toBe "123.html" + expect(item.name).toBe "123" + expect(item.path.split("/").pop()).toBe "123.html" + + it "should display an error if no name is provided", -> + spyOn(TemplateStore, '_displayError') + TemplateStore._onCreateTemplate({contents: 'bla'}) + expect(TemplateStore._displayError).toHaveBeenCalled() + + it "should display an error if no content is provided", -> + spyOn(TemplateStore, '_displayError') + TemplateStore._onCreateTemplate({name: 'bla'}) + expect(TemplateStore._displayError).toHaveBeenCalled() + + it "should save the template file to the templates folder", -> + TemplateStore._onCreateTemplate({name: '123', contents: 'bla'}) + path = "#{stubTemplatesDir}/123.html" + expect(fs.writeFile).toHaveBeenCalled() + expect(fs.writeFile.mostRecentCall.args[0]).toEqual(path) + expect(fs.writeFile.mostRecentCall.args[1]).toEqual('bla') + + it "should open the template so you can see it", -> + TemplateStore._onCreateTemplate({name: '123', contents: 'bla'}) + path = "#{stubTemplatesDir}/123.html" + expect(shell.showItemInFolder).toHaveBeenCalled() + + describe "when given a draft id", -> + it "should create a template from the name and contents of the given draft", -> + spyOn(TemplateStore, 'trigger') + spyOn(TemplateStore, '_populate') + runs -> + TemplateStore._onCreateTemplate({draftClientId: 'localid-b'}) + waitsFor -> + TemplateStore.trigger.callCount > 0 + runs -> + expect(TemplateStore.items().length).toEqual(1) + + it "should display an error if the draft has no subject", -> + spyOn(TemplateStore, '_displayError') + runs -> + TemplateStore._onCreateTemplate({draftClientId: 'localid-nosubject'}) + waitsFor -> + TemplateStore._displayError.callCount > 0 + runs -> + expect(TemplateStore._displayError).toHaveBeenCalled() + + describe "onShowTemplates", -> + it "should open the templates folder in the Finder", -> + TemplateStore._onShowTemplates() + expect(shell.showItemInFolder).toHaveBeenCalled() diff --git a/examples/N1-Composer-Templates/stylesheets/message-templates.less b/examples/N1-Composer-Templates/stylesheets/message-templates.less new file mode 100755 index 000000000..3db293255 --- /dev/null +++ b/examples/N1-Composer-Templates/stylesheets/message-templates.less @@ -0,0 +1,41 @@ +@import "ui-variables"; +@import "ui-mixins"; + +@code-bg-color: #fcf4db; + +.template-picker { + .menu { + .content-container { + height:150px; + overflow-y:scroll; + } + .footer-container { + border-top: 1px solid @border-secondary-bg; + } + } +} + +.template-status-bar { + background-color: @code-bg-color; + color: darken(@code-bg-color, 70%); + border: 1.5px solid darken(@code-bg-color, 10%); + border-radius: @border-radius-small; + padding-top: @padding-small-vertical @padding-small-horizontal @padding-small-vertical @padding-small-horizontal; + font-size: @font-size-small; +} + +.compose-body #contenteditable { + code.var { + font: inherit; + padding:0; + padding-left:2px; + padding-right:2px; + border-bottom: 1.5px solid darken(@code-bg-color, 10%); + background-color: fade(@code-bg-color, 10%); + &.empty { + color:darken(@code-bg-color, 70%); + border-bottom: 1px solid darken(@code-bg-color, 14%); + background-color: @code-bg-color; + } + } +} diff --git a/examples/N1-Composer-Translate/package.json b/examples/N1-Composer-Translate/package.json index 56ddf51cd..89a5d21ff 100755 --- a/examples/N1-Composer-Translate/package.json +++ b/examples/N1-Composer-Translate/package.json @@ -5,7 +5,7 @@ "isStarterPackage": true, "title": "Translation", - "description": "An example package for N1 that translates drafts into other languages using the Yandex API.", + "description": "Translate your drafts in the composer into other languages using the Yandex Translation API.", "icon": "./icon.png", "license": "Proprietary", @@ -14,7 +14,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/nylas/translate" + "url": "https://github.com/nylas/N1" }, "windowTypes": { "default": true, diff --git a/examples/N1-Github-Contact-Card-Section/package.json b/examples/N1-Github-Contact-Card-Section/package.json index c458e01e0..ed11a4d7c 100644 --- a/examples/N1-Github-Contact-Card-Section/package.json +++ b/examples/N1-Github-Contact-Card-Section/package.json @@ -5,7 +5,7 @@ "isStarterPackage": true, "title": "Github", - "description": "Adds Github quick actions to many emails, and allows you to see the Github profiles of the people you email.", + "description": "Extends the contact card in the sidebar to show public repos of the people you email.", "icon": "./icon.png", "license": "MIT", diff --git a/internal_packages/composer/lib/contenteditable-component.cjsx b/internal_packages/composer/lib/contenteditable-component.cjsx index 8788fa366..c553db5e2 100644 --- a/internal_packages/composer/lib/contenteditable-component.cjsx +++ b/internal_packages/composer/lib/contenteditable-component.cjsx @@ -42,21 +42,6 @@ class ContenteditableComponent extends React.Component @_editableNode().addEventListener('contextmenu', @_onShowContextualMenu) @_setupSelectionListeners() @_setupGlobalMouseListener() - - @_disposable = atom.commands.add '.contenteditable-container *', { - 'core:focus-next': (event) => - editableNode = @_editableNode() - range = DOMUtils.getRangeInScope(editableNode) - for extension in DraftStore.extensions() - extension.onFocusNext(editableNode, range, event) if extension.onFocusNext - - 'core:focus-previous': (event) => - editableNode = @_editableNode() - range = DOMUtils.getRangeInScope(editableNode) - for extension in DraftStore.extensions() - extension.onFocusPrevious(editableNode, range, event) if extension.onFocusPrevious - } - @_cleanHTML() @setInnerState editableNode: @_editableNode() @@ -69,7 +54,6 @@ class ContenteditableComponent extends React.Component @_editableNode().removeEventListener('contextmenu', @_onShowContextualMenu) @_teardownSelectionListeners() @_teardownGlobalMouseListener() - @_disposable.dispose() componentWillReceiveProps: (nextProps) => @_setupServices(nextProps) @@ -275,7 +259,18 @@ class ContenteditableComponent extends React.Component @_onInput() _onTabDown: (event) -> + editableNode = @_editableNode() + range = DOMUtils.getRangeInScope(editableNode) + + for extension in DraftStore.extensions() + extension.onTabDown(editableNode, range, event) if extension.onTabDown + + return if event.defaultPrevented + @_onTabDownDefaultBehavior(event) + + _onTabDownDefaultBehavior: (event) -> event.preventDefault() + selection = document.getSelection() if selection?.isCollapsed # Only Elements (not Text nodes) have the `closest` method diff --git a/src/flux/stores/draft-store-extension.coffee b/src/flux/stores/draft-store-extension.coffee index f3f59032b..ec9003d37 100644 --- a/src/flux/stores/draft-store-extension.coffee +++ b/src/flux/stores/draft-store-extension.coffee @@ -118,29 +118,14 @@ class DraftStoreExtension @onMouseUp: (editableNode, range, event) -> return - ### - Public: Called when the user presses `Shift-Tab` while focused on the composer's body field. - Override onFocusPrevious in your DraftStoreExtension to adjust the selection or perform - other actions. If your package implements Shift-Tab behavior in a particular scenario, you - should prevent the default behavior of Shift-Tab via `event.preventDefault()`. - - - `editableNode` The composer's contenteditable {Node} that received the event. - - - `range`: The currently selected {Range} in the `editableNode` - - - `event`: The mouse up event. - - ### - @onFocusPrevious: (editableNode, range, event) -> - return - - ### Public: Called when the user presses `Tab` while focused on the composer's body field. - Override onFocusPrevious in your DraftStoreExtension to adjust the selection or perform + 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` @@ -148,7 +133,7 @@ class DraftStoreExtension - `event`: The mouse up event. ### - @onFocusNext: (editableNode, range, event) -> + @onTabDown: (editableNode, range, event) -> return ### diff --git a/static/images/composer/icon-composer-templates@2x.png b/static/images/composer/icon-composer-templates@2x.png deleted file mode 100644 index 748c21c257168901789390dc0e6abbdc3d749f3e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1244 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz$r9IylHmNblJdl&R0hYC{G?O` z&)mfH)S%SFl*+=BsWuD@%oUj-5hW46K32*3xq68pHF_1f1wh>l3^w)^1&PVosU-?Y zsp*+{wo31J?^jaDOtDo8H}y5}EpSfF$n>ZxN)4{^3rViZPPR-@vbR&PsjvbXkegbP zs8ErclUHn2VXFi-*9yo63F|8l;|;8yV;tSX!AHTNxNBK!Fm_wxX0Ys~{IQs9ivwtx`rwNr9EVetCJh zUb(Seeo?xG?WUP)qwZeFo6%mkOz;^d;tf|AVqJOz-6iAnjT zCALaHmqNUdTj1*pH#n~t8c@I>)2~P@&^OdG(9g{U`3tPNxFjeQ;S8**i$f|4QuTvU zi}Op1l7aD&rVP^z3_JW5ffNE=W946z35=A)9GCp$(%jU%5>FRfC7_I6N@j|cnT2su zin+0guBl0)nXXBSiIHwnVp@`}rD=+Vk%?()Vwyn`%p7d`9SvL^-He@#j4Tb!3=LgP zEsYJFEsYG!+)NBjEghW=VR}9Dic1pnl2c)JX9Dep>NUix*UGslHL)bWC?r2W2bKx~ zGV)9Ei!<^I6r7#Gv96%uo0y!L2NKi-MHIx}E~!PCWvMA{Mftf3;E=Y;#NrC#LI9#a zh1?L-2Rce0lw6RK4@?M{CP7SiW&m>F*(o&-n1_mhnT0W&VKFeH{ql5i45_%4^ymM7 z`@;@QVkvzqSH8?#(0rxgOnt_y8I6zB&(F1f?zOV@g_ePu3`b>(Adl2J%gzI>SV z1z&=aGGBV2z$4?8lQ?*Jcvwzs{ClXCTfXCDn~S8&S{I84I~i2D7N{=d?AvkQxqH%t z7Xk~aJw3DyUU(fk7%CzyDOt+2Sdsh0A7}2}4Qb6?AKB()9yoI33HyYk604zH2S_A%;O9fkCIKC2hUY_cTy> Nvd$@?2>@Agn-Ty3