From cc61aa78119130603d8a13ffe38a26d36c8f16de Mon Sep 17 00:00:00 2001 From: Evan Morikawa Date: Thu, 29 Oct 2015 17:20:41 -0400 Subject: [PATCH] feat(signatures): add signature support in preferences Summary: Adding signature support in preferences Extracting out DraftStore extensions from the Contenteditable component Moved Contenteditable to the nylas component kit Build react remote window selection synchronization. Test Plan: todo Reviewers: bengotow Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D2204 --- .../ic-settings-signatures-win32@2x.png | Bin .../images}/ic-settings-signatures@2x.png | Bin .../composer-signature/lib/main.coffee | 38 ++++++- .../lib/preferences-signatures.cjsx | 105 ++++++++++++++++++ ...offee => signature-draft-extension.coffee} | 2 +- .../spec/draft-extension-spec.coffee | 2 +- .../composer/lib/composer-view.cjsx | 34 +++++- .../contenteditable-quoted-text-spec.cjsx | 4 +- internal_packages/preferences/lib/main.cjsx | 73 ++++++------ .../preferences/lib/preferences-header.cjsx | 15 +-- .../preferences/lib/preferences-store.coffee | 19 ---- .../preferences/lib/preferences.cjsx | 8 +- .../lib/tabs/preferences-signatures.cjsx | 53 --------- .../preferences/stylesheets/preferences.less | 7 ++ .../components}/clipboard-service-spec.coffee | 2 +- .../contenteditable-component-spec.cjsx | 10 +- src/browser/application.coffee | 3 + .../components}/clipboard-service.coffee | 0 .../components/contenteditable.cjsx | 99 +++++++++-------- .../floating-toolbar-container.cjsx | 0 .../components}/floating-toolbar.cjsx | 0 .../stores/preferences-section-store.coffee | 75 +++++++++++++ src/global/nylas-component-kit.coffee | 1 + src/global/nylas-exports.coffee | 1 + src/react-remote/react-remote-child.js | 28 ++++- src/react-remote/react-remote-parent.js | 40 +++++-- src/react-remote/selection-listeners.js | 78 +++++++++++++ 27 files changed, 508 insertions(+), 189 deletions(-) rename {static/images/preferences/tabs => internal_packages/composer-signature/images}/ic-settings-signatures-win32@2x.png (100%) rename {static/images/preferences/tabs => internal_packages/composer-signature/images}/ic-settings-signatures@2x.png (100%) create mode 100644 internal_packages/composer-signature/lib/preferences-signatures.cjsx rename internal_packages/composer-signature/lib/{draft-extension.coffee => signature-draft-extension.coffee} (87%) delete mode 100644 internal_packages/preferences/lib/preferences-store.coffee delete mode 100644 internal_packages/preferences/lib/tabs/preferences-signatures.cjsx rename {internal_packages/composer/spec => spec/components}/clipboard-service-spec.coffee (99%) rename {internal_packages/composer/spec => spec/components}/contenteditable-component-spec.cjsx (89%) rename {internal_packages/composer/lib => src/components}/clipboard-service.coffee (100%) rename internal_packages/composer/lib/contenteditable-component.cjsx => src/components/contenteditable.cjsx (93%) rename {internal_packages/composer/lib => src/components}/floating-toolbar-container.cjsx (100%) rename {internal_packages/composer/lib => src/components}/floating-toolbar.cjsx (100%) create mode 100644 src/flux/stores/preferences-section-store.coffee create mode 100644 src/react-remote/selection-listeners.js diff --git a/static/images/preferences/tabs/ic-settings-signatures-win32@2x.png b/internal_packages/composer-signature/images/ic-settings-signatures-win32@2x.png similarity index 100% rename from static/images/preferences/tabs/ic-settings-signatures-win32@2x.png rename to internal_packages/composer-signature/images/ic-settings-signatures-win32@2x.png diff --git a/static/images/preferences/tabs/ic-settings-signatures@2x.png b/internal_packages/composer-signature/images/ic-settings-signatures@2x.png similarity index 100% rename from static/images/preferences/tabs/ic-settings-signatures@2x.png rename to internal_packages/composer-signature/images/ic-settings-signatures@2x.png diff --git a/internal_packages/composer-signature/lib/main.coffee b/internal_packages/composer-signature/lib/main.coffee index 9572b04a9..3f019c111 100644 --- a/internal_packages/composer-signature/lib/main.coffee +++ b/internal_packages/composer-signature/lib/main.coffee @@ -1,11 +1,41 @@ -{ComponentRegistry, DraftStore} = require 'nylas-exports' -Extension = require './draft-extension' +{PreferencesSectionStore, DraftStore} = require 'nylas-exports' +SignatureDraftExtension = require './signature-draft-extension' module.exports = activate: (@state={}) -> - DraftStore.registerExtension(Extension) + DraftStore.registerExtension(SignatureDraftExtension) + + @sectionConfig = new PreferencesSectionStore.SectionConfig + # TODO: Fix RetinaImg to handle plugin images + icon: -> + if process.platform is "win32" + "nylas://composer-signature/images/ic-settings-signatures-win32@2x.png" + else + "nylas://composer-signature/images/ic-settings-signatures@2x.png" + sectionId: "Signatures" + displayName: "Signatures" + component: require "./preferences-signatures" + + PreferencesSectionStore.registerPreferenceSection(@sectionConfig) + + ## TODO: + # PreferencesSectionStore.registerPreferences "composer-signatures", [ + # { + # section: PreferencesSectionStore.Section.Signatures + # type: "richtext" + # label: "Signature:" + # perAccount: true + # defaultValue: "- Sent from N1" + # }, { + # section: PreferencesSectionStore.Section.Signatures + # type: "toggle" + # label: "Include on replies" + # defaultValue: true + # } + # ] deactivate: -> - DraftStore.unregisterExtension(Extension) + DraftStore.unregisterExtension(SignatureDraftExtension) + PreferencesSectionStore.unregisterPreferenceSection(@sectionConfig.sectionId) serialize: -> @state diff --git a/internal_packages/composer-signature/lib/preferences-signatures.cjsx b/internal_packages/composer-signature/lib/preferences-signatures.cjsx new file mode 100644 index 000000000..28bc5e81c --- /dev/null +++ b/internal_packages/composer-signature/lib/preferences-signatures.cjsx @@ -0,0 +1,105 @@ +React = require 'react' +_ = require 'underscore' +{Contenteditable, RetinaImg, Flexbox} = require 'nylas-component-kit' +{AccountStore, Utils} = require 'nylas-exports' + +class PreferencesSignatures extends React.Component + @displayName: 'PreferencesSignatures' + + constructor: (@props) -> + @_signatureSaveQueue = {} + + selectedAccountId = AccountStore.current()?.id + if selectedAccountId + key = @_configKey(selectedAccountId) + initialSig = @props.config.get(key) + else + initialSig = "" + + @state = + accounts: AccountStore.items() + currentSignature: initialSig + selectedAccountId: selectedAccountId + + componentDidMount: -> + @usub = AccountStore.listen @_onChange + + shouldComponentUpdate: (nextProps, nextState) => + nextState.selectedAccountId isnt @state.selectedAccountId + + componentWillUnmount: -> + @usub() + @_saveSignatureNow(@state.selectedAccountId, @state.currentSignature) + + _saveSignatureNow: (accountId, value) => + key = @_configKey(accountId) + @props.config.set(key, value) + + _saveSignatureSoon: (accountId, value) => + @_signatureSaveQueue[accountId] = value + @_saveSignaturesFromCache() + + __saveSignaturesFromCache: => + for accountId, value of @_signatureSaveQueue + @_saveSignatureNow(accountId, value) + + @_signatureSaveQueue = {} + + _saveSignaturesFromCache: _.debounce(PreferencesSignatures::__saveSignaturesFromCache, 500) + + _onChange: => + @setState @_getStateFromStores() + + _getStateFromStores: -> + accounts = AccountStore.items() + selectedAccountId = @state.selectedAccountId + currentSignature = @state.currentSignature + if not @state.selectedAccountId in _.pluck(accounts, "id") + selectedAccountId = null + currentSignature = "" + return {accounts, selectedAccountId, currentSignature} + + _renderAccountPicker: -> + options = @state.accounts.map (account) -> + + + + + _renderCurrentSignature: -> + + + _onEditSignature: (event) => + html = event.target.value + @setState currentSignature: html + @_saveSignatureSoon(@state.selectedAccountId, html) + + _configKey: (accountId) -> + "nylas.account-#{accountId}.signature" + + _onSelectAccount: (event) => + @_saveSignatureNow(@state.selectedAccountId, @state.currentSignature) + selectedAccountId = event.target.value + key = @_configKey(selectedAccountId) + initialSig = @props.config.get(key) ? "" + @setState + currentSignature: initialSig + selectedAccountId: selectedAccountId + + render: => +
+
+ Signature for: {@_renderAccountPicker()} +
+
+ {@_renderCurrentSignature()} +
+
+ +module.exports = PreferencesSignatures diff --git a/internal_packages/composer-signature/lib/draft-extension.coffee b/internal_packages/composer-signature/lib/signature-draft-extension.coffee similarity index 87% rename from internal_packages/composer-signature/lib/draft-extension.coffee rename to internal_packages/composer-signature/lib/signature-draft-extension.coffee index 7878e4ed8..e39c35936 100644 --- a/internal_packages/composer-signature/lib/draft-extension.coffee +++ b/internal_packages/composer-signature/lib/signature-draft-extension.coffee @@ -3,7 +3,7 @@ class SignatureDraftStoreExtension extends DraftStoreExtension @prepareNewDraft: (draft) -> accountId = AccountStore.current().id - signature = atom.config.get("signatures.#{accountId}") + signature = atom.config.get("nylas.account-#{accountId}.signature") return unless signature insertionPoint = draft.body.indexOf(' describe "prepareNewDraft", -> diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx index 3f8a2459b..aa599be16 100644 --- a/internal_packages/composer/lib/composer-view.cjsx +++ b/internal_packages/composer/lib/composer-view.cjsx @@ -15,6 +15,7 @@ React = require 'react' {DropZone, RetinaImg, ScrollRegion, + Contenteditable, InjectedComponent, FocusTrackingRegion, InjectedComponentSet} = require 'nylas-component-kit' @@ -26,7 +27,6 @@ ExpandedParticipants = require './expanded-participants' CollapsedParticipants = require './collapsed-participants' ContenteditableFilter = require './contenteditable-filter' -ContenteditableComponent = require './contenteditable-component' Fields = require './fields' @@ -275,7 +275,7 @@ class ComposerView extends React.Component _renderBodyContenteditable: -> - @setState focusedField: Fields.Body} @@ -285,9 +285,39 @@ class ComposerView extends React.Component onFilePaste={@_onFilePaste} footerElements={@_editableFooterElements()} onScrollToBottom={@_onScrollToBottom()} + lifecycleCallbacks={@_contenteditableLifecycleCallbacks()} getComposerBoundingRect={@_getComposerBoundingRect} initialSelectionSnapshot={@_recoveredSelection} /> + _contenteditableLifecycleCallbacks: -> + componentDidUpdate: (editableNode) => + for extension in DraftStore.extensions() + extension.onComponentDidUpdate?(editableNode) + + onInput: (editableNode, event) => + for extension in DraftStore.extensions() + extension.onInput?(editableNode, event) + + onTabDown: (editableNode, event, range) => + for extension in DraftStore.extensions() + extension.onTabDown?(editableNode, range, event) + + onSubstitutionPerformed: (editableNode) => + for extension in DraftStore.extensions() + extension.onSubstitutionPerformed?(editableNode) + + onLearnSpelling: (editableNode, text) => + for extension in DraftStore.extensions() + extension.onLearnSpelling?(editableNode, text) + + onMouseUp: (editableNode, event, range) => + return unless range + try + for extension in DraftStore.extensions() + extension.onMouseUp?(editableNode, range, event) + catch e + console.error('DraftStore extension raised an error: '+e.toString()) + # The contenteditable decides when to request a scroll based on the # position of the cursor and its relative distance to this composer # component. We provide it our boundingClientRect so it can calculate diff --git a/internal_packages/composer/spec/contenteditable-quoted-text-spec.cjsx b/internal_packages/composer/spec/contenteditable-quoted-text-spec.cjsx index 68a3e024d..9ed7c641b 100644 --- a/internal_packages/composer/spec/contenteditable-quoted-text-spec.cjsx +++ b/internal_packages/composer/spec/contenteditable-quoted-text-spec.cjsx @@ -9,9 +9,9 @@ ReactTestUtils = React.addons.TestUtils Fields = require '../lib/fields' Composer = require "../lib/composer-view", -ContenteditableComponent = require "../lib/contenteditable-component", +{Contenteditable} = require 'nylas-component-kit' -describe "ContenteditableComponent", -> +describe "Contenteditable", -> beforeEach -> @onChange = jasmine.createSpy('onChange') @htmlNoQuote = 'Test HTML
' diff --git a/internal_packages/preferences/lib/main.cjsx b/internal_packages/preferences/lib/main.cjsx index ad2689c66..141d620f5 100644 --- a/internal_packages/preferences/lib/main.cjsx +++ b/internal_packages/preferences/lib/main.cjsx @@ -1,4 +1,4 @@ -PreferencesStore = require './preferences-store' +{PreferencesSectionStore} = require 'nylas-exports' module.exports = activate: (@state={}) -> @@ -6,42 +6,43 @@ module.exports = React = require 'react' {Actions} = require('nylas-exports') - Actions.registerPreferencesTab({ - icon: 'ic-settings-general.png' - name: 'General' - component: require './tabs/preferences-general' - }) - Actions.registerPreferencesTab({ - icon: 'ic-settings-accounts.png' - name: 'Accounts' - component: require './tabs/preferences-accounts' - }) - # Actions.registerPreferencesTab({ - # icon: 'ic-settings-mailrules.png' - # name: 'Mail Rules' - # component: require './tabs/preferences-mailrules' - # }) - Actions.registerPreferencesTab({ - icon: 'ic-settings-shortcuts.png' - name: 'Shortcuts' - component: require './tabs/preferences-keymaps' - }) - Actions.registerPreferencesTab({ - icon: 'ic-settings-notifications.png' - name: 'Notifications' - component: require './tabs/preferences-notifications' - }) - Actions.registerPreferencesTab({ - icon: 'ic-settings-appearance.png' - name: 'Appearance' - component: require './tabs/preferences-appearance' - }) + Cfg = PreferencesSectionStore.SectionConfig - # Actions.registerPreferencesTab({ - # icon: 'ic-settings-signatures.png' - # name: 'Signatures' - # component: require './tabs/preferences-signatures' - # }) + PreferencesSectionStore.registerPreferenceSection(new Cfg { + icon: 'ic-settings-general.png' + sectionId: 'General' + displayName: 'General' + component: require './tabs/preferences-general' + order: 1 + }) + PreferencesSectionStore.registerPreferenceSection(new Cfg { + icon: 'ic-settings-accounts.png' + sectionId: 'Accounts' + displayName: 'Accounts' + component: require './tabs/preferences-accounts' + order: 2 + }) + PreferencesSectionStore.registerPreferenceSection(new Cfg { + icon: 'ic-settings-shortcuts.png' + sectionId: 'Shortcuts' + displayName: 'Shortcuts' + component: require './tabs/preferences-keymaps' + order: 3 + }) + PreferencesSectionStore.registerPreferenceSection(new Cfg { + icon: 'ic-settings-notifications.png' + sectionId: 'Notifications' + displayName: 'Notifications' + component: require './tabs/preferences-notifications' + order: 4 + }) + PreferencesSectionStore.registerPreferenceSection(new Cfg { + icon: 'ic-settings-appearance.png' + sectionId: 'Appearance' + displayName: 'Appearance' + component: require './tabs/preferences-appearance' + order: 5 + }) Actions.openPreferences.listen(@_openPreferences) ipc.on 'open-preferences', => @_openPreferences() diff --git a/internal_packages/preferences/lib/preferences-header.cjsx b/internal_packages/preferences/lib/preferences-header.cjsx index 95e79b85c..0fb6fd7ea 100644 --- a/internal_packages/preferences/lib/preferences-header.cjsx +++ b/internal_packages/preferences/lib/preferences-header.cjsx @@ -15,18 +15,19 @@ class PreferencesHeader extends React.Component imgMode = RetinaImg.Mode.ContentIsMask else imgMode = RetinaImg.Mode.ContentPreserve -
- { @props.tabs.map (tab) => - classname = "preference-header-item" - classname += " active" if tab is @props.activeTab -
@props.changeActiveTab(tab) } key={tab.name}> +
+ { @props.tabs.map (sectionConfig) => + classname = "preference-header-item" + classname += " active" if sectionConfig is @props.activeTab + +
@props.changeActiveTab(sectionConfig) } key={sectionConfig.sectionId}>
- +
- {tab.name} + {sectionConfig.displayName}
diff --git a/internal_packages/preferences/lib/preferences-store.coffee b/internal_packages/preferences/lib/preferences-store.coffee deleted file mode 100644 index a5bc1d01c..000000000 --- a/internal_packages/preferences/lib/preferences-store.coffee +++ /dev/null @@ -1,19 +0,0 @@ -Reflux = require 'reflux' -_ = require 'underscore' -NylasStore = require 'nylas-store' -{Actions} = require 'nylas-exports' - -class PreferencesStore extends NylasStore - constructor: -> - @_tabs = [] - @listenTo Actions.registerPreferencesTab, @_registerTab - - tabs: => - @_tabs - - _registerTab: (tabConfig) => - @_tabs.push(tabConfig) - @_triggerSoon ?= _.debounce(( => @trigger()), 20) - @_triggerSoon() - -module.exports = new PreferencesStore() diff --git a/internal_packages/preferences/lib/preferences.cjsx b/internal_packages/preferences/lib/preferences.cjsx index 47f42157c..b96d3275b 100644 --- a/internal_packages/preferences/lib/preferences.cjsx +++ b/internal_packages/preferences/lib/preferences.cjsx @@ -1,15 +1,15 @@ React = require 'react' _ = require 'underscore' {RetinaImg, Flexbox, ConfigPropContainer} = require 'nylas-component-kit' +{PreferencesSectionStore} = require 'nylas-exports' -PreferencesStore = require './preferences-store' PreferencesHeader = require './preferences-header' class Preferences extends React.Component @displayName: 'Preferences' constructor: (@props) -> - tabs = PreferencesStore.tabs() + tabs = PreferencesSectionStore.sections() if @props.initialTab activeTab = _.find tabs, (t) => t.name is @props.initialTab activeTab ||= tabs[0] @@ -18,7 +18,7 @@ class Preferences extends React.Component componentDidMount: => @unlisteners = [] - @unlisteners.push PreferencesStore.listen => + @unlisteners.push PreferencesSectionStore.listen => @setState(@getStateFromStores()) componentWillUnmount: => @@ -29,7 +29,7 @@ class Preferences extends React.Component @setState(activeTab: @state.tabs[0]) getStateFromStores: => - tabs: PreferencesStore.tabs() + tabs: PreferencesSectionStore.sections() render: => if @state.activeTab diff --git a/internal_packages/preferences/lib/tabs/preferences-signatures.cjsx b/internal_packages/preferences/lib/tabs/preferences-signatures.cjsx deleted file mode 100644 index 6195f1952..000000000 --- a/internal_packages/preferences/lib/tabs/preferences-signatures.cjsx +++ /dev/null @@ -1,53 +0,0 @@ -React = require 'react' -_ = require 'underscore' -{RetinaImg, Flexbox} = require 'nylas-component-kit' - -class PreferencesSignatures extends React.Component - @displayName: 'PreferencesSignatures' - - render: => -
-
-
- Signatures -
-
- -
-
-
    -
  • Personal
  • -
  • Corporate
  • -
-
-
-
-
    -
  • +
  • -
  • -
  • -
-
-
-
-
-
- Signature -
-
- -
-
    -
  • B
  • -
  • I
  • -
  • u
  • -
-
- -
-
-
-
-
-
- -module.exports = PreferencesSignatures diff --git a/internal_packages/preferences/stylesheets/preferences.less b/internal_packages/preferences/stylesheets/preferences.less index 51a469917..379676ef0 100644 --- a/internal_packages/preferences/stylesheets/preferences.less +++ b/internal_packages/preferences/stylesheets/preferences.less @@ -99,6 +99,13 @@ body.platform-darwin { margin-left: 2.5%; margin-right: 2.5%; + .contenteditable-container { + border: 1px solid @input-border-color; + padding: 10px; + margin-top: 20px; + min-height: 200px; + } + .section-body { padding: 10px 0 0 0; diff --git a/internal_packages/composer/spec/clipboard-service-spec.coffee b/spec/components/clipboard-service-spec.coffee similarity index 99% rename from internal_packages/composer/spec/clipboard-service-spec.coffee rename to spec/components/clipboard-service-spec.coffee index 8dd1932bb..1c71df431 100644 --- a/internal_packages/composer/spec/clipboard-service-spec.coffee +++ b/spec/components/clipboard-service-spec.coffee @@ -1,4 +1,4 @@ -ClipboardService = require '../lib/clipboard-service' +ClipboardService = require '../../src/components/clipboard-service' describe "ClipboardService", -> beforeEach -> diff --git a/internal_packages/composer/spec/contenteditable-component-spec.cjsx b/spec/components/contenteditable-component-spec.cjsx similarity index 89% rename from internal_packages/composer/spec/contenteditable-component-spec.cjsx rename to spec/components/contenteditable-component-spec.cjsx index 4d7417c46..8f753431c 100644 --- a/internal_packages/composer/spec/contenteditable-component-spec.cjsx +++ b/spec/components/contenteditable-component-spec.cjsx @@ -6,20 +6,20 @@ _ = require "underscore" fs = require 'fs' React = require "react/addons" ReactTestUtils = React.addons.TestUtils -ContenteditableComponent = require "../lib/contenteditable-component", +Contenteditable = require "../../src/components/contenteditable", -describe "ContenteditableComponent", -> +describe "Contenteditable", -> beforeEach -> @onChange = jasmine.createSpy('onChange') html = 'Test HTML' @component = ReactTestUtils.renderIntoDocument( - + ) @editableNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@component, 'contentEditable')) describe "render", -> it 'should render into the document', -> - expect(ReactTestUtils.isCompositeComponentWithType @component, ContenteditableComponent).toBe true + expect(ReactTestUtils.isCompositeComponentWithType @component, Contenteditable).toBe true it "should include a content-editable div", -> expect(@editableNode).toBeDefined() @@ -62,7 +62,7 @@ describe "ContenteditableComponent", -> it "should save the image to a temporary file and call `onFilePaste`", -> onPaste = jasmine.createSpy('onPaste') @component = ReactTestUtils.renderIntoDocument( - + ) @editableNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@component, 'contentEditable')) runs -> diff --git a/src/browser/application.coffee b/src/browser/application.coffee index cff205700..3a560d016 100644 --- a/src/browser/application.coffee +++ b/src/browser/application.coffee @@ -333,6 +333,9 @@ class Application ipc.on 'from-react-remote-window', (event, json) => @windowManager.sendToMainWindow('from-react-remote-window', json) + ipc.on 'from-react-remote-window-selection', (event, json) => + @windowManager.sendToMainWindow('from-react-remote-window-selection', json) + ipc.on 'inline-style-parse', (event, {body, clientId}) => juice = require 'juice' try diff --git a/internal_packages/composer/lib/clipboard-service.coffee b/src/components/clipboard-service.coffee similarity index 100% rename from internal_packages/composer/lib/clipboard-service.coffee rename to src/components/clipboard-service.coffee diff --git a/internal_packages/composer/lib/contenteditable-component.cjsx b/src/components/contenteditable.cjsx similarity index 93% rename from internal_packages/composer/lib/contenteditable-component.cjsx rename to src/components/contenteditable.cjsx index c553db5e2..9a6c988cc 100644 --- a/internal_packages/composer/lib/contenteditable-component.cjsx +++ b/src/components/contenteditable.cjsx @@ -2,14 +2,13 @@ _ = require 'underscore' React = require 'react' {Utils, - DOMUtils, - DraftStore} = require 'nylas-exports' + DOMUtils} = require 'nylas-exports' ClipboardService = require './clipboard-service' FloatingToolbarContainer = require './floating-toolbar-container' -class ContenteditableComponent extends React.Component - @displayName: "ContenteditableComponent" +class Contenteditable extends React.Component + @displayName: "Contenteditable" @propTypes: html: React.PropTypes.string initialSelectionSnapshot: React.PropTypes.object @@ -23,8 +22,26 @@ class ContenteditableComponent extends React.Component onScrollTo: React.PropTypes.func onScrollToBottom: React.PropTypes.func + # A series of callbacks that can get executed at various points along + # the contenteditable. Has the keys: + lifecycleCallbacks: React.PropTypes.object + + spellcheck: React.PropTypes.bool + + floatingToolbar: React.PropTypes.bool + @defaultProps: filters: [] + spellcheck: true + floatingToolbar: true + lifecycleCallbacks: + componentDidUpdate: (editableNode) -> + onInput: (editableNode, event) -> + onTabDown: (editableNode, event, range) -> + onLearnSpelling: (editableNode, text) -> + onSubstitutionPerformed: (editableNode) -> + onMouseUp: (editableNode, event, range) -> + constructor: (@props) -> @innerState = {} @@ -66,19 +83,23 @@ class ContenteditableComponent extends React.Component @_restoreSelection() editableNode = @_editableNode() - for extension in DraftStore.extensions() - extension.onComponentDidUpdate(@_editableNode()) if extension.onComponentDidUpdate + + @props.lifecycleCallbacks.componentDidUpdate(editableNode) @setInnerState links: editableNode.querySelectorAll("*[href]") editableNode: editableNode + _renderFloatingToolbar: -> + return unless @props.floatingToolbar + + render: =>
- + {@_renderFloatingToolbar()}
@_createLists() - _runExtensionFilters: (event) -> - for extension in DraftStore.extensions() - extension.onInput(@_editableNode(), event) if extension.onInput - _saveNewHtml: -> html = @_editableNode().innerHTML for filter in @props.filters @@ -262,8 +279,7 @@ class ContenteditableComponent extends React.Component editableNode = @_editableNode() range = DOMUtils.getRangeInScope(editableNode) - for extension in DraftStore.extensions() - extension.onTabDown(editableNode, range, event) if extension.onTabDown + @props.lifecycleCallbacks.onTabDown(editableNode, event, range) return if event.defaultPrevented @_onTabDownDefaultBehavior(event) @@ -684,7 +700,6 @@ class ContenteditableComponent extends React.Component clipboard = require('clipboard') Menu = remote.require('menu') MenuItem = remote.require('menu-item') - spellchecker = require('spellchecker') apply = (newtext) => range.deleteContents() @@ -693,15 +708,7 @@ class ContenteditableComponent extends React.Component range.selectNode(node) selection.removeAllRanges() selection.addRange(range) - for extension in DraftStore.extensions() - if extension.onSubstitutionPerformed - extension.onSubstitutionPerformed(@_editableNode()) - - learnSpelling = => - spellchecker.add(text) - for extension in DraftStore.extensions() - if extension.onLearnSpelling - extension.onLearnSpelling(@_editableNode(), text) + @props.lifecycleCallbacks.onSubstitutionPerformed(@_editableNode()) cut = => clipboard.writeText(text) @@ -715,17 +722,23 @@ class ContenteditableComponent extends React.Component menu = new Menu() - if spellchecker.isMisspelled(text) - corrections = spellchecker.getCorrectionsForMisspelling(text) - if corrections.length > 0 - corrections.forEach (correction) -> - menu.append(new MenuItem({ label: correction, click:( -> apply(correction))})) - else - menu.append(new MenuItem({ label: 'No Guesses Found', enabled: false})) + ## TODO, move into spellcheck package + if @props.spellcheck + spellchecker = require('spellchecker') + learnSpelling = => + spellchecker.add(text) + @props.lifecycleCallbacks.onLearnSpelling(@_editableNode(), text) + if spellchecker.isMisspelled(text) + corrections = spellchecker.getCorrectionsForMisspelling(text) + if corrections.length > 0 + corrections.forEach (correction) -> + menu.append(new MenuItem({ label: correction, click:( -> apply(correction))})) + else + menu.append(new MenuItem({ label: 'No Guesses Found', enabled: false})) - menu.append(new MenuItem({ type: 'separator' })) - menu.append(new MenuItem({ label: 'Learn Spelling', click: learnSpelling})) - menu.append(new MenuItem({ type: 'separator' })) + menu.append(new MenuItem({ type: 'separator' })) + menu.append(new MenuItem({ label: 'Learn Spelling', click: learnSpelling})) + menu.append(new MenuItem({ type: 'separator' })) menu.append(new MenuItem({ label: 'Cut', click:cut})) menu.append(new MenuItem({ label: 'Copy', click:copy})) @@ -771,14 +784,10 @@ class ContenteditableComponent extends React.Component return event unless DOMUtils.selectionInScope(selection, editableNode) range = DOMUtils.getRangeInScope(editableNode) - if range - try - for extension in DraftStore.extensions() - extension.onMouseUp(editableNode, range, event) if extension.onMouseUp - catch e - console.log('DraftStore extension raised an error: '+e.toString()) - event + @props.lifecycleCallbacks.onMouseUp(editableNode, event, range) + + return event _onDragStart: (event) => editable = @_editableNode() @@ -881,4 +890,4 @@ class ContenteditableComponent extends React.Component @_setupSelectionListeners() @_onInput() -module.exports = ContenteditableComponent +module.exports = Contenteditable diff --git a/internal_packages/composer/lib/floating-toolbar-container.cjsx b/src/components/floating-toolbar-container.cjsx similarity index 100% rename from internal_packages/composer/lib/floating-toolbar-container.cjsx rename to src/components/floating-toolbar-container.cjsx diff --git a/internal_packages/composer/lib/floating-toolbar.cjsx b/src/components/floating-toolbar.cjsx similarity index 100% rename from internal_packages/composer/lib/floating-toolbar.cjsx rename to src/components/floating-toolbar.cjsx diff --git a/src/flux/stores/preferences-section-store.coffee b/src/flux/stores/preferences-section-store.coffee new file mode 100644 index 000000000..e222460f4 --- /dev/null +++ b/src/flux/stores/preferences-section-store.coffee @@ -0,0 +1,75 @@ +_ = require 'underscore' +NylasStore = require 'nylas-store' + +class SectionConfig + constructor: (opts={}) -> + opts.order ?= Infinity + _.extend(@, opts) + + nameOrUrl: -> + if _.isFunction(@icon) + icon = @icon() + else + icon = @icon + + if icon.indexOf("nylas://") is 0 + return {url: icon} + else + return {name: icon} + +class PreferencesSectionStore extends NylasStore + constructor: -> + @_sectionConfigs = [] + @_triggerSoon ?= _.debounce(( => @trigger()), 20) + @Section = {} + @SectionConfig = SectionConfig + + sections: => + @_sectionConfigs + + # TODO: Use our Class + # TODO: Add in a "richtext" input type in addition to standard input + # types. + registerPreferences: (packageId, config) -> + throw new Error("Not implemented yet") + + unregisterPreferences: (packageId) -> + throw new Error("Not implemented yet") + + ### + Public: Register a new top-level section to preferences + + - `sectionConfig` a `PreferencesSectionStore.SectionConfig` object + - `icon` A `nylas://` url or image name. Can be a function that + resolves to one of these + schema definitions on the PreferencesSectionStore.Section.MySectionId + - `sectionId` A unique name to access the Section by + - `displayName` The display name. This may go through i18n. + - `component` The Preference section's React Component. + + Most Preference sections include an area where a {PreferencesForm} is + rendered. This is a type of {GeneratedForm} that uses the schema passed + into {PreferencesSectionStore::registerPreferences} + + Note that `icon` gets passed into the `url` field of a {RetinaImg}. This + will, in an ideal case, expect to find the following images: + + - my-icon-darwin@1x.png + - my-icon-darwin@2x.png + - my-icon-win32@1x.png + - my-icon-win32@2x.png + + ### + registerPreferenceSection: (sectionConfig) -> + @Section[sectionConfig.sectionId] = sectionConfig.sectionId + @_sectionConfigs.push(sectionConfig) + @_sectionConfigs = _.sortBy(@_sectionConfigs, "order") + @_triggerSoon() + + unregisterPreferenceSection: (sectionId) -> + delete @Section[sectionId] + @_sectionConfigs = _.reject @_sectionConfigs, (sectionConfig) -> + sectionConfig.sectionId is sectionId + @_triggerSoon() + +module.exports = new PreferencesSectionStore() diff --git a/src/global/nylas-component-kit.coffee b/src/global/nylas-component-kit.coffee index 2b2930dc7..309b043df 100644 --- a/src/global/nylas-component-kit.coffee +++ b/src/global/nylas-component-kit.coffee @@ -21,6 +21,7 @@ class NylasComponentKit @load "DraggableImg", 'draggable-img' @load "EventedIFrame", 'evented-iframe' @load "ButtonDropdown", 'button-dropdown' + @load "Contenteditable", 'contenteditable' @load "MultiselectList", 'multiselect-list' @load "InjectedComponent", 'injected-component' @load "TokenizingTextField", 'tokenizing-text-field' diff --git a/src/global/nylas-exports.coffee b/src/global/nylas-exports.coffee index 892466709..9490d5f44 100644 --- a/src/global/nylas-exports.coffee +++ b/src/global/nylas-exports.coffee @@ -112,6 +112,7 @@ class NylasExports @require "FocusedContactsStore", 'flux/stores/focused-contacts-store' @require "MessageBodyProcessor", 'flux/stores/message-body-processor' @require "MessageStoreExtension", 'flux/stores/message-store-extension' + @require "PreferencesSectionStore", 'flux/stores/preferences-section-store' # React Components @get "React", -> require 'react' # Our version of React for 3rd party use diff --git a/src/react-remote/react-remote-child.js b/src/react-remote/react-remote-child.js index 6eaaa70b6..86c0cd340 100644 --- a/src/react-remote/react-remote-child.js +++ b/src/react-remote/react-remote-child.js @@ -1,12 +1,24 @@ +var _ = require('underscore') var container = document.getElementById("container"); var ipc = require('ipc'); +var lastSelectionData = {} document.body.classList.add("platform-"+process.platform); document.body.classList.add("window-type-react-remote"); +exp = require('./selection-listeners.js'); +restoreSelection = exp.restoreSelection; +getSelectionData = exp.getSelectionData; + var receiveEvent = function (json) { var remote = require('remote'); + if (json.selectionData) { + document.removeEventListener("selectionchange", selectionChange); + restoreSelection(json.selectionData) + document.addEventListener("selectionchange", selectionChange); + } + if (json.html) { var browserWindow = remote.getCurrentWindow(); browserWindow.on('focus', function() { @@ -86,7 +98,10 @@ events.forEach(function(type) { if (event.target) { representation.targetReactId = event.target.dataset.reactid; } - if (event.target.value !== undefined) { + if (event.target.contentEditable=="true") { + representation.targetValue = event.target.innerHTML; + } + else if (event.target.value !== undefined) { representation.targetValue = event.target.value; } if (event.target.checked !== undefined) { @@ -100,3 +115,14 @@ events.forEach(function(type) { } }, true); }); + + +selectionChange = function() { + selectionData = getSelectionData() + if (_.isEqual(selectionData, lastSelectionData)) { return; } + lastSelectionData = _.clone(selectionData) + var remote = require('remote'); + remote.getCurrentWindow().id + ipc.send("from-react-remote-window-selection", selectionData); +} +// document.addEventListener("selectionchange", selectionChange); diff --git a/src/react-remote/react-remote-parent.js b/src/react-remote/react-remote-parent.js index 42626a216..e5a934f04 100644 --- a/src/react-remote/react-remote-parent.js +++ b/src/react-remote/react-remote-parent.js @@ -5,6 +5,7 @@ var LinkedValueUtils = require('react/lib/LinkedValueUtils'); var ReactDOMComponent = require('react/lib/ReactDOMComponent'); var methods = Object.keys(ReactDOMComponent.BackendIDOperations); var invocationTargets = []; +var lastSelectionData = {} var sources = { CSSPropertyOperations: require('react/lib/CSSPropertyOperations'), @@ -133,7 +134,10 @@ ipc.on('from-react-remote-window', function(json) { if (rep.targetReactId) { rep.target = document.querySelector(["[data-reactid='"+rep.targetReactId+"']"]); } - if (rep.target && (rep.targetValue !== undefined)) { + if (rep.target && rep.target.contentEditable=="true") { + rep.target.innerHTML = rep.targetValue; + } + else if (rep.target && (rep.targetValue !== undefined)) { rep.target.value = rep.targetValue; } if (rep.target && (rep.targetChecked !== undefined)) { @@ -148,16 +152,36 @@ ipc.on('from-react-remote-window', function(json) { var e = new EventClass(rep.eventType, rep); - process.nextTick(function() { - if (rep.target) { - rep.target.dispatchEvent(e); - } else { - container.dispatchEvent(e); - } - }); + if (rep.target) { + rep.target.dispatchEvent(e); + } else { + container.dispatchEvent(e); + } } }); +exp = require('./selection-listeners.js'); +restoreSelection = exp.restoreSelection; +getSelectionData = exp.getSelectionData; + +selectionChange = function() { + selectionData = getSelectionData(); + if (_.isEqual(selectionData, lastSelectionData)) { return; } + lastSelectionData = _.clone(selectionData) + for (var i = 0; i < invocationTargets.length; i++) { + var target = invocationTargets[i]; + target.send({selectionData: selectionData}) + } +} +// document.addEventListener("selectionchange", selectionChange); + +ipc.on('from-react-remote-window-selection', function(selectionData){ + document.removeEventListener("selectionchange", selectionChange) + restoreSelection(selectionData) + document.addEventListener("selectionchange", selectionChange); +}); + + var parentListenersAttached = false; var reactRemoteContainer = document.createElement('div'); reactRemoteContainer.style.left = '-10000px'; diff --git a/src/react-remote/selection-listeners.js b/src/react-remote/selection-listeners.js new file mode 100644 index 000000000..16212b5a8 --- /dev/null +++ b/src/react-remote/selection-listeners.js @@ -0,0 +1,78 @@ +pathRelativeToReactNode = function (node, stack) { + if (!node || !node.parentNode) { + return stack; + } + if (node.dataset && node.dataset.reactid) { + return stack + } else { + index = -1; + if (node.parentNode && node.parentNode.childNodes) { + for (var i=0; i < node.parentNode.childNodes.length; i++) { + if (node.parentNode.childNodes[i] == node) { + index = i; + } + } + stack.unshift(index) + } + return pathRelativeToReactNode(node.parentNode, stack) + } +} + +restoreSelection = function(selectionData) { + anchorNode = document.querySelector(["[data-reactid='"+selectionData.anchorReactId+"']"]); + focusNode = document.querySelector(["[data-reactid='"+selectionData.focusReactId+"']"]); + if (anchorNode && focusNode) { + for (var i=0; i < selectionData.anchorStack.length; i++) { + childIndex = selectionData.anchorStack[i] + if (anchorNode.childNodes) { + anchorNode = anchorNode.childNodes[childIndex]; + } + } + for (var i=0; i < selectionData.focusStack.length; i++) { + childIndex = selectionData.focusStack[i] + if (focusNode.childNodes) { + focusNode = focusNode.childNodes[childIndex] + } + } + selection = document.getSelection(); + console.log("Setting selection", anchorNode, selectionData.anchorOffset, focusNode, selectionData.focusOffset) + selection.setBaseAndExtent(anchorNode, + selectionData.anchorOffset, + focusNode, + selectionData.focusOffset) + } +} + +getSelectionData = function(){ + selection = document.getSelection(); + selectionData = { + anchorReactId: null, + focusReactId: null, + anchorOffset: selection.anchorOffset, + focusOffset: selection.focusOffset, + }; + if (selection.anchorNode) { + anchorStack = pathRelativeToReactNode(selection.anchorNode, []); + reactNode = selection.anchorNode; + for (var i=0; i < anchorStack.length; i++) { + reactNode = reactNode.parentNode; + } + selectionData.anchorReactId = reactNode.dataset.reactid + selectionData.anchorStack = anchorStack + } + if (selection.focusNode) { + focusStack = pathRelativeToReactNode(selection.focusNode, []); + reactNode = selection.focusNode; + for (var i=0; i < focusStack.length; i++) { + reactNode = reactNode.parentNode; + } + selectionData.focusReactId = reactNode.dataset.reactid + selectionData.focusStack = focusStack + } + return selectionData +} + +module.exports = { + restoreSelection: restoreSelection, + getSelectionData: getSelectionData +};