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:
Drew Regitsky 2015-12-29 15:11:04 -08:00
parent 4bd46b055f
commit be800ac89a
11 changed files with 497 additions and 161 deletions

View file

@ -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 &lt;code&gt; tags with class "var". You can include these by editing the raw HTML of the template and adding <code>&lt;code class="var"&gt;[content]&lt;/code&gt;</code>. Add
the "empty" class to make a region dark yellow and indicate that it should be filled in. When you send your message, &lt;code&gt;
tags are always stripped so the recipient never sees any highlighting.
In raw HTML, variables are defined as HTML &lt;code&gt; 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>

View file

@ -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');

View 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

View file

@ -11,7 +11,6 @@ class TemplatePicker extends React.Component {
constructor() {
super();
TemplateStore.init();
this.state = {
searchValue: '',
templates: TemplateStore.items(),

View file

@ -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>
);
}

View file

@ -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)=> {

View file

@ -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', ()=> {

View file

@ -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%);
}
}
}

View file

@ -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)

View file

@ -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")

View file

@ -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