From be800ac89a1097806366704455e22d8aed582d4b Mon Sep 17 00:00:00 2001 From: Drew Regitsky Date: Tue, 29 Dec 2015 15:11:04 -0800 Subject: [PATCH] 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 --- .../lib/preferences-templates.cjsx | 67 +++++--- .../lib/template-composer-extension.es6 | 125 +++++++------- .../lib/template-editor.coffee | 54 ++++++ .../lib/template-picker.jsx | 1 - .../lib/template-status-bar.jsx | 2 +- .../lib/template-store.es6 | 155 ++++++++++++------ .../spec/template-store-spec.es6 | 10 +- .../stylesheets/message-templates.less | 6 +- .../contenteditable/editor-api.coffee | 18 +- .../contenteditable/extended-selection.coffee | 86 ++++++++++ src/dom-utils.coffee | 134 +++++++++++++++ 11 files changed, 497 insertions(+), 161 deletions(-) create mode 100644 examples/N1-Composer-Templates/lib/template-editor.coffee diff --git a/examples/N1-Composer-Templates/lib/preferences-templates.cjsx b/examples/N1-Composer-Templates/lib/preferences-templates.cjsx index 1435d16f1..7b9d75a61 100644 --- a/examples/N1-Composer-Templates/lib/preferences-templates.cjsx +++ b/examples/N1-Composer-Templates/lib/preferences-templates.cjsx @@ -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 + editor = +
+
+ {if @state.editAsHTML then @_renderHTMLTemplate() else @_renderEditableTemplate()} +
+ + { if _.size(@_templateSaveQueue) > 0 then "Saving changes..." else "Changes saved." } + + {if @state.editState == null then deleteBtn else ""} +
+ {@_renderModeToggle()} +
+
+
-
+

Quick Replies

{ switch @state.editState @@ -205,30 +227,21 @@ class PreferencesTemplates extends React.Component when "new" then @_renderCreateNew() else @_renderName() } -
- {if @state.editAsHTML then @_renderHTMLTemplate() else @_renderEditableTemplate()} -
- - { if _.size(@_templateSaveQueue) > 0 then "Saving changes..." else "Changes saved." } - - {if @state.editState == null then deleteBtn else ""} -
- {@_renderModeToggle()} -
+ {if @state.editState isnt "new" then editor}

- 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: {"{{"}variable_name{"}}"}

- 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 class="var">[content]</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.

- Templates live in the ~/.nylas/templates directory on your computer. Each template + Reply templates live in the ~/.nylas/templates 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.

diff --git a/examples/N1-Composer-Templates/lib/template-composer-extension.es6 b/examples/N1-Composer-Templates/lib/template-composer-extension.es6 index e44d2a8d5..3ade86d85 100644 --- a/examples/N1-Composer-Templates/lib/template-composer-extension.es6 +++ b/examples/N1-Composer-Templates/lib/template-composer-extension.es6 @@ -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 that the selection is within. If found, + // select the next/prev node if the selection ends at the end of the + // 's text, otherwise select the 's contents. + for (let i=0; i= 0 && matchIndex + delta < matches.length) { - selectNode = matches[matchIndex + delta]; + + // If we failed to find a that the selection is within, select the + // nearest 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 { 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'); diff --git a/examples/N1-Composer-Templates/lib/template-editor.coffee b/examples/N1-Composer-Templates/lib/template-editor.coffee new file mode 100644 index 000000000..80adcd9ba --- /dev/null +++ b/examples/N1-Composer-Templates/lib/template-editor.coffee @@ -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 {{ }} 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 diff --git a/examples/N1-Composer-Templates/lib/template-picker.jsx b/examples/N1-Composer-Templates/lib/template-picker.jsx index 8a541b635..0cd6032e1 100644 --- a/examples/N1-Composer-Templates/lib/template-picker.jsx +++ b/examples/N1-Composer-Templates/lib/template-picker.jsx @@ -11,7 +11,6 @@ class TemplatePicker extends React.Component { constructor() { super(); - TemplateStore.init(); this.state = { searchValue: '', templates: TemplateStore.items(), diff --git a/examples/N1-Composer-Templates/lib/template-status-bar.jsx b/examples/N1-Composer-Templates/lib/template-status-bar.jsx index b0f489a17..532828e69 100644 --- a/examples/N1-Composer-Templates/lib/template-status-bar.jsx +++ b/examples/N1-Composer-Templates/lib/template-status-bar.jsx @@ -50,7 +50,7 @@ class TemplateStatusBar extends React.Component { if (this._draftUsesTemplate()) { return (
- 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.
); } diff --git a/examples/N1-Composer-Templates/lib/template-store.es6 b/examples/N1-Composer-Templates/lib/template-store.es6 index 9fa71e6b8..a1b3d99c0 100644 --- a/examples/N1-Composer-Templates/lib/template-store.es6 +++ b/examples/N1-Composer-Templates/lib/template-store.es6 @@ -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)=> { diff --git a/examples/N1-Composer-Templates/spec/template-store-spec.es6 b/examples/N1-Composer-Templates/spec/template-store-spec.es6 index 12a5c6d3c..c31df5ccd 100644 --- a/examples/N1-Composer-Templates/spec/template-store-spec.es6 +++ b/examples/N1-Composer-Templates/spec/template-store-spec.es6 @@ -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', ()=> { diff --git a/examples/N1-Composer-Templates/stylesheets/message-templates.less b/examples/N1-Composer-Templates/stylesheets/message-templates.less index 1671b25f8..afd03548a 100755 --- a/examples/N1-Composer-Templates/stylesheets/message-templates.less +++ b/examples/N1-Composer-Templates/stylesheets/message-templates.less @@ -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%); } } } diff --git a/src/components/contenteditable/editor-api.coffee b/src/components/contenteditable/editor-api.coffee index dc1f8a02f..b06fe3857 100644 --- a/src/components/contenteditable/editor-api.coffee +++ b/src/components/contenteditable/editor-api.coffee @@ -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) diff --git a/src/components/contenteditable/extended-selection.coffee b/src/components/contenteditable/extended-selection.coffee index 905bed9dd..624d6a20f 100644 --- a/src/components/contenteditable/extended-selection.coffee +++ b/src/components/contenteditable/extended-selection.coffee @@ -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") diff --git a/src/dom-utils.coffee b/src/dom-utils.coffee index bae9624ff..4168465e1 100644 --- a/src/dom-utils.coffee +++ b/src/dom-utils.coffee @@ -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