mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-09-08 13:44:53 +08:00
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
This commit is contained in:
parent
fca5db4e45
commit
fa3a2ee631
27 changed files with 508 additions and 189 deletions
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
@ -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
|
||||
|
|
|
@ -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) ->
|
||||
<option value={account.id}>{account.emailAddress}</option>
|
||||
|
||||
<select value={@state.selectedAccountId} onChange={@_onSelectAccount}>
|
||||
{options}
|
||||
</select>
|
||||
|
||||
_renderCurrentSignature: ->
|
||||
<Contenteditable
|
||||
ref="signatureInput"
|
||||
html={@state.currentSignature}
|
||||
onChange={@_onEditSignature}
|
||||
spellcheck={false}
|
||||
floatingToolbar={false} />
|
||||
|
||||
_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: =>
|
||||
<div className="container-signatures">
|
||||
<div className="section-title">
|
||||
Signature for: {@_renderAccountPicker()}
|
||||
</div>
|
||||
<div>
|
||||
{@_renderCurrentSignature()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
module.exports = PreferencesSignatures
|
|
@ -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('<blockquote')
|
|
@ -1,6 +1,6 @@
|
|||
{Message} = require 'nylas-exports'
|
||||
|
||||
SignatureDraftStoreExtension = require '../lib/draft-extension'
|
||||
SignatureDraftStoreExtension = require '../lib/signature-draft-extension'
|
||||
|
||||
describe "SignatureDraftStoreExtension", ->
|
||||
describe "prepareNewDraft", ->
|
||||
|
|
|
@ -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
|
|||
</span>
|
||||
|
||||
_renderBodyContenteditable: ->
|
||||
<ContenteditableComponent
|
||||
<Contenteditable
|
||||
ref={Fields.Body}
|
||||
html={@state.body}
|
||||
onFocus={ => @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
|
||||
|
|
|
@ -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 <strong>HTML</strong><br>'
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -15,18 +15,19 @@ class PreferencesHeader extends React.Component
|
|||
imgMode = RetinaImg.Mode.ContentIsMask
|
||||
else
|
||||
imgMode = RetinaImg.Mode.ContentPreserve
|
||||
<div className="preference-header">
|
||||
{ @props.tabs.map (tab) =>
|
||||
classname = "preference-header-item"
|
||||
classname += " active" if tab is @props.activeTab
|
||||
|
||||
<div className={classname} onClick={ => @props.changeActiveTab(tab) } key={tab.name}>
|
||||
<div className="preference-header">
|
||||
{ @props.tabs.map (sectionConfig) =>
|
||||
classname = "preference-header-item"
|
||||
classname += " active" if sectionConfig is @props.activeTab
|
||||
|
||||
<div className={classname} onClick={ => @props.changeActiveTab(sectionConfig) } key={sectionConfig.sectionId}>
|
||||
<div className="phi-container">
|
||||
<div className="icon">
|
||||
<RetinaImg mode={imgMode} name={tab.icon} />
|
||||
<RetinaImg mode={imgMode} {...sectionConfig.nameOrUrl()} />
|
||||
</div>
|
||||
<div className="name">
|
||||
{tab.name}
|
||||
{sectionConfig.displayName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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()
|
|
@ -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
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
React = require 'react'
|
||||
_ = require 'underscore'
|
||||
{RetinaImg, Flexbox} = require 'nylas-component-kit'
|
||||
|
||||
class PreferencesSignatures extends React.Component
|
||||
@displayName: 'PreferencesSignatures'
|
||||
|
||||
render: =>
|
||||
<div className="container-signatures">
|
||||
<div className="section-signaturces">
|
||||
<div className="section-title">
|
||||
Signatures
|
||||
</div>
|
||||
<div className="section-body">
|
||||
<Flexbox direction="row" style={alignItems: "top"}>
|
||||
<div style={flex: 2}>
|
||||
<div className="menu">
|
||||
<ul className="menu-items">
|
||||
<li>Personal</li>
|
||||
<li>Corporate</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="menu-footer">
|
||||
<div className="menu-horizontal">
|
||||
<ul className="menu-items">
|
||||
<li>+</li>
|
||||
<li>-</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={flex: 5}>
|
||||
<div className="signature-area">
|
||||
Signature
|
||||
</div>
|
||||
<div className="signature-footer">
|
||||
<button className="edit-html-button btn">Edit HTML</button>
|
||||
<div className="menu-horizontal">
|
||||
<ul className="menu-items">
|
||||
<li><b>B</b></li>
|
||||
<li><i>I</i></li>
|
||||
<li><u>u</u></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Flexbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
module.exports = PreferencesSignatures
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
ClipboardService = require '../lib/clipboard-service'
|
||||
ClipboardService = require '../../src/components/clipboard-service'
|
||||
|
||||
describe "ClipboardService", ->
|
||||
beforeEach ->
|
|
@ -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 <strong>HTML</strong>'
|
||||
@component = ReactTestUtils.renderIntoDocument(
|
||||
<ContenteditableComponent html={html} onChange={@onChange}/>
|
||||
<Contenteditable html={html} onChange={@onChange}/>
|
||||
)
|
||||
@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(
|
||||
<ContenteditableComponent html={''} onChange={@onChange} onFilePaste={onPaste} />
|
||||
<Contenteditable html={''} onChange={@onChange} onFilePaste={onPaste} />
|
||||
)
|
||||
@editableNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@component, 'contentEditable'))
|
||||
runs ->
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
<FloatingToolbarContainer
|
||||
ref="toolbarController"
|
||||
onSaveUrl={@_onSaveUrl}
|
||||
onDomMutator={@_onDomMutator} />
|
||||
|
||||
render: =>
|
||||
<div className="contenteditable-container">
|
||||
<FloatingToolbarContainer
|
||||
ref="toolbarController"
|
||||
onSaveUrl={@_onSaveUrl}
|
||||
onDomMutator={@_onDomMutator} />
|
||||
{@_renderFloatingToolbar()}
|
||||
|
||||
<div id="contenteditable"
|
||||
ref="contenteditable"
|
||||
|
@ -143,7 +164,7 @@ class ContenteditableComponent extends React.Component
|
|||
|
||||
@_runCoreFilters()
|
||||
|
||||
@_runExtensionFilters(event)
|
||||
@props.lifecycleCallbacks.onInput(@_editableNode(), event)
|
||||
|
||||
@_normalize()
|
||||
|
||||
|
@ -162,10 +183,6 @@ class ContenteditableComponent extends React.Component
|
|||
_runCoreFilters: ->
|
||||
@_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
|
75
src/flux/stores/preferences-section-store.coffee
Normal file
75
src/flux/stores/preferences-section-store.coffee
Normal file
|
@ -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 <GeneratedForm /> 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()
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
28
src/react-remote/react-remote-child.js
vendored
28
src/react-remote/react-remote-child.js
vendored
|
@ -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);
|
||||
|
|
40
src/react-remote/react-remote-parent.js
vendored
40
src/react-remote/react-remote-parent.js
vendored
|
@ -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';
|
||||
|
|
78
src/react-remote/selection-listeners.js
vendored
Normal file
78
src/react-remote/selection-listeners.js
vendored
Normal file
|
@ -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
|
||||
};
|
Loading…
Add table
Reference in a new issue