const React = require('react'); const { connect } = require('react-redux'); const { Header } = require('./Header.min.js'); const { SideBar } = require('./SideBar.min.js'); const { NoteList } = require('./NoteList.min.js'); const NoteEditor = require('./NoteEditor/NoteEditor.js').default; const { stateUtils } = require('lib/reducer.js'); const { PromptDialog } = require('./PromptDialog.min.js'); const NoteContentPropertiesDialog = require('./NoteContentPropertiesDialog.js').default; const NotePropertiesDialog = require('./NotePropertiesDialog.min.js'); const ShareNoteDialog = require('./ShareNoteDialog.js').default; const InteropServiceHelper = require('../InteropServiceHelper.js'); const Setting = require('lib/models/Setting.js'); const BaseModel = require('lib/BaseModel.js'); const Tag = require('lib/models/Tag.js'); const Note = require('lib/models/Note.js'); const { uuid } = require('lib/uuid.js'); const { shim } = require('lib/shim'); const Folder = require('lib/models/Folder.js'); const { themeStyle } = require('../theme.js'); const { _ } = require('lib/locale.js'); const { bridge } = require('electron').remote.require('./bridge'); const eventManager = require('../eventManager'); const VerticalResizer = require('./VerticalResizer.min'); const PluginManager = require('lib/services/PluginManager'); const TemplateUtils = require('lib/TemplateUtils'); const EncryptionService = require('lib/services/EncryptionService'); const ipcRenderer = require('electron').ipcRenderer; const { time } = require('lib/time-utils.js'); class MainScreenComponent extends React.Component { constructor() { super(); this.state = { promptOptions: null, modalLayer: { visible: false, message: '', }, notePropertiesDialogOptions: {}, noteContentPropertiesDialogOptions: {}, shareNoteDialogOptions: {}, }; this.setupAppCloseHandling(); this.notePropertiesDialog_close = this.notePropertiesDialog_close.bind(this); this.noteContentPropertiesDialog_close = this.noteContentPropertiesDialog_close.bind(this); this.shareNoteDialog_close = this.shareNoteDialog_close.bind(this); this.sidebar_onDrag = this.sidebar_onDrag.bind(this); this.noteList_onDrag = this.noteList_onDrag.bind(this); this.commandSavePdf = this.commandSavePdf.bind(this); this.commandPrint = this.commandPrint.bind(this); } setupAppCloseHandling() { this.waitForNotesSavedIID_ = null; // This event is dispached from the main process when the app is about // to close. The renderer process must respond with the "appCloseReply" // and tell the main process whether the app can really be closed or not. // For example, it cannot be closed right away if a note is being saved. // If a note is being saved, we wait till it is saved and then call // "appCloseReply" again. ipcRenderer.on('appClose', () => { if (this.waitForNotesSavedIID_) clearInterval(this.waitForNotesSavedIID_); this.waitForNotesSavedIID_ = null; ipcRenderer.send('asynchronous-message', 'appCloseReply', { canClose: !this.props.hasNotesBeingSaved, }); if (this.props.hasNotesBeingSaved) { this.waitForNotesSavedIID_ = setInterval(() => { if (!this.props.hasNotesBeingSaved) { clearInterval(this.waitForNotesSavedIID_); this.waitForNotesSavedIID_ = null; ipcRenderer.send('asynchronous-message', 'appCloseReply', { canClose: true, }); } }, 50); } }); } sidebar_onDrag(event) { Setting.setValue('style.sidebar.width', this.props.sidebarWidth + event.deltaX); } noteList_onDrag(event) { Setting.setValue('style.noteList.width', Setting.value('style.noteList.width') + event.deltaX); } notePropertiesDialog_close() { this.setState({ notePropertiesDialogOptions: {} }); } noteContentPropertiesDialog_close() { this.setState({ noteContentPropertiesDialogOptions: {} }); } shareNoteDialog_close() { this.setState({ shareNoteDialogOptions: {} }); } UNSAFE_componentWillReceiveProps(newProps) { // Execute a command if any, and if we haven't already executed it if (newProps.windowCommand && newProps.windowCommand !== this.props.windowCommand) { this.doCommand(newProps.windowCommand); } } toggleVisiblePanes() { this.props.dispatch({ type: 'NOTE_VISIBLE_PANES_TOGGLE', }); } toggleSidebar() { this.props.dispatch({ type: 'SIDEBAR_VISIBILITY_TOGGLE', }); } toggleNoteList() { this.props.dispatch({ type: 'NOTELIST_VISIBILITY_TOGGLE', }); } async doCommand(command) { if (!command) return; const createNewNote = async (template, isTodo) => { const folderId = Setting.value('activeFolderId'); if (!folderId) return; const body = template ? TemplateUtils.render(template) : ''; const newNote = await Note.save({ parent_id: folderId, is_todo: isTodo ? 1 : 0, body: body, }, { provisional: true }); this.props.dispatch({ type: 'NOTE_SELECT', id: newNote.id, }); }; let commandProcessed = true; let delayedFunction = null; let delayedArgs = null; if (command.name === 'newNote') { if (!this.props.folders.length) { bridge().showErrorMessageBox(_('Please create a notebook first.')); } else { await createNewNote(null, false); } } else if (command.name === 'newTodo') { if (!this.props.folders.length) { bridge().showErrorMessageBox(_('Please create a notebook first')); } else { await createNewNote(null, true); } } else if (command.name === 'newNotebook' || (command.name === 'newSubNotebook' && command.activeFolderId)) { this.setState({ promptOptions: { label: _('Notebook title:'), onClose: async (answer) => { if (answer) { let folder = null; try { folder = await Folder.save({ title: answer }, { userSideValidation: true }); if (command.name === 'newSubNotebook') folder = await Folder.moveToFolder(folder.id, command.activeFolderId); } catch (error) { bridge().showErrorMessageBox(error.message); } if (folder) { this.props.dispatch({ type: 'FOLDER_SELECT', id: folder.id, }); } } this.setState({ promptOptions: null }); }, }, }); } else if (command.name === 'setTags') { const tags = await Tag.commonTagsByNoteIds(command.noteIds); const startTags = tags .map((a) => { return { value: a.id, label: a.title }; }) .sort((a, b) => { // sensitivity accent will treat accented characters as differemt // but treats caps as equal return a.label.localeCompare(b.label, undefined, { sensitivity: 'accent' }); }); const allTags = await Tag.allWithNotes(); const tagSuggestions = allTags.map((a) => { return { value: a.id, label: a.title }; }) .sort((a, b) => { // sensitivity accent will treat accented characters as differemt // but treats caps as equal return a.label.localeCompare(b.label, undefined, { sensitivity: 'accent' }); }); this.setState({ promptOptions: { label: _('Add or remove tags:'), inputType: 'tags', value: startTags, autocomplete: tagSuggestions, onClose: async (answer) => { if (answer !== null) { const endTagTitles = answer.map((a) => { return a.label.trim(); }); if (command.noteIds.length === 1) { await Tag.setNoteTagsByTitles(command.noteIds[0], endTagTitles); } else { const startTagTitles = startTags.map((a) => { return a.label.trim(); }); const addTags = endTagTitles.filter((value) => !startTagTitles.includes(value)); const delTags = startTagTitles.filter((value) => !endTagTitles.includes(value)); // apply the tag additions and deletions to each selected note for (let i = 0; i < command.noteIds.length; i++) { const tags = await Tag.tagsByNoteId(command.noteIds[i]); let tagTitles = tags.map((a) => { return a.title; }); tagTitles = tagTitles.concat(addTags); tagTitles = tagTitles.filter((value) => !delTags.includes(value)); await Tag.setNoteTagsByTitles(command.noteIds[i], tagTitles); } } } this.setState({ promptOptions: null }); }, }, }); } else if (command.name === 'moveToFolder') { const folders = await Folder.sortFolderTree(); const startFolders = []; const maxDepth = 15; const addOptions = (folders, depth) => { for (let i = 0; i < folders.length; i++) { const folder = folders[i]; startFolders.push({ key: folder.id, value: folder.id, label: folder.title, indentDepth: depth }); if (folder.children) addOptions(folder.children, (depth + 1) < maxDepth ? depth + 1 : maxDepth); } }; addOptions(folders, 0); this.setState({ promptOptions: { label: _('Move to notebook:'), inputType: 'dropdown', value: '', autocomplete: startFolders, onClose: async (answer) => { if (answer != null) { for (let i = 0; i < command.noteIds.length; i++) { await Note.moveToFolder(command.noteIds[i], answer.value); } } this.setState({ promptOptions: null }); }, }, }); } else if (command.name === 'renameFolder') { const folder = await Folder.load(command.id); if (folder) { this.setState({ promptOptions: { label: _('Rename notebook:'), value: folder.title, onClose: async (answer) => { if (answer !== null) { try { folder.title = answer; await Folder.save(folder, { fields: ['title'], userSideValidation: true }); } catch (error) { bridge().showErrorMessageBox(error.message); } } this.setState({ promptOptions: null }); }, }, }); } } else if (command.name === 'renameTag') { const tag = await Tag.load(command.id); if (tag) { this.setState({ promptOptions: { label: _('Rename tag:'), value: tag.title, onClose: async (answer) => { if (answer !== null) { try { tag.title = answer; await Tag.save(tag, { fields: ['title'], userSideValidation: true }); } catch (error) { bridge().showErrorMessageBox(error.message); } } this.setState({ promptOptions: null }); }, }, }); } } else if (command.name === 'search') { if (!this.searchId_) this.searchId_ = uuid.create(); this.props.dispatch({ type: 'SEARCH_UPDATE', search: { id: this.searchId_, title: command.query, query_pattern: command.query, query_folder_id: null, type_: BaseModel.TYPE_SEARCH, }, }); if (command.query) { this.props.dispatch({ type: 'SEARCH_SELECT', id: this.searchId_, }); } else { const note = await Note.load(this.props.selectedNoteId); if (note) { this.props.dispatch({ type: 'FOLDER_AND_NOTE_SELECT', folderId: note.parent_id, noteId: note.id, }); } } } else if (command.name === 'commandNoteProperties') { this.setState({ notePropertiesDialogOptions: { noteId: command.noteId, visible: true, onRevisionLinkClick: command.onRevisionLinkClick, }, }); } else if (command.name === 'commandContentProperties') { const note = await Note.load(this.props.selectedNoteId); if (note) { this.setState({ noteContentPropertiesDialogOptions: { visible: true, text: note.body, // lines: command.lines, }, }); } } else if (command.name === 'commandShareNoteDialog') { this.setState({ shareNoteDialogOptions: { noteIds: command.noteIds, visible: true, }, }); } else if (command.name === 'toggleVisiblePanes') { this.toggleVisiblePanes(); } else if (command.name === 'toggleSidebar') { this.toggleSidebar(); } else if (command.name === 'toggleNoteList') { this.toggleNoteList(); } else if (command.name === 'showModalMessage') { this.setState({ modalLayer: { visible: true, message:
{command.message}
, }, }); } else if (command.name === 'hideModalMessage') { this.setState({ modalLayer: { visible: false, message: '' } }); } else if (command.name === 'editAlarm') { const note = await Note.load(command.noteId); const defaultDate = new Date(Date.now() + 2 * 3600 * 1000); defaultDate.setMinutes(0); defaultDate.setSeconds(0); this.setState({ promptOptions: { label: _('Set alarm:'), inputType: 'datetime', buttons: ['ok', 'cancel', 'clear'], value: note.todo_due ? new Date(note.todo_due) : defaultDate, onClose: async (answer, buttonType) => { let newNote = null; if (buttonType === 'clear') { newNote = { id: note.id, todo_due: 0, }; } else if (answer !== null) { newNote = { id: note.id, todo_due: answer.getTime(), }; } if (newNote) { await Note.save(newNote); eventManager.emit('alarmChange', { noteId: note.id, note: newNote }); } 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 }); }, }, }); } else if (command.name === 'exportPdf') { delayedFunction = this.commandSavePdf; delayedArgs = { noteIds: command.noteIds }; } else if (command.name === 'print') { delayedFunction = this.commandPrint; delayedArgs = { noteIds: command.noteIds }; } else { commandProcessed = false; } if (commandProcessed) { this.props.dispatch({ type: 'WINDOW_COMMAND', name: null, }); } if (delayedFunction) { requestAnimationFrame(() => { delayedFunction = delayedFunction.bind(this); delayedFunction(delayedArgs); }); } } async waitForNoteToSaved(noteId) { while (noteId && this.props.editorNoteStatuses[noteId] === 'saving') { console.info('Waiting for note to be saved...', this.props.editorNoteStatuses); await time.msleep(100); } } async printTo_(target, options) { // Concurrent print calls are disallowed to avoid incorrect settings being restored upon completion if (this.isPrinting_) { console.info(`Printing ${options.path} to ${target} disallowed, already printing.`); return; } this.isPrinting_ = true; // Need to wait for save because the interop service reloads the note from the database await this.waitForNoteToSaved(options.noteId); if (target === 'pdf') { try { const pdfData = await InteropServiceHelper.exportNoteToPdf(options.noteId, { printBackground: true, pageSize: Setting.value('export.pdfPageSize'), landscape: Setting.value('export.pdfPageOrientation') === 'landscape', customCss: this.props.customCss, }); await shim.fsDriver().writeFile(options.path, pdfData, 'buffer'); } catch (error) { console.error(error); bridge().showErrorMessageBox(error.message); } } else if (target === 'printer') { try { await InteropServiceHelper.printNote(options.noteId, { printBackground: true, customCss: this.props.customCss, }); } catch (error) { console.error(error); bridge().showErrorMessageBox(error.message); } } this.isPrinting_ = false; } async commandSavePdf(args) { try { const noteIds = args.noteIds; if (!noteIds.length) throw new Error('No notes selected for pdf export'); let path = null; if (noteIds.length === 1) { path = bridge().showSaveDialog({ filters: [{ name: _('PDF File'), extensions: ['pdf'] }], defaultPath: await InteropServiceHelper.defaultFilename(noteIds[0], 'pdf'), }); } else { path = bridge().showOpenDialog({ properties: ['openDirectory', 'createDirectory'], }); } if (!path) return; for (let i = 0; i < noteIds.length; i++) { const note = await Note.load(noteIds[i]); let pdfPath = ''; if (noteIds.length === 1) { pdfPath = path; } else { const n = await InteropServiceHelper.defaultFilename(note.id, 'pdf'); pdfPath = await shim.fsDriver().findUniqueFilename(`${path}/${n}`); } await this.printTo_('pdf', { path: pdfPath, noteId: note.id }); } } catch (error) { console.error(error); bridge().showErrorMessageBox(error.message); } } async commandPrint(args) { // TODO: test try { const noteIds = args.noteIds; if (noteIds.length !== 1) throw new Error(_('Only one note can be printed at a time.')); await this.printTo_('printer', { noteId: noteIds[0] }); } catch (error) { bridge().showErrorMessageBox(error.message); } } styles(themeId, width, height, messageBoxVisible, isSidebarVisible, isNoteListVisible, sidebarWidth, noteListWidth) { const styleKey = [themeId, width, height, messageBoxVisible, +isSidebarVisible, +isNoteListVisible, sidebarWidth, noteListWidth].join('_'); if (styleKey === this.styleKey_) return this.styles_; const theme = themeStyle(themeId); this.styleKey_ = styleKey; this.styles_ = {}; this.styles_.header = { width: width, }; this.styles_.messageBox = { width: width, height: 30, display: 'flex', alignItems: 'center', paddingLeft: 10, backgroundColor: theme.warningBackgroundColor, }; const rowHeight = height - theme.headerHeight - (messageBoxVisible ? this.styles_.messageBox.height : 0); this.styles_.verticalResizerSidebar = { width: 5, // HACK: For unknown reasons, the resizers are just a little bit taller than the other elements, // making the whole window scroll vertically. So we remove 10 extra pixels here. height: rowHeight - 10, display: 'inline-block', }; this.styles_.verticalResizerNotelist = Object.assign({}, this.styles_.verticalResizerSidebar); this.styles_.sideBar = { width: sidebarWidth - this.styles_.verticalResizerSidebar.width, height: rowHeight, display: 'inline-block', verticalAlign: 'top', }; if (isSidebarVisible === false) { this.styles_.sideBar.width = 0; this.styles_.sideBar.display = 'none'; this.styles_.verticalResizerSidebar.display = 'none'; } this.styles_.noteList = { width: noteListWidth - this.styles_.verticalResizerNotelist.width, height: rowHeight, display: 'inline-block', verticalAlign: 'top', }; if (isNoteListVisible === false) { this.styles_.noteList.width = 0; this.styles_.noteList.display = 'none'; this.styles_.verticalResizerNotelist.display = 'none'; } this.styles_.noteText = { width: Math.floor(width - this.styles_.sideBar.width - this.styles_.noteList.width - 10), height: rowHeight, display: 'inline-block', verticalAlign: 'top', }; this.styles_.prompt = { width: width, height: height, }; this.styles_.modalLayer = Object.assign({}, theme.textStyle, { zIndex: 10000, position: 'absolute', top: 0, left: 0, backgroundColor: theme.backgroundColor, width: width - 20, height: height - 20, padding: 10, }); return this.styles_; } renderNotification(theme, styles) { if (!this.messageBoxVisible()) return null; const onViewStatusScreen = () => { this.props.dispatch({ type: 'NAV_GO', routeName: 'Status', }); }; const onViewEncryptionConfigScreen = () => { this.props.dispatch({ type: 'NAV_GO', routeName: 'Config', props: { defaultSection: 'encryption', }, }); }; let msg = null; if (this.props.hasDisabledSyncItems) { msg = ( {_('Some items cannot be synchronised.')}{' '} onViewStatusScreen()}> {_('View them now')} ); } else if (this.props.hasDisabledEncryptionItems) { msg = ( {_('Some items cannot be decrypted.')}{' '} onViewStatusScreen()}> {_('View them now')} ); } else if (this.props.showMissingMasterKeyMessage) { msg = ( {_('One or more master keys need a password.')}{' '} onViewEncryptionConfigScreen()}> {_('Set the password')} ); } else if (this.props.showNeedUpgradingMasterKeyMessage) { msg = ( {_('One of your master keys use an obsolete encryption method.')}{' '} onViewEncryptionConfigScreen()}> {_('View them now')} ); } else if (this.props.showShouldReencryptMessage) { msg = ( {_('The default encryption method has been changed, you should re-encrypt your data.')}{' '} onViewEncryptionConfigScreen()}> {_('More info')} ); } return (
{msg}
); } messageBoxVisible() { return this.props.hasDisabledSyncItems || this.props.showMissingMasterKeyMessage || this.props.showNeedUpgradingMasterKeyMessage || this.props.showShouldReencryptMessage || this.props.hasDisabledEncryptionItems; } render() { const theme = themeStyle(this.props.theme); const style = Object.assign( { color: theme.color, backgroundColor: theme.backgroundColor, }, this.props.style, ); const promptOptions = this.state.promptOptions; const folders = this.props.folders; const notes = this.props.notes; const sidebarVisibility = this.props.sidebarVisibility; const noteListVisibility = this.props.noteListVisibility; const styles = this.styles(this.props.theme, style.width, style.height, this.messageBoxVisible(), sidebarVisibility, noteListVisibility, this.props.sidebarWidth, this.props.noteListWidth); const onConflictFolder = this.props.selectedFolderId === Folder.conflictFolderId(); const headerItems = []; headerItems.push({ title: _('Toggle sidebar'), iconName: 'fa-bars', iconRotation: this.props.sidebarVisibility ? 0 : 90, onClick: () => { this.doCommand({ name: 'toggleSidebar' }); }, }); headerItems.push({ title: _('Toggle note list'), iconName: 'fa-align-justify', iconRotation: noteListVisibility ? 0 : 90, onClick: () => { this.doCommand({ name: 'toggleNoteList' }); }, }); headerItems.push({ title: _('New note'), iconName: 'fa-file', enabled: !!folders.length && !onConflictFolder, onClick: () => { this.doCommand({ name: 'newNote' }); }, }); headerItems.push({ title: _('New to-do'), iconName: 'fa-check-square', enabled: !!folders.length && !onConflictFolder, onClick: () => { this.doCommand({ name: 'newTodo' }); }, }); headerItems.push({ title: _('New notebook'), iconName: 'fa-book', onClick: () => { this.doCommand({ name: 'newNotebook' }); }, }); headerItems.push({ title: _('Code View'), iconName: 'fa-file-code ', enabled: !!notes.length, type: 'checkbox', checked: this.props.settingEditorCodeView, onClick: () => { // A bit of a hack, but for now don't allow changing code view // while a note is being saved as it will cause a problem with // TinyMCE because it won't have time to send its content before // being switch to Ace Editor. if (this.props.hasNotesBeingSaved) return; Setting.toggle('editor.codeView'); }, }); if (this.props.settingEditorCodeView) { headerItems.push({ title: _('Layout'), iconName: 'fa-columns', enabled: !!notes.length, onClick: () => { this.doCommand({ name: 'toggleVisiblePanes' }); }, }); } headerItems.push({ title: _('Search...'), iconName: 'fa-search', onQuery: (query) => { this.doCommand({ name: 'search', query: query }); }, type: 'search', }); if (!this.promptOnClose_) { this.promptOnClose_ = (answer, buttonType) => { return this.state.promptOptions.onClose(answer, buttonType); }; } const messageComp = this.renderNotification(theme, styles); const dialogInfo = PluginManager.instance().pluginDialogToShow(this.props.plugins); const pluginDialog = !dialogInfo ? null : ; const modalLayerStyle = Object.assign({}, styles.modalLayer, { display: this.state.modalLayer.visible ? 'block' : 'none' }); const notePropertiesDialogOptions = this.state.notePropertiesDialogOptions; const noteContentPropertiesDialogOptions = this.state.noteContentPropertiesDialogOptions; const shareNoteDialogOptions = this.state.shareNoteDialogOptions; const bodyEditor = this.props.settingEditorCodeView ? 'AceEditor' : 'TinyMCE'; return (
{this.state.modalLayer.message}
{noteContentPropertiesDialogOptions.visible && } {notePropertiesDialogOptions.visible && } {shareNoteDialogOptions.visible && }
{messageComp} {pluginDialog}
); } } const mapStateToProps = (state) => { return { theme: state.settings.theme, settingEditorCodeView: state.settings['editor.codeView'], windowCommand: state.windowCommand, sidebarVisibility: state.sidebarVisibility, noteListVisibility: state.noteListVisibility, folders: state.folders, notes: state.notes, hasDisabledSyncItems: state.hasDisabledSyncItems, hasDisabledEncryptionItems: state.hasDisabledEncryptionItems, showMissingMasterKeyMessage: state.notLoadedMasterKeys.length && state.masterKeys.length, showNeedUpgradingMasterKeyMessage: !!EncryptionService.instance().masterKeysThatNeedUpgrading(state.masterKeys).length, showShouldReencryptMessage: state.settings['encryption.shouldReencrypt'] >= Setting.SHOULD_REENCRYPT_YES, selectedFolderId: state.selectedFolderId, sidebarWidth: state.settings['style.sidebar.width'], noteListWidth: state.settings['style.noteList.width'], selectedNoteId: state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null, plugins: state.plugins, templates: state.templates, customCss: state.customCss, editorNoteStatuses: state.editorNoteStatuses, hasNotesBeingSaved: stateUtils.hasNotesBeingSaved(state), }; }; const MainScreen = connect(mapStateToProps)(MainScreenComponent); module.exports = { MainScreen };