mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-21 07:46:06 +08:00
refactor(templates): major additions and refactoring for the Templates plugin.
Summary: Adds several new features to the templates plugin, fixes some existing bugs, and refactors existing code. New Plugin Features/Fixes: - Changes the templates editor in preferences to allow variables to be entered with `{{brackets}}`. Handles many contenteditable complexities to implement. - Better interaction for renaming and deleting of templates in the editor. - Changes tabbing behavior when using templates. Tabbing between variables now wraps around, and typing tab from outside a variable region highlights the closest region. - Prevents "Enter" key in the composer when inside a variable region, and strips all formatting/tags from within the region - this prevents major contenteditable issues that can result in inline CSS in the style of our variable regions, which will not be removed when sending. - Shows a warning when choosing a template if it will replace existing text in a draft. - Prevents invalid characters in template names (due to filenames, esp. on Windows), and shows an error message. Strips these characters from draft titles when making a template. - Fixes a bug where TemplateStore's initialization code was being called multiple times. New N1 code: - Several new methods in `DOMUtils` useful for working with contenteditable. - Implement some missing methods in `Editor` Refactor: - Major refactor/rewrite of template composer extension to use new DOMUtils methods and simplify the logic (while adding new functionality). Remaining issues: - `preferences-tempaltes.cjsx` and `template-editor.coffee` should be rewritten in ES6 for consistency - Need tests for new DOMUtils functions and for new Templates plugin code. Test Plan: manual, need to update specs Reviewers: evan, bengotow Reviewed By: evan, bengotow Subscribers: juan Differential Revision: https://phab.nylas.com/D2382
This commit is contained in:
parent
4bd46b055f
commit
be800ac89a
|
@ -2,20 +2,21 @@ _ = require 'underscore'
|
|||
{Contenteditable, RetinaImg, Flexbox} = require 'nylas-component-kit'
|
||||
{AccountStore, Utils, React} = require 'nylas-exports'
|
||||
TemplateStore = require './template-store'
|
||||
TemplateEditor = require './template-editor'
|
||||
|
||||
class PreferencesTemplates extends React.Component
|
||||
@displayName: 'PreferencesTemplates'
|
||||
|
||||
constructor: (@props) ->
|
||||
TemplateStore.init()
|
||||
@_templateSaveQueue = {}
|
||||
|
||||
{templates, selectedTemplate, selectedTemplateName} = @_getStateFromStores()
|
||||
@state =
|
||||
editAsHTML: false
|
||||
editState: null
|
||||
templates: []
|
||||
selectedTemplate: null
|
||||
selectedTemplateName: null
|
||||
templates: templates
|
||||
selectedTemplate: selectedTemplate
|
||||
selectedTemplateName: selectedTemplateName
|
||||
contents: null
|
||||
|
||||
componentDidMount: ->
|
||||
|
@ -36,7 +37,7 @@ class PreferencesTemplates extends React.Component
|
|||
)
|
||||
|
||||
_saveTemplateNow: (name, contents, callback) =>
|
||||
TemplateStore.saveTemplate(name, contents, false, callback)
|
||||
TemplateStore.saveTemplate(name, contents, callback)
|
||||
|
||||
_saveTemplateSoon: (name, contents) =>
|
||||
@_templateSaveQueue[name] = contents
|
||||
|
@ -58,25 +59,31 @@ class PreferencesTemplates extends React.Component
|
|||
|
||||
_getStateFromStores: ->
|
||||
templates = TemplateStore.items()
|
||||
selectedTemplate = @state.selectedTemplate
|
||||
#selectedTemplate = _.findWhere(templates, {id: @state?.selectedTemplate?.id}) || templates[0]
|
||||
|
||||
selectedTemplate = @state?.selectedTemplate
|
||||
# deleted
|
||||
if selectedTemplate? and selectedTemplate.id not in _.pluck(templates, "id")
|
||||
selectedTemplate = null
|
||||
else if not selectedTemplate?
|
||||
# none selected
|
||||
else if not selectedTemplate
|
||||
selectedTemplate = if templates.length > 0 then templates[0] else null
|
||||
@_loadTemplateContents(selectedTemplate)
|
||||
if selectedTemplate?
|
||||
selectedTemplateName = @state.selectedTemplateName || selectedTemplate.name
|
||||
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)
|
||||
|
@ -103,6 +110,7 @@ class PreferencesTemplates extends React.Component
|
|||
ref="templateInput"
|
||||
value={@state.contents}
|
||||
onChange={@_onEditTemplate}
|
||||
extensions={[TemplateEditor]}
|
||||
spellcheck={false} />
|
||||
|
||||
_renderHTMLTemplate: ->
|
||||
|
@ -167,7 +175,7 @@ class PreferencesTemplates extends React.Component
|
|||
contents: ""
|
||||
|
||||
_saveNewTemplate: =>
|
||||
TemplateStore.saveTemplate(@state.selectedTemplateName, @state.contents, true, (template) =>
|
||||
TemplateStore.writeTemplate(@state.selectedTemplateName, @state.contents, (template) =>
|
||||
@setState
|
||||
selectedTemplate: template
|
||||
editState: null
|
||||
|
@ -196,8 +204,22 @@ class PreferencesTemplates extends React.Component
|
|||
<RetinaImg name="icon-composer-trash.png" mode={RetinaImg.Mode.ContentIsMask} />
|
||||
</button>
|
||||
|
||||
editor =
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<section className="container-templates">
|
||||
<section className="container-templates" style={if @state.editState is "new" then {marginBottom:50}}>
|
||||
<h2>Quick Replies</h2>
|
||||
{
|
||||
switch @state.editState
|
||||
|
@ -205,30 +227,21 @@ class PreferencesTemplates extends React.Component
|
|||
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>
|
||||
{if @state.editState isnt "new" then editor}
|
||||
</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.
|
||||
The Quick Replies plugin allows you to create templated email replies. Replies can contain variables, which
|
||||
you can quickly jump between and fill out when using the template. To create a variable, type a set of double curly
|
||||
brackets wrapping the variable's name, like this: <strong>{"{{"}variable_name{"}}"}</strong>
|
||||
</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.
|
||||
In raw HTML, variables are defined as HTML <code> tags with class "var empty". Typing curly brackets creates a tag
|
||||
automatically. The code tags are colored yellow to show the variable regions, but will be stripped out before the message is sent.
|
||||
</p>
|
||||
<p>
|
||||
Templates live in the <strong>~/.nylas/templates</strong> directory on your computer. Each template
|
||||
Reply 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>
|
||||
|
||||
|
|
|
@ -18,88 +18,74 @@ class TemplatesComposerExtension extends ComposerExtension {
|
|||
}
|
||||
}
|
||||
|
||||
static onClick(editableNode, range) {
|
||||
const ref = range.startContainer;
|
||||
let parent = (ref != null) ? ref.parentNode : undefined;
|
||||
let parentCodeNode = null;
|
||||
|
||||
while (parent && parent !== editableNode) {
|
||||
const ref1 = parent.classList;
|
||||
if (((ref1 != null) ? ref1.contains('var') : undefined) && parent.tagName === 'CODE') {
|
||||
parentCodeNode = parent;
|
||||
break;
|
||||
}
|
||||
parent = parent.parentNode;
|
||||
}
|
||||
|
||||
const isSinglePoint = range.startContainer === range.endContainer && range.startOffset === range.endOffset;
|
||||
|
||||
if (isSinglePoint && parentCodeNode) {
|
||||
range.selectNode(parentCodeNode);
|
||||
const selection = document.getSelection();
|
||||
selection.removeAllRanges();
|
||||
return selection.addRange(range);
|
||||
static onClick(editor, event) {
|
||||
var node = event.target;
|
||||
if(node.nodeName === "CODE" && node.classList.contains("var") && node.classList.contains("empty")) {
|
||||
editor.selectAllChildren(node)
|
||||
}
|
||||
}
|
||||
|
||||
static onTabDown(editableNode, selection, event) {
|
||||
static onKeyDown(editor, event) {
|
||||
const editableNode = editor.rootNode;
|
||||
if (event.key === 'Tab') {
|
||||
const range = DOMUtils.getRangeInScope(editableNode);
|
||||
if (event.shiftKey) {
|
||||
this.onTabSelectNextVar(editableNode, range, event, -1);
|
||||
}
|
||||
this.onTabSelectNextVar(editableNode, range, event, 1);
|
||||
}
|
||||
}
|
||||
const nodes = editableNode.querySelectorAll('code.var');
|
||||
if(nodes.length>0) {
|
||||
let sel = editor.currentSelection();
|
||||
let found = false;
|
||||
|
||||
static onTabSelectNextVar(editableNode, range, event, delta) {
|
||||
if (!range) { return; }
|
||||
|
||||
// Try to find the node that the selection range is
|
||||
// currently intersecting with (inside, or around)
|
||||
let parentCodeNode = null;
|
||||
const nodes = editableNode.querySelectorAll('code.var');
|
||||
for (let i = 0, node; i < nodes.length; i++) {
|
||||
node = nodes[i];
|
||||
if (range.intersectsNode(node)) {
|
||||
parentCodeNode = node;
|
||||
}
|
||||
}
|
||||
|
||||
let selectNode = null;
|
||||
if (parentCodeNode) {
|
||||
if (range.startOffset === range.endOffset && parentCodeNode.classList.contains('empty')) {
|
||||
// If the current node is empty and it's a single insertion point,
|
||||
// select the current node rather than advancing to the next node
|
||||
selectNode = parentCodeNode;
|
||||
} else {
|
||||
// advance to the next code node
|
||||
const matches = editableNode.querySelectorAll('code.var');
|
||||
let matchIndex = -1;
|
||||
for (let idx = 0, match; idx < matches.length; idx++) {
|
||||
match = matches[idx];
|
||||
if (match === parentCodeNode) {
|
||||
matchIndex = idx;
|
||||
// First, try to find a <code> that the selection is within. If found,
|
||||
// select the next/prev node if the selection ends at the end of the
|
||||
// <code>'s text, otherwise select the <code>'s contents.
|
||||
for (let i=0; i<nodes.length; i++) {
|
||||
let node = nodes[i];
|
||||
if(DOMUtils.selectionIsWithin(node)) {
|
||||
let selIndex = editor.getSelectionTextIndex(node);
|
||||
let length = DOMUtils.getIndexedTextContent(node).slice(-1)[0].end;
|
||||
let nextIndex = i;
|
||||
if(selIndex.endIndex === length)
|
||||
nextIndex = event.shiftKey ? i-1 : i+1;
|
||||
nextIndex = (nextIndex+nodes.length) % nodes.length; //allow wraparound in both directions
|
||||
sel.selectAllChildren(nodes[nextIndex]);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matchIndex !== -1 && matchIndex + delta >= 0 && matchIndex + delta < matches.length) {
|
||||
selectNode = matches[matchIndex + delta];
|
||||
|
||||
// If we failed to find a <code> that the selection is within, select the
|
||||
// nearest <code> before/after the selection (depending on shift).
|
||||
if(!found) {
|
||||
let treeWalker = document.createTreeWalker(editableNode, NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT);
|
||||
let curIndex = 0, nextIndex = null;
|
||||
while (node = treeWalker.nextNode()) {
|
||||
if(sel.anchorNode === node || sel.focusNode === node)
|
||||
break;
|
||||
if(node.nodeName === "CODE" && node.classList.contains("var"))
|
||||
curIndex++
|
||||
}
|
||||
nextIndex = event.shiftKey ? curIndex-1 : curIndex;
|
||||
nextIndex = (nextIndex+nodes.length) % nodes.length; //allow wraparound in both directions
|
||||
sel.selectAllChildren(nodes[nextIndex]);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
else if(event.key === 'Enter') {
|
||||
const nodes = editableNode.querySelectorAll('code.var');
|
||||
for (let i=0; i<nodes.length; i++) {
|
||||
if(DOMUtils.selectionStartsOrEndsIn(nodes[i])) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectNode) {
|
||||
range.selectNode(selectNode);
|
||||
const selection = document.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
static onContentChanged(editableNode, selection) {
|
||||
static onContentChanged(editor) {
|
||||
editableNode = editor.rootNode;
|
||||
selection = editor.currentSelection().rawSelection;
|
||||
const isWithinNode = (node)=> {
|
||||
let test = selection.baseNode;
|
||||
while (test !== editableNode) {
|
||||
|
@ -114,6 +100,7 @@ class TemplatesComposerExtension extends ComposerExtension {
|
|||
const result = [];
|
||||
for (let i = 0, codeTag; i < codeTags.length; i++) {
|
||||
codeTag = codeTags[i];
|
||||
codeTag.textContent = codeTag.textContent; //sets node contents to just its textContent, strips HTML
|
||||
result.push((() => {
|
||||
if (selection.containsNode(codeTag) || isWithinNode(codeTag)) {
|
||||
return codeTag.classList.remove('empty');
|
||||
|
|
54
examples/N1-Composer-Templates/lib/template-editor.coffee
Normal file
54
examples/N1-Composer-Templates/lib/template-editor.coffee
Normal file
|
@ -0,0 +1,54 @@
|
|||
{DOMUtils, ContenteditableExtension} = require 'nylas-exports'
|
||||
|
||||
class TemplateEditor extends ContenteditableExtension
|
||||
|
||||
|
||||
@onContentChanged: (editor) ->
|
||||
|
||||
# Run through and remove all code nodes that are invalid
|
||||
codeNodes = editor.rootNode.querySelectorAll("code.var.empty")
|
||||
for codeNode in codeNodes
|
||||
# remove any style that was added by contenteditable
|
||||
codeNode.removeAttribute("style")
|
||||
# grab the text content and the indexable text content
|
||||
text = codeNode.textContent
|
||||
indexText = DOMUtils.getIndexedTextContent(codeNode).map( ({text}) -> text ).join("")
|
||||
# unwrap any code nodes that don't start/end with {{}}, and any with line breaks inside
|
||||
if not text.startsWith("{{") or not text.endsWith("}}") or indexText.indexOf("\n")>-1
|
||||
editor.whilePreservingSelection ->
|
||||
DOMUtils.unwrapNode(codeNode)
|
||||
|
||||
# # Attempt to sanitize spans that are needlessly created by contenteditable
|
||||
# for span in editor.rootNode.querySelectorAll("span")
|
||||
# if not span.className
|
||||
# editor.whilePreservingSelection ->
|
||||
# DOMUtils.unwrapNode(span)
|
||||
|
||||
# Find all {{}} and wrap them in code nodes if they aren't already
|
||||
# Regex finds any {{ <contents> }} that doesn't contain {, }, or \n
|
||||
# https://regex101.com/r/jF2oF4/1
|
||||
ranges = editor.regExpSelectorAll(/\{\{[^\n{}]*?\}\}/g)
|
||||
for range in ranges
|
||||
if not DOMUtils.isWrapped(range, "CODE")
|
||||
# Preserve the selection based on text index within the range matched by the regex
|
||||
selIndex = editor.getSelectionTextIndex(range)
|
||||
codeNode = DOMUtils.wrap(range,"CODE")
|
||||
codeNode.className = "var empty"
|
||||
codeNode.textContent = codeNode.textContent # Sets node contents to just its textContent, strips HTML
|
||||
if selIndex?
|
||||
editor.restoreSelectionByTextIndex(codeNode, selIndex.startIndex, selIndex.endIndex)
|
||||
|
||||
|
||||
@onKeyDown: (editor) ->
|
||||
# Look for all existing code tags that we may have added before,
|
||||
# and remove any that now have invalid content (don't start with {{ and
|
||||
# end with }} as well as any that wrap the current selection
|
||||
|
||||
codeNodes = editor.rootNode.querySelectorAll("code.var.empty")
|
||||
for codeNode in codeNodes
|
||||
text = codeNode.textContent
|
||||
if not text.startsWith("{{") or not text.endsWith("}}") or DOMUtils.selectionStartsOrEndsIn(codeNode)
|
||||
editor.whilePreservingSelection ->
|
||||
DOMUtils.unwrapNode(codeNode)
|
||||
|
||||
module.exports = TemplateEditor
|
|
@ -11,7 +11,6 @@ class TemplatePicker extends React.Component {
|
|||
|
||||
constructor() {
|
||||
super();
|
||||
TemplateStore.init();
|
||||
this.state = {
|
||||
searchValue: '',
|
||||
templates: TemplateStore.items(),
|
||||
|
|
|
@ -50,7 +50,7 @@ class TemplateStatusBar extends React.Component {
|
|||
if (this._draftUsesTemplate()) {
|
||||
return (
|
||||
<div className="template-status-bar">
|
||||
Press "tab" to quickly fill in the blanks - highlighting will not be visible to recipients.
|
||||
Press "tab" to quickly move between the blanks - highlighting will not be visible to recipients.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {DraftStore, Actions, QuotedHTMLParser} from 'nylas-exports';
|
||||
import {DraftStore, Actions, QuotedHTMLTransformer} from 'nylas-exports';
|
||||
import NylasStore from 'nylas-store';
|
||||
import shell from 'shell';
|
||||
import path from 'path';
|
||||
|
@ -6,7 +6,14 @@ import fs from 'fs';
|
|||
|
||||
class TemplateStore extends NylasStore {
|
||||
|
||||
init(templatesDir = path.join(NylasEnv.getConfigDirPath(), 'templates')) {
|
||||
static INVALID_TEMPLATE_NAME_REGEX = /[^a-zA-Z0-9_\- ]+/g;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._init();
|
||||
}
|
||||
|
||||
_init(templatesDir = path.join(NylasEnv.getConfigDirPath(), 'templates')) {
|
||||
this.items = this.items.bind(this);
|
||||
this.templatesDirectory = this.templatesDirectory.bind(this);
|
||||
this._setStoreDefaults = this._setStoreDefaults.bind(this);
|
||||
|
@ -14,8 +21,13 @@ class TemplateStore extends NylasStore {
|
|||
this._populate = this._populate.bind(this);
|
||||
this._onCreateTemplate = this._onCreateTemplate.bind(this);
|
||||
this._onShowTemplates = this._onShowTemplates.bind(this);
|
||||
this._displayDialog = this._displayDialog.bind(this);
|
||||
this._displayError = this._displayError.bind(this);
|
||||
this._writeTemplate = this._writeTemplate.bind(this);
|
||||
this.saveNewTemplate = this.saveNewTemplate.bind(this);
|
||||
this.saveTemplate = this.saveTemplate.bind(this);
|
||||
this.deleteTemplate = this.deleteTemplate.bind(this);
|
||||
this.renameTemplate = this.renameTemplate.bind(this);
|
||||
this.getTemplateContents = this.getTemplateContents.bind(this);
|
||||
this._onInsertTemplateId = this._onInsertTemplateId.bind(this);
|
||||
this._setStoreDefaults();
|
||||
this._registerListeners();
|
||||
|
@ -23,18 +35,19 @@ class TemplateStore extends NylasStore {
|
|||
this._templatesDir = templatesDir;
|
||||
this._welcomeName = 'Welcome to Templates.html';
|
||||
this._welcomePath = path.join(__dirname, '..', 'assets', this._welcomeName);
|
||||
this._watcher = null;
|
||||
|
||||
// I know this is a bit of pain but don't do anything that
|
||||
// could possibly slow down app launch
|
||||
fs.exists(this._templatesDir, (exists) => {
|
||||
if (exists) {
|
||||
this._populate();
|
||||
fs.watch(this._templatesDir, () => this._populate());
|
||||
this.watch()
|
||||
} else {
|
||||
fs.mkdir(this._templatesDir, () => {
|
||||
fs.readFile(this._welcomePath, (err, welcome) => {
|
||||
fs.writeFile(path.join(this._templatesDir, this._welcomeName), welcome, () => {
|
||||
fs.watch(this._templatesDir, () => this._populate());
|
||||
this.watch()
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -42,6 +55,15 @@ class TemplateStore extends NylasStore {
|
|||
});
|
||||
}
|
||||
|
||||
watch() {
|
||||
if(!this._watcher)
|
||||
this._watcher = fs.watch(this._templatesDir, () => this._populate());
|
||||
}
|
||||
unwatch() {
|
||||
this._watcher.close();
|
||||
this._watcher = null;
|
||||
}
|
||||
|
||||
items() {
|
||||
return this._items;
|
||||
}
|
||||
|
@ -81,59 +103,77 @@ class TemplateStore extends NylasStore {
|
|||
if (draftClientId) {
|
||||
DraftStore.sessionForClientId(draftClientId).then((session) => {
|
||||
const draft = session.draft();
|
||||
const draftName = name ? name : draft.subject;
|
||||
const draftContents = contents ? contents : QuotedHTMLParser.removeQuotedHTML(draft.body);
|
||||
const draftName = name ? name : draft.subject.replace(TemplateStore.INVALID_TEMPLATE_NAME_REGEX,"");
|
||||
const draftContents = contents ? contents : QuotedHTMLTransformer.removeQuotedHTML(draft.body);
|
||||
if (!draftName || draftName.length === 0) {
|
||||
this._displayError('Give your draft a subject to name your template.');
|
||||
}
|
||||
if (!draftContents || draftContents.length === 0) {
|
||||
this._displayError('To create a template you need to fill the body of the current draft.');
|
||||
}
|
||||
this._writeTemplate(draftName, draftContents);
|
||||
this.saveNewTemplate(draftName, draftContents, this._onShowTemplates);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!name || name.length === 0) {
|
||||
if (!name || name.length === 0)
|
||||
this._displayError('You must provide a name for your template.');
|
||||
}
|
||||
if (!contents || contents.length === 0) {
|
||||
|
||||
if (!contents || contents.length === 0)
|
||||
this._displayError('You must provide contents for your template.');
|
||||
}
|
||||
this._writeTemplate(name, contents);
|
||||
|
||||
this.saveNewTemplate(name, contents, this._onShowTemplates);
|
||||
}
|
||||
|
||||
_onShowTemplates() {
|
||||
const ref = this._items[0];
|
||||
shell.showItemInFolder(((ref != null) ? ref.path : undefined) || this._templatesDir);
|
||||
Actions.switchPreferencesTab('Quick Replies');
|
||||
Actions.openPreferences()
|
||||
}
|
||||
|
||||
_displayError(message) {
|
||||
const dialog = require('remote').require('dialog');
|
||||
dialog.showErrorBox('Template Creation Error', message);
|
||||
}
|
||||
|
||||
_writeTemplate(name, contents) {
|
||||
this.saveTemplate(name, contents, true, (template) => {
|
||||
Actions.switchPreferencesTab('Quick Replies');
|
||||
Actions.openPreferences()
|
||||
});
|
||||
_displayDialog(title,message,buttons) {
|
||||
const dialog = require('remote').require('dialog');
|
||||
return 0==dialog.showMessageBox({
|
||||
title: title,
|
||||
message: title,
|
||||
detail: message,
|
||||
buttons: buttons,
|
||||
type: 'info'
|
||||
});
|
||||
}
|
||||
|
||||
saveTemplate(name, contents, isNew, callback) {
|
||||
saveNewTemplate(name, contents, callback) {
|
||||
if(name.match(TemplateStore.INVALID_TEMPLATE_NAME_REGEX)) {
|
||||
this._displayError("Invalid template name! Names can only contain letters, numbers, spaces, dashes, and underscores.");
|
||||
return;
|
||||
}
|
||||
|
||||
var template = this._getTemplate(name);
|
||||
if(template) {
|
||||
this._displayError("A template with that name already exists!");
|
||||
return;
|
||||
}
|
||||
this.saveTemplate(name, contents, callback);
|
||||
}
|
||||
|
||||
_getTemplate(name, id) {
|
||||
for(let template of this._items) {
|
||||
if((template.name === name || name == null) && (template.id === id || id == null))
|
||||
return template;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
saveTemplate(name, contents, 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;
|
||||
}
|
||||
|
||||
var template = this._getTemplate(name);
|
||||
this.unwatch();
|
||||
fs.writeFile(templatePath, contents, (err) => {
|
||||
this.watch();
|
||||
if (err) { this._displayError(err); }
|
||||
if (template === null) {
|
||||
template = {
|
||||
|
@ -150,24 +190,27 @@ class TemplateStore extends NylasStore {
|
|||
}
|
||||
|
||||
deleteTemplate(name, callback) {
|
||||
var template = null;
|
||||
this._items.forEach((item) => {
|
||||
if (item.name === name) { template = item; }
|
||||
});
|
||||
var template = this._getTemplate(name);
|
||||
if (!template) { return undefined }
|
||||
|
||||
fs.unlink(template.path, () => {
|
||||
this._populate();
|
||||
if(callback)
|
||||
callback()
|
||||
});
|
||||
if(this._displayDialog(
|
||||
'Delete this template?',
|
||||
'The template and its file will be permanently deleted.',
|
||||
['Delete','Cancel']
|
||||
))
|
||||
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(newName.match(TemplateStore.INVALID_TEMPLATE_NAME_REGEX)) {
|
||||
this._displayError("Invalid template name! Names can only contain letters, numbers, spaces, dashes, and underscores.");
|
||||
return;
|
||||
}
|
||||
var template = this._getTemplate(oldName);
|
||||
if (!template) { return undefined }
|
||||
|
||||
const newFilename = `${newName}.html`;
|
||||
|
@ -185,18 +228,26 @@ class TemplateStore extends NylasStore {
|
|||
_onInsertTemplateId({templateId, draftClientId} = {}) {
|
||||
this.getTemplateContents(templateId, (body) => {
|
||||
DraftStore.sessionForClientId(draftClientId).then((session)=> {
|
||||
draftHtml = QuotedHTMLParser.appendQuotedHTML(body, session.draft().body);
|
||||
session.changes.add({body: draftHtml});
|
||||
var proceed = true;
|
||||
if (!session.draft().pristine) {
|
||||
proceed = this._displayDialog(
|
||||
'Replace draft contents?',
|
||||
'It looks like your draft already has some content. Loading this template will ' +
|
||||
'overwrite all draft contents.',
|
||||
['Replace contents','Cancel']
|
||||
)
|
||||
}
|
||||
|
||||
if(proceed) {
|
||||
draftHtml = QuotedHTMLTransformer.appendQuotedHTML(body, session.draft().body);
|
||||
session.changes.add({body: draftHtml});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getTemplateContents(templateId, callback) {
|
||||
let template = null;
|
||||
this._items.forEach((item) => {
|
||||
if (item.id === templateId) { template = item; }
|
||||
});
|
||||
|
||||
var template = this._getTemplate(null,templateId);
|
||||
if (!template) { return undefined }
|
||||
|
||||
fs.readFile(template.path, (err, data)=> {
|
||||
|
|
|
@ -30,7 +30,7 @@ describe('TemplateStore', ()=> {
|
|||
|
||||
it('should create the templates folder if it does not exist', ()=> {
|
||||
spyOn(fs, 'exists').andCallFake((path, callback)=> callback(false) );
|
||||
TemplateStore.init(stubTemplatesDir);
|
||||
TemplateStore._init(stubTemplatesDir);
|
||||
expect(fs.mkdir).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
@ -39,7 +39,7 @@ describe('TemplateStore', ()=> {
|
|||
spyOn(fs, 'exists').andCallFake((path, callback)=> { callback(true); });
|
||||
spyOn(fs, 'watch').andCallFake((path, callback)=> watchCallback = callback);
|
||||
spyOn(fs, 'readdir').andCallFake((path, callback)=> { callback(null, Object.keys(stubTemplateFiles)); });
|
||||
TemplateStore.init(stubTemplatesDir);
|
||||
TemplateStore._init(stubTemplatesDir);
|
||||
watchCallback();
|
||||
expect(TemplateStore.items()).toEqual(stubTemplates);
|
||||
});
|
||||
|
@ -57,7 +57,7 @@ describe('TemplateStore', ()=> {
|
|||
callback(null, []);
|
||||
}
|
||||
});
|
||||
TemplateStore.init(stubTemplatesDir);
|
||||
TemplateStore._init(stubTemplatesDir);
|
||||
expect(TemplateStore.items()).toEqual([]);
|
||||
|
||||
watchFired = true;
|
||||
|
@ -71,7 +71,7 @@ describe('TemplateStore', ()=> {
|
|||
spyOn(fs, 'exists').andCallFake((path, callback)=> { callback(true); });
|
||||
spyOn(fs, 'watch').andCallFake((path, callback)=> watchCallback = callback);
|
||||
spyOn(fs, 'readdir').andCallFake((path, callback)=> { callback(null, Object.keys(stubTemplateFiles)); });
|
||||
TemplateStore.init(stubTemplatesDir);
|
||||
TemplateStore._init(stubTemplatesDir);
|
||||
watchCallback();
|
||||
const add = jasmine.createSpy('add');
|
||||
spyOn(DraftStore, 'sessionForClientId').andCallFake(()=> {
|
||||
|
@ -105,7 +105,7 @@ describe('TemplateStore', ()=> {
|
|||
const session = {draft() { return d; }};
|
||||
return Promise.resolve(session);
|
||||
});
|
||||
TemplateStore.init(stubTemplatesDir);
|
||||
TemplateStore._init(stubTemplatesDir);
|
||||
});
|
||||
|
||||
it('should create a template with the given name and contents', ()=> {
|
||||
|
|
|
@ -31,11 +31,9 @@
|
|||
padding-left:2px;
|
||||
padding-right:2px;
|
||||
border-bottom: 1.5px solid darken(@code-bg-color, 10%);
|
||||
background-color: fade(@code-bg-color, 10%);
|
||||
background-color: @code-bg-color;
|
||||
&.empty {
|
||||
color:darken(@code-bg-color, 70%);
|
||||
border-bottom: 1px solid darken(@code-bg-color, 14%);
|
||||
background-color: @code-bg-color;
|
||||
color:darken(@code-bg-color, 50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
{DOMUtils} = require 'nylas-exports'
|
||||
ExtendedSelection = require './extended-selection'
|
||||
|
||||
# An extended interface of execCommand
|
||||
|
@ -24,12 +25,24 @@ class EditorAPI
|
|||
constructor: (@rootNode) ->
|
||||
@_extendedSelection = new ExtendedSelection(@rootNode)
|
||||
|
||||
wrapSelection: ->
|
||||
## TODO
|
||||
wrapSelection:(nodeName) ->
|
||||
wrapped = DOMUtils.wrap(@_selection.getRangeAt(0), nodeName)
|
||||
@select(wrapped)
|
||||
return @
|
||||
|
||||
regExpSelectorAll:(regex) ->
|
||||
DOMUtils.regExpSelectorAll(@rootNode, regex)
|
||||
|
||||
currentSelection: -> @_extendedSelection
|
||||
|
||||
whilePreservingSelection: (fn) ->
|
||||
sel = @currentSelection().exportSelection()
|
||||
fn()
|
||||
@select(sel)
|
||||
|
||||
getSelectionTextIndex: (args...) -> @_extendedSelection.getSelectionTextIndex(args...)
|
||||
|
||||
|
||||
collapse: (args...) -> @_extendedSelection.collapse(args...); @
|
||||
collapseToStart: (args...) -> @_extendedSelection.collapseToStart(args...); @
|
||||
collapseToEnd: (args...) -> @_extendedSelection.collapseToEnd(args...); @
|
||||
|
@ -37,6 +50,7 @@ class EditorAPI
|
|||
select: (args...) -> @_extendedSelection.select(args...); @
|
||||
selectEnd: (args...) -> @_extendedSelection.selectEnd(args...); @
|
||||
selectAllChildren: (args...) -> @_extendedSelection.selectAllChildren(args...); @
|
||||
restoreSelectionByTextIndex: (args...) -> @_extendedSelection.restoreSelectionByTextIndex(args...); @
|
||||
|
||||
backColor: (color) -> @_ec("backColor", false, color)
|
||||
bold: -> @_ec("bold", false)
|
||||
|
|
|
@ -100,6 +100,92 @@ class ExtendedSelection
|
|||
DOMUtils.findNodeByRegex(@scopeNode, arg)
|
||||
return
|
||||
|
||||
# Finds the start and end text index of the current selection relative
|
||||
# to a given Node or Range. Returns an object of the form:
|
||||
# {startIndex, endIndex}
|
||||
#
|
||||
# Uses getIndexedTextContent to index the text, which accounts for line breaks
|
||||
# from DIVs and BRs. For ranges, the index takes into account the start and end
|
||||
# offsets of the range.
|
||||
getSelectionTextIndex: (refRangeOrNode) ->
|
||||
return null unless DOMUtils.selectionStartsOrEndsIn(refRangeOrNode)
|
||||
sel = @rawSelection
|
||||
return null unless sel
|
||||
startIndex = null
|
||||
endIndex = null
|
||||
range = null
|
||||
rangeOffset = 0
|
||||
if refRangeOrNode instanceof Range
|
||||
range = refRangeOrNode
|
||||
parentNode = range.commonAncestorContainer
|
||||
else
|
||||
parentNode = refRangeOrNode
|
||||
|
||||
# If the selection is directly on the parent node, just return the
|
||||
# selection offsets
|
||||
if parentNode is sel.anchorNode
|
||||
if range then rangeOffset = range.startOffset
|
||||
startIndex = sel.anchorOffset-rangeOffset
|
||||
if parentNode is sel.focusNode
|
||||
if range then rangeOffset = range.startOffset
|
||||
endIndex = sel.focusOffset-rangeOffset
|
||||
|
||||
if parentNode is sel.anchorNode and parentNode is sel.focusNode
|
||||
return {startIndex, endIndex}
|
||||
|
||||
# Otherwise find the start and end index within a text representation of the
|
||||
# parent node
|
||||
for {node, start, end} in DOMUtils.getIndexedTextContent(parentNode)
|
||||
if range?.startContainer is node
|
||||
rangeOffset = start + range.startOffset
|
||||
if sel.anchorNode is node
|
||||
startIndex = start + sel.anchorOffset - rangeOffset
|
||||
if sel.focusNode is node
|
||||
endIndex = start + sel.focusOffset - rangeOffset
|
||||
return {startIndex, endIndex}
|
||||
|
||||
# Sets the current selection to start and end at the specified indices, relative
|
||||
# to the given Range or Node. This is the inverse of getSelectionByTextIndex.
|
||||
#
|
||||
# Uses getIndexedTextContent to index the text, which accounts for line breaks
|
||||
# from DIVs and BRs. For ranges, the index takes into account the start and end
|
||||
# offsets of the range.
|
||||
restoreSelectionByTextIndex: (refRangeOrNode, startIndex, endIndex) ->
|
||||
startNode = null
|
||||
startOffset = null
|
||||
endNode = null
|
||||
endOffset = null
|
||||
range = null
|
||||
sel = @rawSelection
|
||||
if refRangeOrNode instanceof Range
|
||||
range = refRangeOrNode
|
||||
parentNode = range.commonAncestorContainer
|
||||
else
|
||||
parentNode = refRangeOrNode
|
||||
|
||||
if parentNode.childNodes.length == 0 # text node
|
||||
sel.setBaseAndExtent(parentNode, startIndex, parentNode, endIndex)
|
||||
|
||||
inRange = (range is null) # we're not in range yet, unless there is no range
|
||||
items = DOMUtils.getIndexedTextContent(parentNode)
|
||||
for {node, start, end},i in items
|
||||
inRange = inRange or (range.startContainer is node)
|
||||
atEnd = i==(items.length-1)
|
||||
if not inRange
|
||||
continue
|
||||
if range?.startContainer is node
|
||||
rangeOffset = start + range.startIndex
|
||||
if startIndex? then startIndex += rangeOffset
|
||||
if endIndex? then endIndex += rangeOffset
|
||||
if startIndex? and startIndex >= start and (startIndex < end or atEnd and startIndex==end)
|
||||
startNode = node
|
||||
startOffset = startIndex - start
|
||||
if endIndex? and endIndex >= start and (endIndex < end or atEnd and endIndex==end)
|
||||
endNode = node
|
||||
endOffset = endIndex - start
|
||||
sel.setBaseAndExtent(startNode ? sel.anchorNode, startOffset ? sel.anchorOffset, endNode ? sel.focusNode, endOffset ? sel.focusOffset)
|
||||
|
||||
|
||||
Object.defineProperty @prototype, "anchorNode",
|
||||
get: -> @rawSelection.anchorNode
|
||||
set: -> throw @_errNoSet("anchorNode")
|
||||
|
|
|
@ -466,4 +466,138 @@ DOMUtils =
|
|||
|
||||
return 0
|
||||
|
||||
# Produces a list of indexed text contained within a given node. Returns a
|
||||
# list of objects of the form:
|
||||
# {start, end, node, text}
|
||||
#
|
||||
# The text being indexed is intended to approximate the rendered content visible
|
||||
# to the user. This includes the nodeValue of any text nodes, and "\n" for any
|
||||
# DIV or BR elements.
|
||||
getIndexedTextContent: (node) ->
|
||||
items = []
|
||||
treeWalker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT)
|
||||
position = 0
|
||||
while treeWalker.nextNode()
|
||||
node = treeWalker.currentNode
|
||||
if node.tagName is "BR" or node.nodeType is Node.TEXT_NODE or node.tagName is "DIV"
|
||||
text = if node.nodeType is Node.TEXT_NODE then node.nodeValue else "\n"
|
||||
item =
|
||||
start: position
|
||||
end: position + text.length
|
||||
node: node
|
||||
text: text
|
||||
items.push(item)
|
||||
position += text.length
|
||||
return items
|
||||
|
||||
# Returns true if the inner range is fully contained within the outer range
|
||||
rangeInRange: (inner, outer) ->
|
||||
return outer.isPointInRange(inner.startContainer, inner.startOffset) and outer.isPointInRange(inner.endContainer, inner.endOffset)
|
||||
|
||||
# Returns true if the given ranges overlap
|
||||
rangeOverlapsRange: (range1, range2) ->
|
||||
return range2.isPointInRange(range1.startContainer, range1.startOffset) or range1.isPointInRange(range2.startContainer, range2.startOffset)
|
||||
|
||||
# Returns true if the first range starts or ends within the second range.
|
||||
# Unlike rangeOverlapsRange, returns false if range2 is fully within range1.
|
||||
rangeStartsOrEndsInRange: (range1, range2) ->
|
||||
return range2.isPointInRange(range1.startContainer, range1.startOffset) or range2.isPointInRange(range1.endContainer, range1.endOffset)
|
||||
|
||||
# Accepts a Range or a Node, and returns true if the current selection starts
|
||||
# or ends within it. Useful for knowing if a DOM modification will break the
|
||||
# current selection.
|
||||
selectionStartsOrEndsIn: (rangeOrNode) ->
|
||||
selection = document.getSelection()
|
||||
return false unless selection
|
||||
if rangeOrNode instanceof Range
|
||||
return @rangeStartsOrEndsInRange(selection.getRangeAt(0), rangeOrNode)
|
||||
else if rangeOrNode instanceof Node
|
||||
range = new Range()
|
||||
range.selectNode(rangeOrNode)
|
||||
return @rangeStartsOrEndsInRange(selection.getRangeAt(0), range)
|
||||
else
|
||||
return false
|
||||
|
||||
# Accepts a Range or a Node, and returns true if the current selection is fully
|
||||
# contained within it.
|
||||
selectionIsWithin: (rangeOrNode) ->
|
||||
selection = document.getSelection()
|
||||
return false unless selection
|
||||
if rangeOrNode instanceof Range
|
||||
return @rangeInRange(selection.getRangeAt(0), rangeOrNode)
|
||||
else if rangeOrNode instanceof Node
|
||||
range = new Range()
|
||||
range.selectNode(rangeOrNode)
|
||||
return @rangeInRange(selection.getRangeAt(0), range)
|
||||
else
|
||||
return false
|
||||
|
||||
# Finds all matches to a regex within a node's text content (including line
|
||||
# breaks from DIVs and BRs, as \n), and returns a list of corresponding Range
|
||||
# objects.
|
||||
regExpSelectorAll: (node, regex) ->
|
||||
|
||||
# Generate a text representation of the node's content
|
||||
nodeTextList = @getIndexedTextContent(node)
|
||||
text = nodeTextList.map( ({text}) -> text ).join("")
|
||||
|
||||
# Build a list of range objects by looping over regex matches in the
|
||||
# text content string, and then finding the node those match indexes
|
||||
# point to.
|
||||
ranges = []
|
||||
listPosition = 0
|
||||
while (result = regex.exec(text)) isnt null
|
||||
from = result.index
|
||||
to = regex.lastIndex
|
||||
item = nodeTextList[listPosition]
|
||||
range = document.createRange()
|
||||
|
||||
while from >= item.end
|
||||
item = nodeTextList[++listPosition]
|
||||
start = if item.node.nodeType is Node.TEXT_NODE then from - item.start else 0
|
||||
range.setStart(item.node,start)
|
||||
|
||||
while to > item.end
|
||||
item = nodeTextList[++listPosition]
|
||||
end = if item.node.nodeType is Node.TEXT_NODE then to - item.start else 0
|
||||
range.setEnd(item.node, end)
|
||||
|
||||
ranges.push(range)
|
||||
|
||||
return ranges
|
||||
|
||||
# Returns true if the given range is the sole content of a node with the given
|
||||
# nodeName. If the range's parent has a different nodeName or contains any other
|
||||
# content, returns false.
|
||||
isWrapped: (range, nodeName) ->
|
||||
return false unless range and nodeName
|
||||
startNode = range.startContainer
|
||||
endNode = range.endContainer
|
||||
return false unless startNode.parentNode is endNode.parentNode # must have same parent
|
||||
return false if startNode.previousSibling or endNode.nextSibling # selection must span all sibling nodes
|
||||
return false if range.startOffset > 0 or range.endOffset < endNode.textContent.length # selection must span all text
|
||||
return startNode.parentNode.nodeName is nodeName
|
||||
|
||||
# Modifies the DOM to wrap the given range with a new node, of name nodeName.
|
||||
#
|
||||
# If the range starts or ends in the middle of an node, that node will be split.
|
||||
# This will likely break selections that contain any of the affected nodes.
|
||||
wrap: (range, nodeName) ->
|
||||
newNode = document.createElement(nodeName)
|
||||
try
|
||||
range.surroundContents(newNode)
|
||||
catch
|
||||
newNode.appendChild(range.extractContents())
|
||||
range.insertNode(newNode)
|
||||
return newNode
|
||||
|
||||
# Modifies the DOM to "unwrap" a given node, replacing that node with its contents.
|
||||
# This may break selections containing the affected nodes.
|
||||
unwrapNode: (node) ->
|
||||
fragment = document.createDocumentFragment()
|
||||
while (child = node.firstChild)
|
||||
fragment.appendChild(child)
|
||||
node.parentNode.replaceChild(fragment, node)
|
||||
return fragment
|
||||
|
||||
module.exports = DOMUtils
|
||||
|
|
Loading…
Reference in a new issue