mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-21 15:56:10 +08:00
feat(templates-plugin): Add prefs page with template editor, fix bugs.
[Composer Templates / Quick Reply plugin example] Add a page to preferences with a basic editor for quick reply templates, allowing add, delete, and edit (HTML and rendered text), with instructions for how to add variable regions. Add methods to TemplatesStore to enable these. Fix issues in plugin with quoted text handling, name conflicts, `this` scoping.
This commit is contained in:
parent
031c4af043
commit
20c522ed39
|
@ -1,4 +1,4 @@
|
|||
import {ComponentRegistry, DraftStore} from 'nylas-exports';
|
||||
import {PreferencesUIStore, ComponentRegistry, DraftStore} from 'nylas-exports';
|
||||
import TemplatePicker from './template-picker';
|
||||
import TemplateStatusBar from './template-status-bar';
|
||||
import Extension from './template-draft-extension';
|
||||
|
@ -8,14 +8,21 @@ module.exports = {
|
|||
|
||||
activate(state = {}) {
|
||||
this.state = state;
|
||||
this.preferencesTab = new PreferencesUIStore.TabItem({
|
||||
tabId: "Quick Replies",
|
||||
displayName: "Quick Replies",
|
||||
component: require("./preferences-templates"),
|
||||
});
|
||||
ComponentRegistry.register(TemplatePicker, {role: 'Composer:ActionButton'});
|
||||
ComponentRegistry.register(TemplateStatusBar, {role: 'Composer:Footer'});
|
||||
PreferencesUIStore.registerPreferencesTab(this.preferencesTab);
|
||||
return DraftStore.registerExtension(Extension);
|
||||
},
|
||||
|
||||
deactivate() {
|
||||
ComponentRegistry.unregister(TemplatePicker);
|
||||
ComponentRegistry.unregister(TemplateStatusBar);
|
||||
PreferencesUIStore.unregisterPreferencesTab(this.preferencesTab.sectionId);
|
||||
return DraftStore.unregisterExtension(Extension);
|
||||
},
|
||||
|
||||
|
|
238
examples/N1-Composer-Templates/lib/preferences-templates.cjsx
Normal file
238
examples/N1-Composer-Templates/lib/preferences-templates.cjsx
Normal file
|
@ -0,0 +1,238 @@
|
|||
_ = require 'underscore'
|
||||
{Contenteditable, RetinaImg, Flexbox} = require 'nylas-component-kit'
|
||||
{AccountStore, Utils, React} = require 'nylas-exports'
|
||||
TemplateStore = require './template-store';
|
||||
|
||||
class PreferencesTemplates extends React.Component
|
||||
@displayName: 'PreferencesTemplates'
|
||||
|
||||
constructor: (@props) ->
|
||||
TemplateStore.init();
|
||||
@_templateSaveQueue = {}
|
||||
|
||||
@state =
|
||||
editAsHTML: false
|
||||
editState: null
|
||||
templates: []
|
||||
selectedTemplate: null
|
||||
selectedTemplateName: null
|
||||
contents: null
|
||||
|
||||
componentDidMount: ->
|
||||
@usub = TemplateStore.listen @_onChange
|
||||
|
||||
componentWillUnmount: ->
|
||||
@usub()
|
||||
if @state.selectedTemplate?
|
||||
@_saveTemplateNow(@state.selectedTemplate.name, @state.contents)
|
||||
|
||||
|
||||
|
||||
#SAVING AND LOADING TEMPLATES
|
||||
_loadTemplateContents: (template) =>
|
||||
if template
|
||||
TemplateStore.getTemplateContents(template.id, (contents) =>
|
||||
@setState({contents: contents})
|
||||
)
|
||||
|
||||
_saveTemplateNow: (name, contents, callback) =>
|
||||
TemplateStore.saveTemplate(name, contents, false, callback)
|
||||
|
||||
_saveTemplateSoon: (name, contents) =>
|
||||
@_templateSaveQueue[name] = contents
|
||||
@_saveTemplatesFromCache()
|
||||
|
||||
__saveTemplatesFromCache: =>
|
||||
for name, contents of @_templateSaveQueue
|
||||
@_saveTemplateNow(name, contents)
|
||||
|
||||
@_templateSaveQueue = {}
|
||||
|
||||
_saveTemplatesFromCache: _.debounce(PreferencesTemplates::__saveTemplatesFromCache, 500)
|
||||
|
||||
|
||||
|
||||
# OVERALL STATE HANDLING
|
||||
_onChange: =>
|
||||
@setState @_getStateFromStores()
|
||||
|
||||
_getStateFromStores: ->
|
||||
templates = TemplateStore.items()
|
||||
selectedTemplate = @state.selectedTemplate
|
||||
if selectedTemplate? and selectedTemplate.id not in _.pluck(templates, "id")
|
||||
selectedTemplate = null
|
||||
else if not selectedTemplate?
|
||||
selectedTemplate = if templates.length > 0 then templates[0] else null
|
||||
@_loadTemplateContents(selectedTemplate)
|
||||
if selectedTemplate?
|
||||
selectedTemplateName = @state.selectedTemplateName || selectedTemplate.name
|
||||
return {templates, selectedTemplate, selectedTemplateName}
|
||||
|
||||
|
||||
|
||||
# TEMPLATE CONTENT EDITING
|
||||
_onEditTemplate: (event) =>
|
||||
html = event.target.value
|
||||
@setState contents: html
|
||||
if @state.selectedTemplate?
|
||||
@_saveTemplateSoon(@state.selectedTemplate.name, html)
|
||||
|
||||
_onSelectTemplate: (event) =>
|
||||
if @state.selectedTemplate?
|
||||
@_saveTemplateNow(@state.selectedTemplate.name, @state.contents)
|
||||
selectedTemplate = null
|
||||
for template in @state.templates
|
||||
if template.id == event.target.value
|
||||
selectedTemplate = template
|
||||
@setState
|
||||
selectedTemplate: selectedTemplate
|
||||
selectedTemplateName: selectedTemplate?.name
|
||||
contents: null
|
||||
@_loadTemplateContents(selectedTemplate)
|
||||
|
||||
_renderTemplatePicker: ->
|
||||
options = @state.templates.map (template) ->
|
||||
<option value={template.id} key={template.id}>{template.name}</option>
|
||||
|
||||
<select value={@state.selectedTemplate?.id} onChange={@_onSelectTemplate}>
|
||||
{options}
|
||||
</select>
|
||||
|
||||
_renderEditableTemplate: ->
|
||||
<Contenteditable
|
||||
ref="templateInput"
|
||||
value={@state.contents}
|
||||
onChange={@_onEditTemplate}
|
||||
spellcheck={false} />
|
||||
|
||||
_renderHTMLTemplate: ->
|
||||
<textarea ref="templateHTMLInput"
|
||||
value={@state.contents}
|
||||
onChange={@_onEditTemplate}/>
|
||||
|
||||
_renderModeToggle: ->
|
||||
if @state.editAsHTML
|
||||
return <a onClick={=> @setState(editAsHTML: false); return}>Edit live preview</a>
|
||||
else
|
||||
return <a onClick={=> @setState(editAsHTML: true); return}>Edit raw HTML</a>
|
||||
|
||||
|
||||
|
||||
# TEMPLATE NAME EDITING
|
||||
_renderEditName: ->
|
||||
<div className="section-title">
|
||||
Template Name: <input type="text" className="template-name-input" value={@state.selectedTemplateName} onChange={@_onEditName}/>
|
||||
<button className="btn template-name-btn" onClick={@_saveName}>Save Name</button>
|
||||
<button className="btn template-name-btn" onClick={@_cancelEditName}>Cancel</button>
|
||||
</div>
|
||||
|
||||
_renderName: ->
|
||||
rawText = if @state.editAsHTML then "Raw HTML " else ""
|
||||
<div className="section-title">
|
||||
{rawText}Template: {@_renderTemplatePicker()}
|
||||
<button className="btn template-name-btn" title="New template" onClick={@_startNewTemplate}>New</button>
|
||||
<button className="btn template-name-btn" onClick={ => @setState(editState: "name") }>Rename</button>
|
||||
</div>
|
||||
|
||||
_onEditName: =>
|
||||
@setState({selectedTemplateName: event.target.value})
|
||||
|
||||
_cancelEditName: =>
|
||||
@setState
|
||||
selectedTemplateName: @state.selectedTemplate?.name
|
||||
editState: null
|
||||
|
||||
_saveName: =>
|
||||
if @state.selectedTemplate?.name != @state.selectedTemplateName
|
||||
TemplateStore.renameTemplate(@state.selectedTemplate.name, @state.selectedTemplateName, (renamedTemplate) =>
|
||||
@setState
|
||||
selectedTemplate: renamedTemplate
|
||||
editState: null
|
||||
)
|
||||
else
|
||||
@setState
|
||||
editState: null
|
||||
|
||||
|
||||
# DELETE AND NEW
|
||||
_deleteTemplate: =>
|
||||
if @state.selectedTemplate?
|
||||
TemplateStore.deleteTemplate(@state.selectedTemplate.name)
|
||||
|
||||
_startNewTemplate: =>
|
||||
@setState
|
||||
editState: "new"
|
||||
selectedTemplate: null
|
||||
selectedTemplateName: ""
|
||||
contents: ""
|
||||
|
||||
_saveNewTemplate: =>
|
||||
TemplateStore.saveTemplate(@state.selectedTemplateName, @state.contents, true, (template) =>
|
||||
@setState
|
||||
selectedTemplate: template
|
||||
editState: null
|
||||
)
|
||||
|
||||
_cancelNewTemplate: =>
|
||||
template = if @state.templates.length>0 then @state.templates[0] else null
|
||||
@setState
|
||||
selectedTemplate: template
|
||||
selectedTemplateName: template?.name
|
||||
editState: null
|
||||
@_loadTemplateContents(template)
|
||||
|
||||
_renderCreateNew: ->
|
||||
<div className="section-title">
|
||||
Template Name: <input type="text" className="template-name-input" value={@state.selectedTemplateName} onChange={@_onEditName}/>
|
||||
<button className="btn btn-emphasis template-name-btn" onClick={@_saveNewTemplate}>Save</button>
|
||||
<button className="btn template-name-btn" onClick={@_cancelNewTemplate}>Cancel</button>
|
||||
</div>
|
||||
|
||||
|
||||
# MAIN RENDER
|
||||
render: =>
|
||||
deleteBtn =
|
||||
<button className="btn template-name-btn" title="Delete template" onClick={@_deleteTemplate}>
|
||||
<RetinaImg name="icon-composer-trash.png" mode={RetinaImg.Mode.ContentIsMask} />
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<section className="container-templates">
|
||||
<h2>Quick Replies</h2>
|
||||
{
|
||||
switch @state.editState
|
||||
when "name" then @_renderEditName()
|
||||
when "new" then @_renderCreateNew()
|
||||
else @_renderName()
|
||||
}
|
||||
<div className="template-wrap">
|
||||
{if @state.editAsHTML then @_renderHTMLTemplate() else @_renderEditableTemplate()}
|
||||
</div>
|
||||
<span className="editor-note">
|
||||
{ if _.size(@_templateSaveQueue) > 0 then "Saving changes..." else "Changes saved." }
|
||||
</span>
|
||||
<span style={float:"right"}>{if @state.editState == null then deleteBtn else ""}</span>
|
||||
<div className="toggle-mode" style={marginTop: "1em"}>
|
||||
{@_renderModeToggle()}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="templates-instructions">
|
||||
<p>
|
||||
The Quick Replies plugin lets you write preset templates to use as email responses. Replies can contain variables, which
|
||||
you can quickly jump between and fill out when using the template.
|
||||
</p>
|
||||
<p>
|
||||
Variables are defined as HTML <code> tags with class "var". You can include these by editing the raw HTML of the template and adding <code><code class="var">[content]</code></code>. Add
|
||||
the "empty" class to make a region dark yellow and indicate that it should be filled in. When you send your message, <code>
|
||||
tags are always stripped so the recipient never sees any highlighting.
|
||||
</p>
|
||||
<p>
|
||||
Templates live in the <strong>~/.nylas/templates</strong> directory on your computer. Each template
|
||||
is an HTML file - the name of the file is the name of the template, and its contents are the default message body.
|
||||
</p>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
|
||||
module.exports = PreferencesTemplates
|
|
@ -3,7 +3,7 @@ import {Popover, Menu, RetinaImg} from 'nylas-component-kit';
|
|||
import TemplateStore from './template-store';
|
||||
|
||||
class TemplatePicker extends React.Component {
|
||||
static displayName = 'TemplatePicker'
|
||||
static displayName = 'TemplatePicker';
|
||||
|
||||
static propTypes = {
|
||||
draftClientId: React.PropTypes.string,
|
||||
|
@ -52,16 +52,16 @@ class TemplatePicker extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
_onChooseTemplate = (template)=> {
|
||||
_onChooseTemplate = (template) => {
|
||||
Actions.insertTemplateId({templateId: template.id, draftClientId: this.props.draftClientId});
|
||||
return this.refs.popover.close();
|
||||
}
|
||||
|
||||
_onManageTemplates() {
|
||||
_onManageTemplates = () => {
|
||||
return Actions.showTemplates();
|
||||
}
|
||||
|
||||
_onNewTemplate() {
|
||||
_onNewTemplate = () => {
|
||||
return Actions.createTemplate({draftClientId: this.props.draftClientId});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {DraftStore, Actions} from 'nylas-exports';
|
||||
import {DraftStore, Actions, QuotedHTMLParser} from 'nylas-exports';
|
||||
import NylasStore from 'nylas-store';
|
||||
import shell from 'shell';
|
||||
import path from 'path';
|
||||
|
@ -82,7 +82,7 @@ class TemplateStore extends NylasStore {
|
|||
DraftStore.sessionForClientId(draftClientId).then((session) => {
|
||||
const draft = session.draft();
|
||||
const draftName = name ? name : draft.subject;
|
||||
const draftContents = contents ? contents : draft.body;
|
||||
const draftContents = contents ? contents : QuotedHTMLParser.removeQuotedHTML(draft.body);
|
||||
if (!draftName || draftName.length === 0) {
|
||||
this._displayError('Give your draft a subject to name your template.');
|
||||
}
|
||||
|
@ -113,34 +113,95 @@ class TemplateStore extends NylasStore {
|
|||
}
|
||||
|
||||
_writeTemplate(name, contents) {
|
||||
this.saveTemplate(name, contents, true, (template) => {
|
||||
Actions.switchPreferencesTab('Quick Replies');
|
||||
Actions.openPreferences()
|
||||
});
|
||||
}
|
||||
|
||||
saveTemplate(name, contents, isNew, callback) {
|
||||
const filename = `${name}.html`;
|
||||
const templatePath = path.join(this._templatesDir, filename);
|
||||
|
||||
var template = null;
|
||||
this._items.forEach((item) => {
|
||||
if (item.name === name) { template = item; }
|
||||
});
|
||||
|
||||
if(isNew && template !== null) {
|
||||
this._displayError("A template with that name already exists!");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
fs.writeFile(templatePath, contents, (err) => {
|
||||
if (err) { this._displayError(err); }
|
||||
shell.showItemInFolder(templatePath);
|
||||
this._items.push({
|
||||
id: filename,
|
||||
name: name,
|
||||
path: templatePath,
|
||||
});
|
||||
if (template === null) {
|
||||
template = {
|
||||
id: filename,
|
||||
name: name,
|
||||
path: templatePath
|
||||
};
|
||||
this._items.push(template);
|
||||
}
|
||||
if(callback)
|
||||
callback(template);
|
||||
this.trigger(this);
|
||||
});
|
||||
}
|
||||
|
||||
deleteTemplate(name, callback) {
|
||||
var template = null;
|
||||
this._items.forEach((item) => {
|
||||
if (item.name === name) { template = item; }
|
||||
});
|
||||
if (!template) { return undefined }
|
||||
|
||||
fs.unlink(template.path, () => {
|
||||
this._populate();
|
||||
if(callback)
|
||||
callback()
|
||||
});
|
||||
}
|
||||
|
||||
renameTemplate(oldName, newName, callback) {
|
||||
var template = null;
|
||||
this._items.forEach((item) => {
|
||||
if (item.name === oldName) { template = item; }
|
||||
});
|
||||
if (!template) { return undefined }
|
||||
|
||||
const newFilename = `${newName}.html`;
|
||||
const oldPath = path.join(this._templatesDir, `${oldName}.html`);
|
||||
const newPath = path.join(this._templatesDir, newFilename);
|
||||
fs.rename(oldPath, newPath, () => {
|
||||
template.name = newName;
|
||||
template.id = newFilename;
|
||||
template.path = newPath;
|
||||
this.trigger(this);
|
||||
callback(template)
|
||||
});
|
||||
}
|
||||
|
||||
_onInsertTemplateId({templateId, draftClientId} = {}) {
|
||||
const iterable = this._items;
|
||||
this.getTemplateContents(templateId, (body) => {
|
||||
DraftStore.sessionForClientId(draftClientId).then((session)=> {
|
||||
draftHtml = QuotedHTMLParser.appendQuotedHTML(body, session.draft().body);
|
||||
session.changes.add({body: draftHtml});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getTemplateContents(templateId, callback) {
|
||||
let template = null;
|
||||
for (let i = 0, item; i < iterable.length; i++) {
|
||||
item = iterable[i];
|
||||
this._items.forEach((item) => {
|
||||
if (item.id === templateId) { template = item; }
|
||||
}
|
||||
if (!template) { return undefined; }
|
||||
});
|
||||
|
||||
if (!template) { return undefined }
|
||||
|
||||
fs.readFile(template.path, (err, data)=> {
|
||||
const body = data.toString();
|
||||
DraftStore.sessionForClientId(draftClientId).then((session)=> {
|
||||
session.changes.add({body: body});
|
||||
});
|
||||
callback(body);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
font-size: @font-size-small;
|
||||
}
|
||||
|
||||
.compose-body .contenteditable {
|
||||
.compose-body,.container-templates .contenteditable {
|
||||
code.var {
|
||||
font: inherit;
|
||||
padding:0;
|
||||
|
@ -39,3 +39,94 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.container-templates {
|
||||
max-width: 640px;
|
||||
|
||||
|
||||
.template-wrap {
|
||||
position: relative;
|
||||
border: 1px solid @input-border-color;
|
||||
padding: 10px;
|
||||
margin-top: 20px;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
|
||||
textarea {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
.section-body {
|
||||
padding: 10px 0 0 0;
|
||||
|
||||
.menu {
|
||||
border: solid thin #CCC;
|
||||
margin-right: 5px;
|
||||
min-height: 200px;
|
||||
.menu-items {
|
||||
margin:0;
|
||||
padding:0;
|
||||
list-style: none;
|
||||
|
||||
li { padding: 6px; }
|
||||
}
|
||||
}
|
||||
.menu-horizontal {
|
||||
height: 100%;
|
||||
.menu-items {
|
||||
height:100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
li {
|
||||
text-align:center;
|
||||
width:40px;
|
||||
display:inline-block;
|
||||
padding:8px 16px 8px 16px;
|
||||
border-right: solid thin #CCC;
|
||||
}
|
||||
}
|
||||
}
|
||||
.template-area {
|
||||
border: solid thin #CCC;
|
||||
min-height: 200px;
|
||||
}
|
||||
.menu-footer {
|
||||
border: solid thin #CCC;
|
||||
overflow: auto;
|
||||
}
|
||||
.template-footer {
|
||||
border: solid thin #CCC;
|
||||
overflow: auto;
|
||||
|
||||
.edit-html-button {
|
||||
float: right;
|
||||
margin: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.template-name-btn {
|
||||
float: right;
|
||||
margin: 6px;
|
||||
}
|
||||
.template-name-input {
|
||||
display: inline-block;
|
||||
width: 200px;
|
||||
}
|
||||
.editor-note {
|
||||
color: #AAA;
|
||||
font-size: small;
|
||||
}
|
||||
}
|
||||
.templates-instructions {
|
||||
color: #333;
|
||||
font-size: small;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue