diff --git a/ElectronClient/app/app.js b/ElectronClient/app/app.js index 38714e443..d93b3fd66 100644 --- a/ElectronClient/app/app.js +++ b/ElectronClient/app/app.js @@ -26,11 +26,13 @@ const ResourceService = require('lib/services/ResourceService'); const ClipperServer = require('lib/ClipperServer'); const ExternalEditWatcher = require('lib/services/ExternalEditWatcher'); const { bridge } = require('electron').remote.require('./bridge'); +const { shell } = require('electron'); const Menu = bridge().Menu; const MenuItem = bridge().MenuItem; const PluginManager = require('lib/services/PluginManager'); const RevisionService = require('lib/services/RevisionService'); const MigrationService = require('lib/services/MigrationService'); +const TemplateUtils = require('lib/TemplateUtils'); const pluginClasses = [ require('./plugins/GotoAnything.min'), @@ -209,7 +211,7 @@ class Application extends BaseApplication { // The bridge runs within the main process, with its own instance of locale.js // so it needs to be set too here. bridge().setLocale(Setting.value('locale')); - this.refreshMenu(); + await this.refreshMenu(); } if (action.type == 'SETTING_UPDATE_ONE' && action.key == 'showTrayIcon' || action.type == 'SETTING_UPDATE_ALL') { @@ -246,10 +248,10 @@ class Application extends BaseApplication { return result; } - refreshMenu() { + async refreshMenu() { const screen = this.lastMenuScreen_; this.lastMenuScreen_ = null; - this.updateMenu(screen); + await this.updateMenu(screen); } focusElement_(target) { @@ -260,7 +262,7 @@ class Application extends BaseApplication { }); } - updateMenu(screen) { + async updateMenu(screen) { if (this.lastMenuScreen_ === screen) return; const sortNoteFolderItems = (type) => { @@ -328,6 +330,7 @@ class Application extends BaseApplication { const exportItems = []; const preferencesItems = []; const toolsItemsFirst = []; + const templateItems = []; const ioService = new InteropService(); const ioModules = ioService.modules(); for (let i = 0; i < ioModules.length; i++) { @@ -504,6 +507,57 @@ class Application extends BaseApplication { screens: ['Main'], }); + const templateDirExists = await shim.fsDriver().exists(Setting.value('templateDir')); + + templateItems.push({ + label: _('Create note from template'), + visible: templateDirExists, + click: () => { + this.dispatch({ + type: 'WINDOW_COMMAND', + name: 'selectTemplate', + noteType: 'note', + }); + } + }, { + label: _('Create to-do from template'), + visible: templateDirExists, + click: () => { + this.dispatch({ + type: 'WINDOW_COMMAND', + name: 'selectTemplate', + noteType: 'todo', + }); + } + }, { + label: _('Insert template'), + visible: templateDirExists, + accelerator: 'CommandOrControl+Alt+I', + click: () => { + this.dispatch({ + type: 'WINDOW_COMMAND', + name: 'selectTemplate', + }); + } + }, { + label: _('Open template directory'), + click: () => { + const templateDir = Setting.value('templateDir'); + if (!templateDirExists) shim.fsDriver().mkdir(templateDir); + shell.openItem(templateDir); + } + }, { + label: _('Refresh templates'), + click: async () => { + const templates = await TemplateUtils.loadTemplates(Setting.value('templateDir')); + + this.store().dispatch({ + type: 'TEMPLATE_UPDATE_ALL', + templates: templates + }); + } + }); + const toolsItems = toolsItemsFirst.concat(preferencesItems); function _checkForUpdates(ctx) { @@ -563,6 +617,13 @@ class Application extends BaseApplication { shim.isMac() ? noItem : newNotebookItem, { type: 'separator', visible: shim.isMac() ? false : true + }, { + label: _('Templates'), + visible: shim.isMac() ? false : true, + submenu: templateItems, + }, { + type: 'separator', + visible: shim.isMac() ? false : true }, { label: _('Import'), visible: shim.isMac() ? false : true, @@ -613,6 +674,11 @@ class Application extends BaseApplication { platforms: ['darwin'], accelerator: 'Command+W', selector: 'performClose:', + }, { + type: 'separator', + }, { + label: _('Templates'), + submenu: templateItems, }, { type: 'separator', }, { @@ -1080,6 +1146,13 @@ class Application extends BaseApplication { css: cssString }); + const templates = await TemplateUtils.loadTemplates(Setting.value('templateDir')); + + this.store().dispatch({ + type: 'TEMPLATE_UPDATE_ALL', + templates: templates + }); + // Note: Auto-update currently doesn't work in Linux: it downloads the update // but then doesn't install it on exit. if (shim.isWindows() || shim.isMac()) { diff --git a/ElectronClient/app/gui/MainScreen.jsx b/ElectronClient/app/gui/MainScreen.jsx index a123a038e..e6a812e1f 100644 --- a/ElectronClient/app/gui/MainScreen.jsx +++ b/ElectronClient/app/gui/MainScreen.jsx @@ -74,12 +74,13 @@ class MainScreenComponent extends React.Component { async doCommand(command) { if (!command) return; - const createNewNote = async (title, isTodo) => { + const createNewNote = async (template, isTodo) => { const folderId = Setting.value('activeFolderId'); if (!folderId) return; const newNote = { parent_id: folderId, + template: template, is_todo: isTodo ? 1 : 0, }; @@ -272,6 +273,30 @@ class MainScreenComponent extends React.Component { eventManager.emit('alarmChange', { noteId: note.id }); } + this.setState({ promptOptions: null }); + } + }, + }); + } else if (command.name === 'selectTemplate') { + this.setState({ + promptOptions: { + label: _('Template file:'), + inputType: 'dropdown', + value: this.props.templates[0], // Need to start with some value + autocomplete: this.props.templates, + onClose: async (answer) => { + if (answer) { + if (command.noteType === 'note' || command.noteType === 'todo') { + createNewNote(answer.value, command.noteType === 'todo'); + } else { + this.props.dispatch({ + type: 'WINDOW_COMMAND', + name: 'insertTemplate', + value: answer.value, + }); + } + } + this.setState({ promptOptions: null }); } }, @@ -523,6 +548,7 @@ const mapStateToProps = (state) => { selectedNoteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null, plugins: state.plugins, noteDevToolsVisible: state.noteDevToolsVisible, + templates: state.templates, }; }; diff --git a/ElectronClient/app/gui/NoteText.jsx b/ElectronClient/app/gui/NoteText.jsx index c5f61b07b..437fd1820 100644 --- a/ElectronClient/app/gui/NoteText.jsx +++ b/ElectronClient/app/gui/NoteText.jsx @@ -40,6 +40,7 @@ const DecryptionWorker = require('lib/services/DecryptionWorker'); const ModelCache = require('lib/services/ModelCache'); const NoteTextViewer = require('./NoteTextViewer.min'); const NoteRevisionViewer = require('./NoteRevisionViewer.min'); +const TemplateUtils = require('lib/TemplateUtils'); require('brace/mode/markdown'); // https://ace.c9.io/build/kitchen-sink.html @@ -452,14 +453,18 @@ class NoteTextComponent extends React.Component { const stateNoteId = this.state.note ? this.state.note.id : null; let noteId = null; let note = null; + let newNote = null; let loadingNewNote = true; let parentFolder = null; let noteTags = []; let scrollPercent = 0; if (props.newNote) { - note = Object.assign({}, props.newNote); + // assign new note and prevent body from being null + note = Object.assign({}, props.newNote, {body: ''}); this.lastLoadedNoteId_ = null; + if (note.template) + note.body = TemplateUtils.render(note.template); } else { noteId = props.noteId; @@ -1012,6 +1017,8 @@ class NoteTextComponent extends React.Component { fn = this.commandShowLocalSearch; } else if (command.name === 'textCode') { fn = this.commandTextCode; + } else if (command.name === 'insertTemplate') { + fn = () => { return this.commandTemplate(command.value); }; } } @@ -1349,6 +1356,10 @@ class NoteTextComponent extends React.Component { this.wrapSelectionWithStrings('`', '`'); } + commandTemplate(value) { + this.wrapSelectionWithStrings(TemplateUtils.render(value)); + } + addListItem(string1, string2 = '', defaultText = '') { const currentLine = this.selectionRangeCurrentLine(); let newLine = '\n' @@ -1920,6 +1931,7 @@ const mapStateToProps = (state) => { customCss: state.customCss, lastEditorScrollPercents: state.lastEditorScrollPercents, historyNotes: state.historyNotes, + templates: state.templates, }; }; diff --git a/ElectronClient/app/gui/PromptDialog.jsx b/ElectronClient/app/gui/PromptDialog.jsx index 288ebf7bb..17dbcca43 100644 --- a/ElectronClient/app/gui/PromptDialog.jsx +++ b/ElectronClient/app/gui/PromptDialog.jsx @@ -6,6 +6,7 @@ const { themeStyle } = require('../theme.js'); const { time } = require('lib/time-utils.js'); const Datetime = require('react-datetime'); const CreatableSelect = require('react-select/lib/Creatable').default; +const Select = require('react-select').default; const makeAnimated = require('react-select/lib/animated').default; class PromptDialog extends React.Component { @@ -101,7 +102,7 @@ class PromptDialog extends React.Component { borderColor: theme.dividerColor, }; - this.styles_.tagList = { + this.styles_.select = { control: (provided) => (Object.assign(provided, { minWidth: width * 0.2, maxWidth: width * 0.5, @@ -115,6 +116,10 @@ class PromptDialog extends React.Component { fontFamily: theme.fontFamily, backgroundColor: theme.backgroundColor, })), + option: (provided) => (Object.assign(provided, { + color: theme.color, + fontFamily: theme.fontFamily, + })), multiValueLabel: (provided) => (Object.assign(provided, { fontFamily: theme.fontFamily, })), @@ -123,14 +128,22 @@ class PromptDialog extends React.Component { })), }; - this.styles_.tagListTheme = (tagTheme) => (Object.assign(tagTheme, { + this.styles_.selectTheme = (tagTheme) => (Object.assign(tagTheme, { borderRadius: 2, colors: Object.assign(tagTheme.colors, { primary: theme.raisedBackgroundColor, primary25: theme.raisedBackgroundColor, neutral0: theme.backgroundColor, + neutral5: theme.backgroundColor, neutral10: theme.raisedBackgroundColor, + neutral20: theme.raisedBackgroundColor, + neutral30: theme.raisedBackgroundColor, + neutral40: theme.color, + neutral50: theme.color, + neutral60: theme.color, + neutral70: theme.color, neutral80: theme.color, + neutral90: theme.color, danger: theme.backgroundColor, dangerLight: theme.colorError2, }), @@ -179,14 +192,19 @@ class PromptDialog extends React.Component { this.setState({ answer: momentObject }); } - const onTagsChange = (newTags) => { - this.setState({ answer: newTags }); + const onSelectChange = (newValue) => { + this.setState({ answer: newValue }); this.focusInput_ = true; } const onKeyDown = (event) => { - if (event.key === 'Enter' && this.props.inputType !== 'tags') { - onClose(true); + if (event.key === 'Enter') { + if (this.props.inputType !== 'tags' && this.props.inputType !== 'dropdown') { + onClose(true); + } else if (this.answerInput_.current && !this.answerInput_.current.state.menuIsOpen) { + // The menu will be open if the user is selecting a new item + onClose(true); + } } else if (event.key === 'Escape') { onClose(false); } @@ -206,8 +224,8 @@ class PromptDialog extends React.Component { /> } else if (this.props.inputType === 'tags') { inputComp = onKeyDown(event)} + /> + } else if (this.props.inputType === 'dropdown') { + inputComp =