diff --git a/.babelrc b/.babelrc index 92bb81ed..270349d2 100644 --- a/.babelrc +++ b/.babelrc @@ -5,7 +5,7 @@ "presets": ["react-hmre"] }, "test": { - "presets": ["react", "es2015"], + "presets": ["env" ,"react", "es2015"], "plugins": [ [ "babel-plugin-webpack-alias", { "config": "${PWD}/webpack.config.js" } ] ] diff --git a/.boostnoterc.sample b/.boostnoterc.sample index 2caa2c1a..2d581a48 100644 --- a/.boostnoterc.sample +++ b/.boostnoterc.sample @@ -10,7 +10,6 @@ "theme": "monokai" }, "hotkey": { - "toggleFinder": "Cmd + Alt + S", "toggleMain": "Cmd + Alt + L" }, "isSideNavFolded": false, @@ -23,7 +22,10 @@ "fontSize": "14", "lineNumber": true }, - "sortBy": "UPDATED_AT", + "sortBy": { + "default": "UPDATED_AT" + }, + "sortTagsBy": "ALPHABETICAL", "ui": { "defaultNote": "ALWAYS_ASK", "disableDirectWrite": false, diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..a4730cbf --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Space indentation +[*] +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +# The indent size used in the `package.json` file cannot be changed +# https://github.com/npm/npm/pull/3180#issuecomment-16336516 +[{*.yml,*.yaml,package.json}] +indent_style = space +indent_size = 2 diff --git a/.eslintignore b/.eslintignore index e9a81977..5f7deaa8 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,4 @@ node_modules/ compiled/ dist/ +extra_scripts/ \ No newline at end of file diff --git a/.eslintrc b/.eslintrc index a1646659..1709c9d8 100644 --- a/.eslintrc +++ b/.eslintrc @@ -3,7 +3,9 @@ "plugins": ["react"], "rules": { "no-useless-escape": 0, - "prefer-const": "warn", + "prefer-const": ["warn", { + "destructuring": "all" + }], "no-unused-vars": "warn", "no-undef": "warn", "no-lone-blocks": "warn", @@ -17,5 +19,8 @@ "FileReader": true, "localStorage": true, "fetch": true + }, + "env": { + "jest": true } } diff --git a/.travis.yml b/.travis.yml index 013169e8..d9267f77 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,9 @@ language: node_js node_js: - - stable - - lts/* + - 7 script: - npm run lint && npm run test + - yarn jest - 'if [[ ${TRAVIS_PULL_REQUEST_BRANCH:-$TRAVIS_BRANCH} = "master" ]]; then npm install -g grunt npm@5.2 && grunt pre-build; fi' after_success: - openssl aes-256-cbc -K $encrypted_440d7f9a3c38_key -iv $encrypted_440d7f9a3c38_iv diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md index be4b7e4f..1f5ada57 100644 --- a/ISSUE_TEMPLATE.md +++ b/ISSUE_TEMPLATE.md @@ -1,10 +1,25 @@ +# Current behavior + +# Expected behavior + +# Steps to reproduce + +1. +2. +3. + +# Environment + +- Version : +- OS Version and name : + diff --git a/LICENSE b/LICENSE index 0b41fd66..7472c9eb 100644 --- a/LICENSE +++ b/LICENSE @@ -2,7 +2,7 @@ GPL-3.0 Boostnote - an open source note-taking app made for programmers just like you. -Copyright (C) 2017 Maisin&Co., Inc. +Copyright (C) 2017 - 2018 BoostIO This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by diff --git a/__mocks__/electron.js b/__mocks__/electron.js new file mode 100644 index 00000000..2176fbac --- /dev/null +++ b/__mocks__/electron.js @@ -0,0 +1,7 @@ +module.exports = { + require: jest.genMockFunction(), + match: jest.genMockFunction(), + app: jest.genMockFunction(), + remote: jest.genMockFunction(), + dialog: jest.genMockFunction() +} diff --git a/browser/components/CodeEditor.js b/browser/components/CodeEditor.js index 5d799935..d81ce39d 100644 --- a/browser/components/CodeEditor.js +++ b/browser/components/CodeEditor.js @@ -3,36 +3,37 @@ import React from 'react' import _ from 'lodash' import CodeMirror from 'codemirror' import 'codemirror-mode-elixir' -import path from 'path' -import copyImage from 'browser/main/lib/dataApi/copyImage' -import { findStorage } from 'browser/lib/findStorage' +import attachmentManagement from 'browser/main/lib/dataApi/attachmentManagement' +import convertModeName from 'browser/lib/convertModeName' +import { options, TableEditor } from '@susisu/mte-kernel' +import TextEditorInterface from 'browser/lib/TextEditorInterface' +import eventEmitter from 'browser/main/lib/eventEmitter' +import iconv from 'iconv-lite' +import crypto from 'crypto' +import consts from 'browser/lib/consts' import fs from 'fs' +const { ipcRenderer } = require('electron') +import normalizeEditorFontFamily from 'browser/lib/normalizeEditorFontFamily' CodeMirror.modeURL = '../node_modules/codemirror/mode/%N/%N.js' -const defaultEditorFontFamily = ['Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', 'monospace'] - -function pass (name) { - switch (name) { - case 'ejs': - return 'Embedded Javascript' - case 'html_ruby': - return 'Embedded Ruby' - case 'objectivec': - return 'Objective C' - case 'text': - return 'Plain Text' - default: - return name - } -} +const buildCMRulers = (rulers, enableRulers) => + (enableRulers ? rulers.map(ruler => ({ column: ruler })) : []) export default class CodeEditor extends React.Component { constructor (props) { super(props) - this.changeHandler = (e) => this.handleChange(e) + this.scrollHandler = _.debounce(this.handleScroll.bind(this), 100, { + leading: false, + trailing: true + }) + this.changeHandler = e => this.handleChange(e) + this.focusHandler = () => { + ipcRenderer.send('editor:focused', true) + } this.blurHandler = (editor, e) => { + ipcRenderer.send('editor:focused', false) if (e == null) return null let el = e.relatedTarget while (el != null) { @@ -42,18 +43,87 @@ export default class CodeEditor extends React.Component { el = el.parentNode } this.props.onBlur != null && this.props.onBlur(e) + + const { storageKey, noteKey } = this.props + attachmentManagement.deleteAttachmentsNotPresentInNote( + this.editor.getValue(), + storageKey, + noteKey + ) } this.pasteHandler = (editor, e) => this.handlePaste(editor, e) - this.loadStyleHandler = (e) => { + this.loadStyleHandler = e => { this.editor.refresh() } + this.searchHandler = (e, msg) => this.handleSearch(msg) + this.searchState = null + + this.formatTable = () => this.handleFormatTable() + } + + handleSearch (msg) { + const cm = this.editor + const component = this + + if (component.searchState) cm.removeOverlay(component.searchState) + if (msg.length < 3) return + + cm.operation(function () { + component.searchState = makeOverlay(msg, 'searching') + cm.addOverlay(component.searchState) + + function makeOverlay (query, style) { + query = new RegExp( + query.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'), + 'gi' + ) + return { + token: function (stream) { + query.lastIndex = stream.pos + var match = query.exec(stream.string) + if (match && match.index === stream.pos) { + stream.pos += match[0].length || 1 + return style + } else if (match) { + stream.pos = match.index + } else { + stream.skipToEnd() + } + } + } + } + }) + } + + handleFormatTable () { + this.tableEditor.formatAll(options({textWidthOptions: {}})) } componentDidMount () { + const { rulers, enableRulers } = this.props + const expandSnippet = this.expandSnippet.bind(this) + + const defaultSnippet = [ + { + id: crypto.randomBytes(16).toString('hex'), + name: 'Dummy text', + prefix: ['lorem', 'ipsum'], + content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.' + } + ] + if (!fs.existsSync(consts.SNIPPET_FILE)) { + fs.writeFileSync( + consts.SNIPPET_FILE, + JSON.stringify(defaultSnippet, null, 4), + 'utf8' + ) + } + this.value = this.props.value this.editor = CodeMirror(this.refs.root, { + rulers: buildCMRulers(rulers, enableRulers), value: this.props.value, - lineNumbers: true, + lineNumbers: this.props.displayLineNumbers, lineWrapping: true, theme: this.props.theme, indentUnit: this.props.indentSize, @@ -63,15 +133,24 @@ export default class CodeEditor extends React.Component { scrollPastEnd: this.props.scrollPastEnd, inputStyle: 'textarea', dragDrop: false, - autoCloseBrackets: true, + foldGutter: true, + gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'], + autoCloseBrackets: { + pairs: '()[]{}\'\'""$$**``', + triples: '```"""\'\'\'', + explode: '[]{}``$$', + override: true + }, extraKeys: { Tab: function (cm) { const cursor = cm.getCursor() const line = cm.getLine(cursor.line) + const cursorPosition = cursor.ch + const charBeforeCursor = line.substr(cursorPosition - 1, 1) if (cm.somethingSelected()) cm.indentSelection('add') else { const tabs = cm.getOption('indentWithTabs') - if (line.trimLeft().match(/^(-|\*|\+) (\[( |x)\] )?$/)) { + if (line.trimLeft().match(/^(-|\*|\+) (\[( |x)] )?$/)) { cm.execCommand('goLineStart') if (tabs) { cm.execCommand('insertTab') @@ -79,6 +158,21 @@ export default class CodeEditor extends React.Component { cm.execCommand('insertSoftTab') } cm.execCommand('goLineEnd') + } else if ( + !charBeforeCursor.match(/\t|\s|\r|\n/) && + cursor.ch > 1 + ) { + // text expansion on tab key if the char before is alphabet + const snippets = JSON.parse( + fs.readFileSync(consts.SNIPPET_FILE, 'utf8') + ) + if (expandSnippet(line, cursor, cm, snippets) === false) { + if (tabs) { + cm.execCommand('insertTab') + } else { + cm.execCommand('insertSoftTab') + } + } } else { if (tabs) { cm.execCommand('insertTab') @@ -91,8 +185,8 @@ export default class CodeEditor extends React.Component { 'Cmd-T': function (cm) { // Do nothing }, - Enter: 'newlineAndIndentContinueMarkdownList', - 'Ctrl-C': (cm) => { + Enter: 'boostNewLineAndIndentContinueMarkdownList', + 'Ctrl-C': cm => { if (cm.getOption('keyMap').substr(0, 3) === 'vim') { document.execCommand('copy') } @@ -103,9 +197,14 @@ export default class CodeEditor extends React.Component { this.setMode(this.props.mode) + this.editor.on('focus', this.focusHandler) this.editor.on('blur', this.blurHandler) this.editor.on('change', this.changeHandler) this.editor.on('paste', this.pasteHandler) + eventEmitter.on('top:search', this.searchHandler) + + eventEmitter.emit('code:init') + this.editor.on('scroll', this.scrollHandler) const editorTheme = document.getElementById('editorTheme') editorTheme.addEventListener('load', this.loadStyleHandler) @@ -115,6 +214,83 @@ export default class CodeEditor extends React.Component { CodeMirror.Vim.defineEx('wq', 'wq', this.quitEditor) CodeMirror.Vim.defineEx('qw', 'qw', this.quitEditor) CodeMirror.Vim.map('ZZ', ':q', 'normal') + + this.tableEditor = new TableEditor(new TextEditorInterface(this.editor)) + eventEmitter.on('code:format-table', this.formatTable) + } + + expandSnippet (line, cursor, cm, snippets) { + const wordBeforeCursor = this.getWordBeforeCursor( + line, + cursor.line, + cursor.ch + ) + const templateCursorString = ':{}' + for (let i = 0; i < snippets.length; i++) { + if (snippets[i].prefix.indexOf(wordBeforeCursor.text) !== -1) { + if (snippets[i].content.indexOf(templateCursorString) !== -1) { + const snippetLines = snippets[i].content.split('\n') + let cursorLineNumber = 0 + let cursorLinePosition = 0 + for (let j = 0; j < snippetLines.length; j++) { + const cursorIndex = snippetLines[j].indexOf(templateCursorString) + if (cursorIndex !== -1) { + cursorLineNumber = j + cursorLinePosition = cursorIndex + cm.replaceRange( + snippets[i].content.replace(templateCursorString, ''), + wordBeforeCursor.range.from, + wordBeforeCursor.range.to + ) + cm.setCursor({ + line: cursor.line + cursorLineNumber, + ch: cursorLinePosition + }) + } + } + } else { + cm.replaceRange( + snippets[i].content, + wordBeforeCursor.range.from, + wordBeforeCursor.range.to + ) + } + return true + } + } + + return false + } + + getWordBeforeCursor (line, lineNumber, cursorPosition) { + let wordBeforeCursor = '' + const originCursorPosition = cursorPosition + const emptyChars = /\t|\s|\r|\n/ + + // to prevent the word to expand is long that will crash the whole app + // the safeStop is there to stop user to expand words that longer than 20 chars + const safeStop = 20 + + while (cursorPosition > 0) { + const currentChar = line.substr(cursorPosition - 1, 1) + // if char is not an empty char + if (!emptyChars.test(currentChar)) { + wordBeforeCursor = currentChar + wordBeforeCursor + } else if (wordBeforeCursor.length >= safeStop) { + throw new Error('Your snippet trigger is too long !') + } else { + break + } + cursorPosition-- + } + + return { + text: wordBeforeCursor, + range: { + from: { line: lineNumber, ch: originCursorPosition }, + to: { line: lineNumber, ch: cursorPosition } + } + } } quitEditor () { @@ -122,15 +298,21 @@ export default class CodeEditor extends React.Component { } componentWillUnmount () { + this.editor.off('focus', this.focusHandler) this.editor.off('blur', this.blurHandler) this.editor.off('change', this.changeHandler) this.editor.off('paste', this.pasteHandler) + eventEmitter.off('top:search', this.searchHandler) + this.editor.off('scroll', this.scrollHandler) const editorTheme = document.getElementById('editorTheme') editorTheme.removeEventListener('load', this.loadStyleHandler) + + eventEmitter.off('code:format-table', this.formatTable) } componentDidUpdate (prevProps, prevState) { let needRefresh = false + const { rulers, enableRulers } = this.props if (prevProps.mode !== this.props.mode) { this.setMode(this.props.mode) } @@ -148,6 +330,13 @@ export default class CodeEditor extends React.Component { needRefresh = true } + if ( + prevProps.enableRulers !== enableRulers || + prevProps.rulers !== rulers + ) { + this.editor.setOption('rulers', buildCMRulers(rulers, enableRulers)) + } + if (prevProps.indentSize !== this.props.indentSize) { this.editor.setOption('indentUnit', this.props.indentSize) this.editor.setOption('tabSize', this.props.indentSize) @@ -156,6 +345,10 @@ export default class CodeEditor extends React.Component { this.editor.setOption('indentWithTabs', this.props.indentType !== 'space') } + if (prevProps.displayLineNumbers !== this.props.displayLineNumbers) { + this.editor.setOption('lineNumbers', this.props.displayLineNumbers) + } + if (prevProps.scrollPastEnd !== this.props.scrollPastEnd) { this.editor.setOption('scrollPastEnd', this.props.scrollPastEnd) } @@ -166,7 +359,7 @@ export default class CodeEditor extends React.Component { } setMode (mode) { - let syntax = CodeMirror.findModeByName(pass(mode)) + let syntax = CodeMirror.findModeByName(convertModeName(mode)) if (syntax == null) syntax = CodeMirror.findModeByName('Plain Text') this.editor.setOption('mode', syntax.mime) @@ -180,11 +373,9 @@ export default class CodeEditor extends React.Component { } } - moveCursorTo (row, col) { - } + moveCursorTo (row, col) {} - scrollToLine (num) { - } + scrollToLine (num) {} focus () { this.editor.focus() @@ -210,64 +401,189 @@ export default class CodeEditor extends React.Component { this.editor.setCursor(cursor) } - handleDropImage (e) { - e.preventDefault() - const imagePath = e.dataTransfer.files[0].path - const filename = path.basename(imagePath) - - copyImage(imagePath, this.props.storageKey).then((imagePath) => { - const imageMd = `![${filename}](${path.join('/:storage', imagePath)})` - this.insertImageMd(imageMd) - }) + handleDropImage (dropEvent) { + dropEvent.preventDefault() + const { storageKey, noteKey } = this.props + attachmentManagement.handleAttachmentDrop( + this, + storageKey, + noteKey, + dropEvent + ) } - insertImageMd (imageMd) { + insertAttachmentMd (imageMd) { this.editor.replaceSelection(imageMd) } handlePaste (editor, e) { - const dataTransferItem = e.clipboardData.items[0] - if (!dataTransferItem.type.match('image')) return - - const blob = dataTransferItem.getAsFile() - const reader = new FileReader() - let base64data - - reader.readAsDataURL(blob) - reader.onloadend = () => { - base64data = reader.result.replace(/^data:image\/png;base64,/, '') - base64data += base64data.replace('+', ' ') - const binaryData = new Buffer(base64data, 'base64').toString('binary') - const imageName = Math.random().toString(36).slice(-16) - const storagePath = findStorage(this.props.storageKey).path - const imageDir = path.join(storagePath, 'images') - if (!fs.existsSync(imageDir)) fs.mkdirSync(imageDir) - const imagePath = path.join(imageDir, `${imageName}.png`) - fs.writeFile(imagePath, binaryData, 'binary') - const imageMd = `![${imageName}](${path.join('/:storage', `${imageName}.png`)})` - this.insertImageMd(imageMd) + const clipboardData = e.clipboardData + const { storageKey, noteKey } = this.props + const dataTransferItem = clipboardData.items[0] + const pastedTxt = clipboardData.getData('text') + const isURL = str => { + const matcher = /^(?:\w+:)?\/\/([^\s\.]+\.\S{2}|localhost[\:?\d]*)\S*$/ + return matcher.test(str) + } + const isInLinkTag = editor => { + const startCursor = editor.getCursor('start') + const prevChar = editor.getRange( + { line: startCursor.line, ch: startCursor.ch - 2 }, + { line: startCursor.line, ch: startCursor.ch } + ) + const endCursor = editor.getCursor('end') + const nextChar = editor.getRange( + { line: endCursor.line, ch: endCursor.ch }, + { line: endCursor.line, ch: endCursor.ch + 1 } + ) + return prevChar === '](' && nextChar === ')' + } + if (dataTransferItem.type.match('image')) { + attachmentManagement.handlePastImageEvent( + this, + storageKey, + noteKey, + dataTransferItem + ) + } else if ( + this.props.fetchUrlTitle && + isURL(pastedTxt) && + !isInLinkTag(editor) + ) { + this.handlePasteUrl(e, editor, pastedTxt) + } + if (attachmentManagement.isAttachmentLink(pastedTxt)) { + attachmentManagement + .handleAttachmentLinkPaste(storageKey, noteKey, pastedTxt) + .then(modifiedText => { + this.editor.replaceSelection(modifiedText) + }) + e.preventDefault() } } + handleScroll (e) { + if (this.props.onScroll) { + this.props.onScroll(e) + } + } + + handlePasteUrl (e, editor, pastedTxt) { + e.preventDefault() + const taggedUrl = `<${pastedTxt}>` + editor.replaceSelection(taggedUrl) + + const isImageReponse = response => { + return ( + response.headers.has('content-type') && + response.headers.get('content-type').match(/^image\/.+$/) + ) + } + const replaceTaggedUrl = replacement => { + const value = editor.getValue() + const cursor = editor.getCursor() + const newValue = value.replace(taggedUrl, replacement) + const newCursor = Object.assign({}, cursor, { + ch: cursor.ch + newValue.length - value.length + }) + editor.setValue(newValue) + editor.setCursor(newCursor) + } + + fetch(pastedTxt, { + method: 'get' + }) + .then(response => { + if (isImageReponse(response)) { + return this.mapImageResponse(response, pastedTxt) + } else { + return this.mapNormalResponse(response, pastedTxt) + } + }) + .then(replacement => { + replaceTaggedUrl(replacement) + }) + .catch(e => { + replaceTaggedUrl(pastedTxt) + }) + } + + mapNormalResponse (response, pastedTxt) { + return this.decodeResponse(response).then(body => { + return new Promise((resolve, reject) => { + try { + const parsedBody = new window.DOMParser().parseFromString( + body, + 'text/html' + ) + const linkWithTitle = `[${parsedBody.title}](${pastedTxt})` + resolve(linkWithTitle) + } catch (e) { + reject(e) + } + }) + }) + } + + mapImageResponse (response, pastedTxt) { + return new Promise((resolve, reject) => { + try { + const url = response.url + const name = url.substring(url.lastIndexOf('/') + 1) + const imageLinkWithName = `![${name}](${pastedTxt})` + resolve(imageLinkWithName) + } catch (e) { + reject(e) + } + }) + } + + decodeResponse (response) { + const headers = response.headers + const _charset = headers.has('content-type') + ? this.extractContentTypeCharset(headers.get('content-type')) + : undefined + return response.arrayBuffer().then(buff => { + return new Promise((resolve, reject) => { + try { + const charset = _charset !== undefined && + iconv.encodingExists(_charset) + ? _charset + : 'utf-8' + resolve(iconv.decode(new Buffer(buff), charset).toString()) + } catch (e) { + reject(e) + } + }) + }) + } + + extractContentTypeCharset (contentType) { + return contentType + .split(';') + .filter(str => { + return str.trim().toLowerCase().startsWith('charset') + }) + .map(str => { + return str.replace(/['"]/g, '').split('=')[1] + })[0] + } + render () { - const { className, fontSize } = this.props - let fontFamily = this.props.fontFamily - fontFamily = _.isString(fontFamily) && fontFamily.length > 0 - ? [fontFamily].concat(defaultEditorFontFamily) - : defaultEditorFontFamily + const {className, fontSize} = this.props + const fontFamily = normalizeEditorFontFamily(this.props.fontFamily) + const width = this.props.width return (
this.handleDropImage(e)} + onDrop={e => this.handleDropImage(e)} /> ) } @@ -275,6 +591,8 @@ export default class CodeEditor extends React.Component { CodeEditor.propTypes = { value: PropTypes.string, + enableRulers: PropTypes.bool, + rulers: PropTypes.arrayOf(Number), mode: PropTypes.string, className: PropTypes.string, onBlur: PropTypes.func, diff --git a/browser/components/MarkdownEditor.js b/browser/components/MarkdownEditor.js index e329d281..ee80c887 100644 --- a/browser/components/MarkdownEditor.js +++ b/browser/components/MarkdownEditor.js @@ -92,7 +92,9 @@ class MarkdownEditor extends React.Component { if (this.state.isLocked) return this.setState({ keyPressed: new Set() }) const { config } = this.props - if (config.editor.switchPreview === 'BLUR') { + if (config.editor.switchPreview === 'BLUR' || + (config.editor.switchPreview === 'DBL_CLICK' && this.state.status === 'CODE') + ) { const cursorPosition = this.refs.code.editor.getCursor() this.setState({ status: 'PREVIEW' @@ -104,6 +106,20 @@ class MarkdownEditor extends React.Component { } } + handleDoubleClick (e) { + if (this.state.isLocked) return + this.setState({keyPressed: new Set()}) + const { config } = this.props + if (config.editor.switchPreview === 'DBL_CLICK') { + this.setState({ + status: 'CODE' + }, () => { + this.refs.code.focus() + eventEmitter.emit('topbar:togglelockbutton', this.state.status) + }) + } + } + handlePreviewMouseDown (e) { this.previewMouseDownedAt = new Date() } @@ -207,7 +223,7 @@ class MarkdownEditor extends React.Component { } render () { - const { className, value, config, storageKey } = this.props + const {className, value, config, storageKey, noteKey} = this.props let editorFontSize = parseInt(config.editor.fontSize, 10) if (!(editorFontSize > 0 && editorFontSize < 101)) editorFontSize = 14 @@ -242,8 +258,13 @@ class MarkdownEditor extends React.Component { fontSize={editorFontSize} indentType={config.editor.indentType} indentSize={editorIndentSize} + enableRulers={config.editor.enableRulers} + rulers={config.editor.rulers} + displayLineNumbers={config.editor.displayLineNumbers} scrollPastEnd={config.editor.scrollPastEnd} storageKey={storageKey} + noteKey={noteKey} + fetchUrlTitle={config.editor.fetchUrlTitle} onChange={(e) => this.handleChange(e)} onBlur={(e) => this.handleBlur(e)} /> @@ -260,9 +281,14 @@ class MarkdownEditor extends React.Component { codeBlockFontFamily={config.editor.fontFamily} lineNumber={config.preview.lineNumber} indentSize={editorIndentSize} - scrollPastEnd={config.editor.scrollPastEnd} + scrollPastEnd={config.preview.scrollPastEnd} + smartQuotes={config.preview.smartQuotes} + smartArrows={config.preview.smartArrows} + breaks={config.preview.breaks} + sanitize={config.preview.sanitize} ref='preview' onContextMenu={(e) => this.handleContextMenu(e)} + onDoubleClick={(e) => this.handleDoubleClick(e)} tabIndex='0' value={this.state.renderValue} onMouseUp={(e) => this.handlePreviewMouseUp(e)} @@ -270,6 +296,9 @@ class MarkdownEditor extends React.Component { onCheckboxClick={(e) => this.handleCheckboxClick(e)} showCopyNotification={config.ui.showCopyNotification} storagePath={storage.path} + noteKey={noteKey} + customCSS={config.preview.customCSS} + allowCustomCSS={config.preview.allowCustomCSS} />
) diff --git a/browser/components/MarkdownPreview.js b/browser/components/MarkdownPreview.js old mode 100644 new mode 100755 index 711cabcd..5376a773 --- a/browser/components/MarkdownPreview.js +++ b/browser/components/MarkdownPreview.js @@ -1,30 +1,51 @@ import PropTypes from 'prop-types' import React from 'react' -import markdown from 'browser/lib/markdown' +import Markdown from 'browser/lib/markdown' import _ from 'lodash' import CodeMirror from 'codemirror' import 'codemirror-mode-elixir' import consts from 'browser/lib/consts' import Raphael from 'raphael' import flowchart from 'flowchart' +import mermaidRender from './render/MermaidRender' import SequenceDiagram from 'js-sequence-diagrams' +import Chart from 'chart.js' import eventEmitter from 'browser/main/lib/eventEmitter' -import fs from 'fs' import htmlTextHelper from 'browser/lib/htmlTextHelper' +import convertModeName from 'browser/lib/convertModeName' import copy from 'copy-to-clipboard' import mdurl from 'mdurl' +import exportNote from 'browser/main/lib/dataApi/exportNote' +import { escapeHtmlCharacters } from 'browser/lib/utils' const { remote } = require('electron') +const attachmentManagement = require('../main/lib/dataApi/attachmentManagement') + const { app } = remote const path = require('path') +const fileUrl = require('file-url') + const dialog = remote.dialog const markdownStyle = require('!!css!stylus?sourceMap!./markdown.styl')[0][1] -const appPath = 'file://' + (process.env.NODE_ENV === 'production' - ? app.getAppPath() - : path.resolve()) +const appPath = fileUrl( + process.env.NODE_ENV === 'production' ? app.getAppPath() : path.resolve() +) +const CSS_FILES = [ + `${appPath}/node_modules/katex/dist/katex.min.css`, + `${appPath}/node_modules/codemirror/lib/codemirror.css` +] -function buildStyle (fontFamily, fontSize, codeBlockFontFamily, lineNumber) { +function buildStyle ( + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS +) { return ` @font-face { font-family: 'Lato'; @@ -44,10 +65,23 @@ function buildStyle (fontFamily, fontSize, codeBlockFontFamily, lineNumber) { font-weight: 700; text-rendering: optimizeLegibility; } +@font-face { + font-family: 'Material Icons'; + font-style: normal; + font-weight: 400; + src: local('Material Icons'), + local('MaterialIcons-Regular'), + url('${appPath}/resources/fonts/MaterialIcons-Regular.woff2') format('woff2'), + url('${appPath}/resources/fonts/MaterialIcons-Regular.woff') format('woff'), + url('${appPath}/resources/fonts/MaterialIcons-Regular.ttf') format('truetype'); +} +${allowCustomCSS ? customCSS : ''} ${markdownStyle} + body { font-family: '${fontFamily.join("','")}'; font-size: ${fontSize}px; + ${scrollPastEnd && 'padding-bottom: 90vh;'} } code { font-family: '${codeBlockFontFamily.join("','")}'; @@ -95,9 +129,38 @@ h2 { body p { white-space: normal; } + +@media print { + body[data-theme="${theme}"] { + color: #000; + background-color: #fff; + } + .clipboardButton { + display: none + } +} ` } +const scrollBarStyle = ` +::-webkit-scrollbar { + width: 12px; +} + +::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.15); +} +` +const scrollBarDarkStyle = ` +::-webkit-scrollbar { + width: 12px; +} + +::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.3); +} +` + const { shell } = require('electron') const OSX = global.process.platform === 'darwin' @@ -106,52 +169,65 @@ if (!OSX) { defaultFontFamily.unshift('Microsoft YaHei') defaultFontFamily.unshift('meiryo') } -const defaultCodeBlockFontFamily = ['Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', 'monospace'] - +const defaultCodeBlockFontFamily = [ + 'Monaco', + 'Menlo', + 'Ubuntu Mono', + 'Consolas', + 'source-code-pro', + 'monospace' +] export default class MarkdownPreview extends React.Component { constructor (props) { super(props) - this.contextMenuHandler = (e) => this.handleContextMenu(e) - this.mouseDownHandler = (e) => this.handleMouseDown(e) - this.mouseUpHandler = (e) => this.handleMouseUp(e) - this.anchorClickHandler = (e) => this.handlePreviewAnchorClick(e) - this.checkboxClickHandler = (e) => this.handleCheckboxClick(e) + this.contextMenuHandler = e => this.handleContextMenu(e) + this.mouseDownHandler = e => this.handleMouseDown(e) + this.mouseUpHandler = e => this.handleMouseUp(e) + this.DoubleClickHandler = e => this.handleDoubleClick(e) + this.scrollHandler = _.debounce(this.handleScroll.bind(this), 100, { + leading: false, + trailing: true + }) + this.checkboxClickHandler = e => this.handleCheckboxClick(e) this.saveAsTextHandler = () => this.handleSaveAsText() this.saveAsMdHandler = () => this.handleSaveAsMd() this.saveAsHtmlHandler = () => this.handleSaveAsHtml() this.printHandler = () => this.handlePrint() this.linkClickHandler = this.handlelinkClick.bind(this) + this.initMarkdown = this.initMarkdown.bind(this) + this.initMarkdown() } - handlePreviewAnchorClick (e) { - e.preventDefault() - e.stopPropagation() - - const anchor = e.target.closest('a') - const href = anchor.getAttribute('href') - if (_.isString(href) && href.match(/^#/)) { - const targetElement = this.refs.root.contentWindow.document.getElementById(href.substring(1, href.length)) - if (targetElement != null) { - this.getWindow().scrollTo(0, targetElement.offsetTop) - } - } else { - shell.openExternal(href) - } + initMarkdown () { + const { smartQuotes, sanitize, breaks } = this.props + this.markdown = new Markdown({ + typographer: smartQuotes, + sanitize, + breaks + }) } handleCheckboxClick (e) { this.props.onCheckboxClick(e) } + handleScroll (e) { + if (this.props.onScroll) { + this.props.onScroll(e) + } + } + handleContextMenu (e) { - if (!this.props.onContextMenu) return this.props.onContextMenu(e) } + handleDoubleClick (e) { + if (this.props.onDoubleClick != null) this.props.onDoubleClick(e) + } + handleMouseDown (e) { - if (!this.props.onMouseDown) return if (e.target != null) { switch (e.target.tagName) { case 'A': @@ -175,12 +251,97 @@ export default class MarkdownPreview extends React.Component { } handleSaveAsMd () { - this.exportAsDocument('md') + this.exportAsDocument('md', (noteContent, exportTasks) => { + let result = noteContent + if (this.props && this.props.storagePath && this.props.noteKey) { + const attachmentsAbsolutePaths = attachmentManagement.getAbsolutePathsOfAttachmentsInContent( + noteContent, + this.props.storagePath + ) + attachmentsAbsolutePaths.forEach(attachment => { + exportTasks.push({ + src: attachment, + dst: attachmentManagement.DESTINATION_FOLDER + }) + }) + result = attachmentManagement.removeStorageAndNoteReferences( + noteContent, + this.props.noteKey + ) + } + return result + }) } handleSaveAsHtml () { - this.exportAsDocument('html', (value) => { - return this.refs.root.contentWindow.document.documentElement.outerHTML + this.exportAsDocument('html', (noteContent, exportTasks) => { + const { + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + codeBlockTheme, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS + } = this.getStyleParams() + + const inlineStyles = buildStyle( + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS + ) + let body = this.markdown.render( + escapeHtmlCharacters(noteContent, { detectCodeBlock: true }) + ) + const files = [this.GetCodeThemeLink(codeBlockTheme), ...CSS_FILES] + const attachmentsAbsolutePaths = attachmentManagement.getAbsolutePathsOfAttachmentsInContent( + noteContent, + this.props.storagePath + ) + + files.forEach(file => { + if (global.process.platform === 'win32') { + file = file.replace('file:///', '') + } else { + file = file.replace('file://', '') + } + exportTasks.push({ + src: file, + dst: 'css' + }) + }) + attachmentsAbsolutePaths.forEach(attachment => { + exportTasks.push({ + src: attachment, + dst: attachmentManagement.DESTINATION_FOLDER + }) + }) + body = attachmentManagement.removeStorageAndNoteReferences( + body, + this.props.noteKey + ) + + let styles = '' + files.forEach(file => { + styles += `` + }) + + return ` + + + + + ${styles} + + ${body} + ` }) } @@ -188,53 +349,108 @@ export default class MarkdownPreview extends React.Component { this.refs.root.contentWindow.print() } - exportAsDocument (fileType, formatter) { + exportAsDocument (fileType, contentFormatter) { const options = { - filters: [ - { name: 'Documents', extensions: [fileType] } - ], + filters: [{ name: 'Documents', extensions: [fileType] }], properties: ['openFile', 'createDirectory'] } - const value = formatter ? formatter.call(this, this.props.value) : this.props.value - dialog.showSaveDialog(remote.getCurrentWindow(), options, - (filename) => { + dialog.showSaveDialog(remote.getCurrentWindow(), options, filename => { if (filename) { - fs.writeFile(filename, value, (err) => { - if (err) throw err - }) + const content = this.props.value + const storage = this.props.storagePath + + exportNote(storage, content, filename, contentFormatter) + .then(res => { + dialog.showMessageBox(remote.getCurrentWindow(), { + type: 'info', + message: `Exported to ${filename}` + }) + }) + .catch(err => { + dialog.showErrorBox( + 'Export error', + err ? err.message || err : 'Unexpected error during export' + ) + throw err + }) } }) } fixDecodedURI (node) { - if (node && node.children.length === 1 && typeof node.children[0] === 'string') { + if ( + node && + node.children.length === 1 && + typeof node.children[0] === 'string' + ) { const { innerText, href } = node - node.innerText = mdurl.decode(href) === innerText - ? href - : innerText + node.innerText = mdurl.decode(href) === innerText ? href : innerText + } + } + + getScrollBarStyle () { + const { theme } = this.props + + switch (theme) { + case 'dark': + case 'solarized-dark': + case 'monokai': + return scrollBarDarkStyle + default: + return scrollBarStyle } } componentDidMount () { this.refs.root.setAttribute('sandbox', 'allow-scripts') - this.refs.root.contentWindow.document.body.addEventListener('contextmenu', this.contextMenuHandler) + this.refs.root.contentWindow.document.body.addEventListener( + 'contextmenu', + this.contextMenuHandler + ) - this.refs.root.contentWindow.document.head.innerHTML = ` + let styles = ` - - + ` + + CSS_FILES.forEach(file => { + styles += `` + }) + + this.refs.root.contentWindow.document.head.innerHTML = styles this.rewriteIframe() this.applyStyle() - this.refs.root.contentWindow.document.addEventListener('mousedown', this.mouseDownHandler) - this.refs.root.contentWindow.document.addEventListener('mouseup', this.mouseUpHandler) - this.refs.root.contentWindow.document.addEventListener('drop', this.preventImageDroppedHandler) - this.refs.root.contentWindow.document.addEventListener('dragover', this.preventImageDroppedHandler) + this.refs.root.contentWindow.document.addEventListener( + 'mousedown', + this.mouseDownHandler + ) + this.refs.root.contentWindow.document.addEventListener( + 'mouseup', + this.mouseUpHandler + ) + this.refs.root.contentWindow.document.addEventListener( + 'dblclick', + this.DoubleClickHandler + ) + this.refs.root.contentWindow.document.addEventListener( + 'drop', + this.preventImageDroppedHandler + ) + this.refs.root.contentWindow.document.addEventListener( + 'dragover', + this.preventImageDroppedHandler + ) + this.refs.root.contentWindow.document.addEventListener( + 'scroll', + this.scrollHandler + ) eventEmitter.on('export:save-text', this.saveAsTextHandler) eventEmitter.on('export:save-md', this.saveAsMdHandler) eventEmitter.on('export:save-html', this.saveAsHtmlHandler) @@ -242,11 +458,34 @@ export default class MarkdownPreview extends React.Component { } componentWillUnmount () { - this.refs.root.contentWindow.document.body.removeEventListener('contextmenu', this.contextMenuHandler) - this.refs.root.contentWindow.document.removeEventListener('mousedown', this.mouseDownHandler) - this.refs.root.contentWindow.document.removeEventListener('mouseup', this.mouseUpHandler) - this.refs.root.contentWindow.document.removeEventListener('drop', this.preventImageDroppedHandler) - this.refs.root.contentWindow.document.removeEventListener('dragover', this.preventImageDroppedHandler) + this.refs.root.contentWindow.document.body.removeEventListener( + 'contextmenu', + this.contextMenuHandler + ) + this.refs.root.contentWindow.document.removeEventListener( + 'mousedown', + this.mouseDownHandler + ) + this.refs.root.contentWindow.document.removeEventListener( + 'mouseup', + this.mouseUpHandler + ) + this.refs.root.contentWindow.document.removeEventListener( + 'dblclick', + this.DoubleClickHandler + ) + this.refs.root.contentWindow.document.removeEventListener( + 'drop', + this.preventImageDroppedHandler + ) + this.refs.root.contentWindow.document.removeEventListener( + 'dragover', + this.preventImageDroppedHandler + ) + this.refs.root.contentWindow.document.removeEventListener( + 'scroll', + this.scrollHandler + ) eventEmitter.off('export:save-text', this.saveAsTextHandler) eventEmitter.off('export:save-md', this.saveAsMdHandler) eventEmitter.off('export:save-html', this.saveAsHtmlHandler) @@ -255,122 +494,195 @@ export default class MarkdownPreview extends React.Component { componentDidUpdate (prevProps) { if (prevProps.value !== this.props.value) this.rewriteIframe() - if (prevProps.fontFamily !== this.props.fontFamily || + if ( + prevProps.smartQuotes !== this.props.smartQuotes || + prevProps.sanitize !== this.props.sanitize || + prevProps.smartArrows !== this.props.smartArrows || + prevProps.breaks !== this.props.breaks + ) { + this.initMarkdown() + this.rewriteIframe() + } + if ( + prevProps.fontFamily !== this.props.fontFamily || prevProps.fontSize !== this.props.fontSize || prevProps.codeBlockFontFamily !== this.props.codeBlockFontFamily || prevProps.codeBlockTheme !== this.props.codeBlockTheme || prevProps.lineNumber !== this.props.lineNumber || prevProps.showCopyNotification !== this.props.showCopyNotification || - prevProps.theme !== this.props.theme) { + prevProps.theme !== this.props.theme || + prevProps.scrollPastEnd !== this.props.scrollPastEnd || + prevProps.allowCustomCSS !== this.props.allowCustomCSS || + prevProps.customCSS !== this.props.customCSS + ) { this.applyStyle() this.rewriteIframe() } } - applyStyle () { - const { fontSize, lineNumber, codeBlockTheme } = this.props + getStyleParams () { + const { + fontSize, + lineNumber, + codeBlockTheme, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS + } = this.props let { fontFamily, codeBlockFontFamily } = this.props fontFamily = _.isString(fontFamily) && fontFamily.trim().length > 0 - ? fontFamily.split(',').map(fontName => fontName.trim()).concat(defaultFontFamily) + ? fontFamily + .split(',') + .map(fontName => fontName.trim()) + .concat(defaultFontFamily) : defaultFontFamily - codeBlockFontFamily = _.isString(codeBlockFontFamily) && codeBlockFontFamily.trim().length > 0 - ? codeBlockFontFamily.split(',').map(fontName => fontName.trim()).concat(defaultCodeBlockFontFamily) + codeBlockFontFamily = _.isString(codeBlockFontFamily) && + codeBlockFontFamily.trim().length > 0 + ? codeBlockFontFamily + .split(',') + .map(fontName => fontName.trim()) + .concat(defaultCodeBlockFontFamily) : defaultCodeBlockFontFamily - this.setCodeTheme(codeBlockTheme) - this.getWindow().document.getElementById('style').innerHTML = buildStyle(fontFamily, fontSize, codeBlockFontFamily, lineNumber, codeBlockTheme, lineNumber) + return { + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + codeBlockTheme, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS + } } - setCodeTheme (theme) { - theme = consts.THEMES.some((_theme) => _theme === theme) && theme !== 'default' + applyStyle () { + const { + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + codeBlockTheme, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS + } = this.getStyleParams() + + this.getWindow().document.getElementById( + 'codeTheme' + ).href = this.GetCodeThemeLink(codeBlockTheme) + this.getWindow().document.getElementById('style').innerHTML = buildStyle( + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS + ) + } + + GetCodeThemeLink (theme) { + theme = consts.THEMES.some(_theme => _theme === theme) && + theme !== 'default' ? theme : 'elegant' - this.getWindow().document.getElementById('codeTheme').href = theme.startsWith('solarized') + return theme.startsWith('solarized') ? `${appPath}/node_modules/codemirror/theme/solarized.css` : `${appPath}/node_modules/codemirror/theme/${theme}.css` } rewriteIframe () { - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('a'), (el) => { - el.removeEventListener('click', this.anchorClickHandler) - }) - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('input[type="checkbox"]'), (el) => { - el.removeEventListener('click', this.checkboxClickHandler) - }) + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll( + 'input[type="checkbox"]' + ), + el => { + el.removeEventListener('click', this.checkboxClickHandler) + } + ) - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('a'), (el) => { - el.removeEventListener('click', this.linkClickHandler) - }) + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll('a'), + el => { + el.removeEventListener('click', this.linkClickHandler) + } + ) - const { theme, indentSize, showCopyNotification, storagePath } = this.props + const { + theme, + indentSize, + showCopyNotification, + storagePath, + noteKey + } = this.props let { value, codeBlockTheme } = this.props this.refs.root.contentWindow.document.body.setAttribute('data-theme', theme) + const renderedHTML = this.markdown.render(value) + attachmentManagement.migrateAttachments(value, storagePath, noteKey) + this.refs.root.contentWindow.document.body.innerHTML = attachmentManagement.fixLocalURLS( + renderedHTML, + storagePath + ) + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll( + 'input[type="checkbox"]' + ), + el => { + el.addEventListener('click', this.checkboxClickHandler) + } + ) - const codeBlocks = value.match(/(```)(.|[\n])*?(```)/g) - if (codeBlocks !== null) { - codeBlocks.forEach((codeBlock) => { - value = value.replace(codeBlock, htmlTextHelper.encodeEntities(codeBlock)) - }) - } - this.refs.root.contentWindow.document.body.innerHTML = markdown.render(value) + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll('a'), + el => { + this.fixDecodedURI(el) + el.addEventListener('click', this.linkClickHandler) + } + ) - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('.taskListItem'), (el) => { - el.parentNode.parentNode.style.listStyleType = 'none' - }) - - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('a'), (el) => { - this.fixDecodedURI(el) - el.addEventListener('click', this.anchorClickHandler) - }) - - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('input[type="checkbox"]'), (el) => { - el.addEventListener('click', this.checkboxClickHandler) - }) - - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('a'), (el) => { - el.addEventListener('click', this.linkClickHandler) - }) - - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('img'), (el) => { - el.src = markdown.normalizeLinkText(el.src) - if (!/\/:storage/.test(el.src)) return - el.src = `file:///${markdown.normalizeLinkText(path.join(storagePath, 'images', path.basename(el.src)))}` - }) - - codeBlockTheme = consts.THEMES.some((_theme) => _theme === codeBlockTheme) + codeBlockTheme = consts.THEMES.some(_theme => _theme === codeBlockTheme) ? codeBlockTheme : 'default' - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('.code code'), (el) => { - let syntax = CodeMirror.findModeByName(el.className) - if (syntax == null) syntax = CodeMirror.findModeByName('Plain Text') - CodeMirror.requireMode(syntax.mode, () => { - const content = htmlTextHelper.decodeEntities(el.innerHTML) - const copyIcon = document.createElement('i') - copyIcon.innerHTML = '' - copyIcon.onclick = (e) => { - copy(content) - if (showCopyNotification) { - this.notify('Saved to Clipboard!', { - body: 'Paste it wherever you want!', - silent: true - }) + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll('.code code'), + el => { + let syntax = CodeMirror.findModeByName(convertModeName(el.className)) + if (syntax == null) syntax = CodeMirror.findModeByName('Plain Text') + CodeMirror.requireMode(syntax.mode, () => { + const content = htmlTextHelper.decodeEntities(el.innerHTML) + const copyIcon = document.createElement('i') + copyIcon.innerHTML = + '' + copyIcon.onclick = e => { + copy(content) + if (showCopyNotification) { + this.notify('Saved to Clipboard!', { + body: 'Paste it wherever you want!', + silent: true + }) + } } - } - el.parentNode.appendChild(copyIcon) - el.innerHTML = '' - if (codeBlockTheme.indexOf('solarized') === 0) { - const [refThema, color] = codeBlockTheme.split(' ') - el.parentNode.className += ` cm-s-${refThema} cm-s-${color} CodeMirror` - } else { - el.parentNode.className += ` cm-s-${codeBlockTheme} CodeMirror` - } - CodeMirror.runMode(content, syntax.mime, el, { - tabSize: indentSize + el.parentNode.appendChild(copyIcon) + el.innerHTML = '' + if (codeBlockTheme.indexOf('solarized') === 0) { + const [refThema, color] = codeBlockTheme.split(' ') + el.parentNode.className += ` cm-s-${refThema} cm-s-${color}` + } else { + el.parentNode.className += ` cm-s-${codeBlockTheme}` + } + CodeMirror.runMode(content, syntax.mime, el, { + tabSize: indentSize + }) }) - }) - }) + } + ) const opts = {} // if (this.props.theme === 'dark') { // opts['font-color'] = '#DDD' @@ -378,37 +690,71 @@ export default class MarkdownPreview extends React.Component { // opts['element-color'] = '#DDD' // opts['fill'] = '#3A404C' // } - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('.flowchart'), (el) => { - Raphael.setWindow(this.getWindow()) - try { - const diagram = flowchart.parse(htmlTextHelper.decodeEntities(el.innerHTML)) - el.innerHTML = '' - diagram.drawSVG(el, opts) - _.forEach(el.querySelectorAll('a'), (el) => { - el.addEventListener('click', this.anchorClickHandler) - }) - } catch (e) { - console.error(e) - el.className = 'flowchart-error' - el.innerHTML = 'Flowchart parse error: ' + e.message + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll('.flowchart'), + el => { + Raphael.setWindow(this.getWindow()) + try { + const diagram = flowchart.parse( + htmlTextHelper.decodeEntities(el.innerHTML) + ) + el.innerHTML = '' + diagram.drawSVG(el, opts) + _.forEach(el.querySelectorAll('a'), el => { + el.addEventListener('click', this.linkClickHandler) + }) + } catch (e) { + console.error(e) + el.className = 'flowchart-error' + el.innerHTML = 'Flowchart parse error: ' + e.message + } } - }) + ) - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('.sequence'), (el) => { - Raphael.setWindow(this.getWindow()) - try { - const diagram = SequenceDiagram.parse(htmlTextHelper.decodeEntities(el.innerHTML)) - el.innerHTML = '' - diagram.drawSVG(el, {theme: 'simple'}) - _.forEach(el.querySelectorAll('a'), (el) => { - el.addEventListener('click', this.anchorClickHandler) - }) - } catch (e) { - console.error(e) - el.className = 'sequence-error' - el.innerHTML = 'Sequence diagram parse error: ' + e.message + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll('.sequence'), + el => { + Raphael.setWindow(this.getWindow()) + try { + const diagram = SequenceDiagram.parse( + htmlTextHelper.decodeEntities(el.innerHTML) + ) + el.innerHTML = '' + diagram.drawSVG(el, { theme: 'simple' }) + _.forEach(el.querySelectorAll('a'), el => { + el.addEventListener('click', this.linkClickHandler) + }) + } catch (e) { + console.error(e) + el.className = 'sequence-error' + el.innerHTML = 'Sequence diagram parse error: ' + e.message + } } - }) + ) + + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll('.chart'), + el => { + try { + const chartConfig = JSON.parse(el.innerHTML) + el.innerHTML = '' + var canvas = document.createElement('canvas') + el.appendChild(canvas) + /* eslint-disable no-new */ + new Chart(canvas, chartConfig) + } catch (e) { + console.error(e) + el.className = 'chart-error' + el.innerHTML = 'chartjs diagram parse error: ' + e.message + } + } + ) + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll('.mermaid'), + el => { + mermaidRender(el, htmlTextHelper.decodeEntities(el.innerHTML), theme) + } + ) } focus () { @@ -420,7 +766,9 @@ export default class MarkdownPreview extends React.Component { } scrollTo (targetRow) { - const blocks = this.getWindow().document.querySelectorAll('body>[data-line]') + const blocks = this.getWindow().document.querySelectorAll( + 'body>[data-line]' + ) for (let index = 0; index < blocks.length; index++) { let block = blocks[index] @@ -440,25 +788,64 @@ export default class MarkdownPreview extends React.Component { notify (title, options) { if (global.process.platform === 'win32') { - options.icon = path.join('file://', global.__dirname, '../../resources/app.png') + options.icon = path.join( + 'file://', + global.__dirname, + '../../resources/app.png' + ) } return new window.Notification(title, options) } handlelinkClick (e) { - const noteHash = e.target.href.split('/').pop() - const regexIsNoteLink = /^(.{20})-(.{20})$/ - if (regexIsNoteLink.test(noteHash)) { - eventEmitter.emit('list:jump', noteHash) + e.preventDefault() + e.stopPropagation() + + const href = e.target.href + const linkHash = href.split('/').pop() + + const regexNoteInternalLink = /main.html#(.+)/ + if (regexNoteInternalLink.test(linkHash)) { + const targetId = mdurl.encode(linkHash.match(regexNoteInternalLink)[1]) + const targetElement = this.refs.root.contentWindow.document.getElementById( + targetId + ) + + if (targetElement != null) { + this.getWindow().scrollTo(0, targetElement.offsetTop) + } + return } + + // this will match the new uuid v4 hash and the old hash + // e.g. + // :note:1c211eb7dcb463de6490 and + // :note:7dd23275-f2b4-49cb-9e93-3454daf1af9c + const regexIsNoteLink = /^:note:([a-zA-Z0-9-]{20,36})$/ + if (regexIsNoteLink.test(linkHash)) { + eventEmitter.emit('list:jump', linkHash.replace(':note:', '')) + return + } + + // this will match the old link format storage.key-note.key + // e.g. + // 877f99c3268608328037-1c211eb7dcb463de6490 + const regexIsLegacyNoteLink = /^(.{20})-(.{20})$/ + if (regexIsLegacyNoteLink.test(linkHash)) { + eventEmitter.emit('list:jump', linkHash.split('-')[1]) + return + } + + // other case + shell.openExternal(href) } render () { const { className, style, tabIndex } = this.props return ( - \n\n## Docs :memo:\n- [Boostnote | Boost your happiness, productivity and creativity.](https://hackernoon.com/boostnote-boost-your-happiness-productivity-and-creativity-315034efeebe)\n- [Cloud Syncing & Backups](https://github.com/BoostIO/Boostnote/wiki/Cloud-Syncing-and-Backup)\n- [How to sync your data across Desktop and Mobile apps](https://github.com/BoostIO/Boostnote/wiki/Sync-Data-Across-Desktop-and-Mobile-apps)\n- [Convert data from **Evernote** to Boostnote.](https://github.com/BoostIO/Boostnote/wiki/Evernote)\n- [Keyboard Shortcuts](https://github.com/BoostIO/Boostnote/wiki/Keyboard-Shortcuts)\n- [Keymaps in Editor mode](https://github.com/BoostIO/Boostnote/wiki/Keymaps-in-Editor-mode)\n- [How to set syntax highlight in Snippet note](https://github.com/BoostIO/Boostnote/wiki/Syntax-Highlighting)\n\n---\n\n## Article Archive :books:\n- [Reddit English](http://bit.ly/2mOJPu7)\n- [Reddit Spanish](https://www.reddit.com/r/boostnote_es/)\n- [Reddit Chinese](https://www.reddit.com/r/boostnote_cn/)\n- [Reddit Japanese](https://www.reddit.com/r/boostnote_jp/)\n\n---\n\n## Community :beers:\n- [GitHub](http://bit.ly/2AWWzkD)\n- [Twitter](http://bit.ly/2z8BUJZ)\n- [Facebook Group](http://bit.ly/2jcca8t)' }) - .then((note) => { + .then(note => { store.dispatch({ type: 'UPDATE_NOTE', note: note @@ -128,10 +130,10 @@ class Main extends React.Component { .then(defaultMarkdownNote) .then(() => data.storage) }) - .then((storage) => { + .then(storage => { hashHistory.push('/storages/' + storage.key) }) - .catch((err) => { + .catch(err => { throw err }) } @@ -139,30 +141,33 @@ class Main extends React.Component { componentDidMount () { const { dispatch, config } = this.props - if (config.ui.theme === 'dark') { - document.body.setAttribute('data-theme', 'dark') - } else if (config.ui.theme === 'white') { - document.body.setAttribute('data-theme', 'white') - } else if (config.ui.theme === 'solarized-dark') { - document.body.setAttribute('data-theme', 'solarized-dark') + const supportedThemes = ['dark', 'white', 'solarized-dark', 'monokai'] + + if (supportedThemes.indexOf(config.ui.theme) !== -1) { + document.body.setAttribute('data-theme', config.ui.theme) } else { document.body.setAttribute('data-theme', 'default') } + if (getLocales().indexOf(config.ui.language) !== -1) { + i18n.setLocale(config.ui.language) + } else { + i18n.setLocale('en') + } + applyShortcuts() // Reload all data - dataApi.init() - .then((data) => { - dispatch({ - type: 'INIT_ALL', - storages: data.storages, - notes: data.notes - }) - - if (data.storages.length < 1) { - this.init() - } + dataApi.init().then(data => { + dispatch({ + type: 'INIT_ALL', + storages: data.storages, + notes: data.notes }) + if (data.storages.length < 1) { + this.init() + } + }) + eventEmitter.on('editor:fullscreen', this.toggleFullScreen) } @@ -187,34 +192,40 @@ class Main extends React.Component { handleMouseUp (e) { // Change width of NoteList component. if (this.state.isRightSliderFocused) { - this.setState({ - isRightSliderFocused: false - }, () => { - const { dispatch } = this.props - const newListWidth = this.state.listWidth - // TODO: ConfigManager should dispatch itself. - ConfigManager.set({listWidth: newListWidth}) - dispatch({ - type: 'SET_LIST_WIDTH', - listWidth: newListWidth - }) - }) + this.setState( + { + isRightSliderFocused: false + }, + () => { + const { dispatch } = this.props + const newListWidth = this.state.listWidth + // TODO: ConfigManager should dispatch itself. + ConfigManager.set({ listWidth: newListWidth }) + dispatch({ + type: 'SET_LIST_WIDTH', + listWidth: newListWidth + }) + } + ) } // Change width of SideNav component. if (this.state.isLeftSliderFocused) { - this.setState({ - isLeftSliderFocused: false - }, () => { - const { dispatch } = this.props - const navWidth = this.state.navWidth - // TODO: ConfigManager should dispatch itself. - ConfigManager.set({ navWidth }) - dispatch({ - type: 'SET_NAV_WIDTH', - navWidth - }) - }) + this.setState( + { + isLeftSliderFocused: false + }, + () => { + const { dispatch } = this.props + const navWidth = this.state.navWidth + // TODO: ConfigManager should dispatch itself. + ConfigManager.set({ navWidth }) + dispatch({ + type: 'SET_NAV_WIDTH', + navWidth + }) + } + ) } } @@ -259,8 +270,8 @@ class Main extends React.Component { } hideLeftLists (noteDetail, noteList, mainBody) { - this.setState({noteDetailWidth: noteDetail.style.left}) - this.setState({mainBodyWidth: mainBody.style.left}) + this.setState({ noteDetailWidth: noteDetail.style.left }) + this.setState({ mainBodyWidth: mainBody.style.left }) noteDetail.style.left = '0px' mainBody.style.left = '0px' noteList.style.display = 'none' @@ -282,33 +293,36 @@ class Main extends React.Component {
this.handleMouseMove(e)} - onMouseUp={(e) => this.handleMouseUp(e)} + onMouseMove={e => this.handleMouseMove(e)} + onMouseUp={e => this.handleMouseUp(e)} > {!config.isSideNavFolded && -
this.handleLeftSlideMouseDown(e)} +
this.handleLeftSlideMouseDown(e)} draggable='false' >
-
- } -
} +
- - -
this.handleRightSlideMouseDown(e)} +
this.handleRightSlideMouseDown(e)} draggable='false' >
x)(CSSModules(Main, styles)) +export default connect(x => x)(CSSModules(Main, styles)) diff --git a/browser/main/NewNoteButton/NewNoteButton.styl b/browser/main/NewNoteButton/NewNoteButton.styl index 81ff7e8d..e8e4b5f0 100644 --- a/browser/main/NewNoteButton/NewNoteButton.styl +++ b/browser/main/NewNoteButton/NewNoteButton.styl @@ -74,4 +74,8 @@ body[data-theme="dark"] body[data-theme="solarized-dark"] .root, .root--expanded - background-color $ui-solarized-dark-noteList-backgroundColor \ No newline at end of file + background-color $ui-solarized-dark-noteList-backgroundColor + +body[data-theme="monokai"] + .root, .root--expanded + background-color $ui-monokai-noteList-backgroundColor diff --git a/browser/main/NewNoteButton/index.js b/browser/main/NewNoteButton/index.js index 633433df..85dc7f40 100644 --- a/browser/main/NewNoteButton/index.js +++ b/browser/main/NewNoteButton/index.js @@ -6,6 +6,7 @@ import _ from 'lodash' import modal from 'browser/main/lib/modal' import NewNoteModal from 'browser/main/modals/NewNoteModal' import eventEmitter from 'browser/main/lib/eventEmitter' +import i18n from 'browser/lib/i18n' const { remote } = require('electron') const { dialog } = remote @@ -33,14 +34,15 @@ class NewNoteButton extends React.Component { } handleNewNoteButtonClick (e) { - const { location, dispatch } = this.props + const { location, dispatch, config } = this.props const { storage, folder } = this.resolveTargetFolder() modal.open(NewNoteModal, { storage: storage.key, folder: folder.key, dispatch, - location + location, + config }) } @@ -56,9 +58,9 @@ class NewNoteButton extends React.Component { } } - if (storage == null) this.showMessageBox('No storage to create a note') + if (storage == null) this.showMessageBox(i18n.__('No storage to create a note')) const folder = _.find(storage.folders, {key: params.folderKey}) || storage.folders[0] - if (folder == null) this.showMessageBox('No folder to create a note') + if (folder == null) this.showMessageBox(i18n.__('No folder to create a note')) return { storage, @@ -86,7 +88,7 @@ class NewNoteButton extends React.Component { onClick={(e) => this.handleNewNoteButtonClick(e)}> - Make a note {OSX ? '⌘' : 'Ctrl'} + N + {i18n.__('Make a note')} {OSX ? '⌘' : i18n.__('Ctrl')} + N
diff --git a/browser/main/NoteList/NoteList.styl b/browser/main/NoteList/NoteList.styl index 312f5143..ea261208 100644 --- a/browser/main/NoteList/NoteList.styl +++ b/browser/main/NoteList/NoteList.styl @@ -113,4 +113,28 @@ body[data-theme="solarized-dark"] .control-button--active color $ui-solarized-dark-text-color &:active - color $ui-solarized-dark-text-color \ No newline at end of file + color $ui-solarized-dark-text-color + +body[data-theme="monokai"] + .root + border-color $ui-monokai-borderColor + background-color $ui-monokai-noteList-backgroundColor + + .control + background-color $ui-monokai-noteList-backgroundColor + border-color $ui-monokai-borderColor + + .control-sortBy-select + &:hover + transition 0.2s + color $ui-monokai-text-color + + .control-button + color $ui-monokai-inactive-text-color + &:hover + color $ui-monokai-text-color + + .control-button--active + color $ui-monokai-text-color + &:active + color $ui-monokai-text-color diff --git a/browser/main/NoteList/index.js b/browser/main/NoteList/index.js index 8adaf1a0..f7dd0764 100644 --- a/browser/main/NoteList/index.js +++ b/browser/main/NoteList/index.js @@ -1,11 +1,14 @@ +/* global electron */ import PropTypes from 'prop-types' import React from 'react' import CSSModules from 'browser/lib/CSSModules' +import debounceRender from 'react-debounce-render' import styles from './NoteList.styl' import moment from 'moment' import _ from 'lodash' import ee from 'browser/main/lib/eventEmitter' import dataApi from 'browser/main/lib/dataApi' +import attachmentManagement from 'browser/main/lib/dataApi/attachmentManagement' import ConfigManager from 'browser/main/lib/ConfigManager' import NoteItem from 'browser/components/NoteItem' import NoteItemSimple from 'browser/components/NoteItemSimple' @@ -13,10 +16,16 @@ import searchFromNotes from 'browser/lib/search' import fs from 'fs' import path from 'path' import { hashHistory } from 'react-router' +import copy from 'copy-to-clipboard' import AwsMobileAnalyticsConfig from 'browser/main/lib/AwsMobileAnalyticsConfig' +import Markdown from '../../lib/markdown' +import i18n from 'browser/lib/i18n' +import { confirmDeleteNote } from 'browser/lib/confirmDeleteNote' +import context from 'browser/lib/context' const { remote } = require('electron') -const { Menu, MenuItem, dialog } = remote +const { dialog } = remote +const WP_POST_PATH = '/wp/v2/posts' function sortByCreatedAt (a, b) { return new Date(b.createdAt) - new Date(a.createdAt) @@ -31,7 +40,7 @@ function sortByUpdatedAt (a, b) { } function findNoteByKey (notes, noteKey) { - return notes.find((note) => `${note.storage}-${note.key}` === noteKey) + return notes.find((note) => note.key === noteKey) } function findNotesByKeys (notes, noteKeys) { @@ -39,7 +48,7 @@ function findNotesByKeys (notes, noteKeys) { } function getNoteKey (note) { - return `${note.storage}-${note.key}` + return note.key } class NoteList extends React.Component { @@ -66,6 +75,11 @@ class NoteList extends React.Component { this.deleteNote = this.deleteNote.bind(this) this.focusNote = this.focusNote.bind(this) this.pinToTop = this.pinToTop.bind(this) + this.getNoteStorage = this.getNoteStorage.bind(this) + this.getNoteFolder = this.getNoteFolder.bind(this) + this.getViewType = this.getViewType.bind(this) + this.restoreNote = this.restoreNote.bind(this) + this.copyNoteLink = this.copyNoteLink.bind(this) // TODO: not Selected noteKeys but SelectedNote(for reusing) this.state = { @@ -109,14 +123,27 @@ class NoteList extends React.Component { componentDidUpdate (prevProps) { const { location } = this.props + const { selectedNoteKeys } = this.state + const visibleNoteKeys = this.notes.map(note => note.key) + const note = this.notes[0] + const prevKey = prevProps.location.query.key + const noteKey = visibleNoteKeys.includes(prevKey) ? prevKey : note && note.key - if (this.notes.length > 0 && location.query.key == null) { + if (note && location.query.key == null) { const { router } = this.context if (!location.pathname.match(/\/searched/)) this.contextNotes = this.getContextNotes() + + // A visible note is an active note + if (!selectedNoteKeys.includes(noteKey)) { + if (selectedNoteKeys.length === 1) selectedNoteKeys.pop() + selectedNoteKeys.push(noteKey) + ee.emit('list:moved') + } + router.replace({ pathname: location.pathname, query: { - key: this.notes[0].storage + '-' + this.notes[0].key + key: noteKey } }) return @@ -169,9 +196,8 @@ class NoteList extends React.Component { if (this.notes == null || this.notes.length === 0) { return } - let { router } = this.context - let { location } = this.props - let { selectedNoteKeys, shiftKeyDown } = this.state + let { selectedNoteKeys } = this.state + const { shiftKeyDown } = this.state let targetIndex = this.getTargetIndex() @@ -197,9 +223,8 @@ class NoteList extends React.Component { if (this.notes == null || this.notes.length === 0) { return } - let { router } = this.context - let { location } = this.props - let { selectedNoteKeys, shiftKeyDown } = this.state + let { selectedNoteKeys } = this.state + const { shiftKeyDown } = this.state let targetIndex = this.getTargetIndex() const isTargetLastNote = targetIndex === this.notes.length - 1 @@ -240,30 +265,40 @@ class NoteList extends React.Component { } handleNoteListKeyDown (e) { - const { shiftKeyDown } = this.state if (e.metaKey || e.ctrlKey) return true + // A key if (e.keyCode === 65 && !e.shiftKey) { e.preventDefault() ee.emit('top:new-note') } + // D key if (e.keyCode === 68) { e.preventDefault() this.deleteNote() } + // E key if (e.keyCode === 69) { e.preventDefault() ee.emit('detail:focus') } - if (e.keyCode === 38) { + // L or S key + if (e.keyCode === 76 || e.keyCode === 83) { + e.preventDefault() + ee.emit('top:focus-search') + } + + // UP or K key + if (e.keyCode === 38 || e.keyCode === 75) { e.preventDefault() this.selectPriorNote() } - if (e.keyCode === 40) { + // DOWN or J key + if (e.keyCode === 40 || e.keyCode === 74) { e.preventDefault() this.selectNextNote() } @@ -282,7 +317,7 @@ class NoteList extends React.Component { getNotes () { const { data, params, location } = this.props - if (location.pathname.match(/\/home/) || location.pathname.match(/\alltags/)) { + if (location.pathname.match(/\/home/) || location.pathname.match(/alltags/)) { const allNotes = data.noteMap.map((note) => note) this.contextNotes = allNotes return allNotes @@ -295,8 +330,10 @@ class NoteList extends React.Component { } if (location.pathname.match(/\/searched/)) { - const searchInputText = document.getElementsByClassName('searchInput')[0].value - if (searchInputText === '') { + const searchInputText = params.searchword + const allNotes = data.noteMap.map((note) => note) + this.contextNotes = allNotes + if (searchInputText === undefined || searchInputText === '') { return this.sortByPin(this.contextNotes) } return searchFromNotes(this.contextNotes, searchInputText) @@ -309,11 +346,10 @@ class NoteList extends React.Component { } if (location.pathname.match(/\/tags/)) { + const listOfTags = params.tagname.split(' ') return data.noteMap.map(note => { return note - }).filter(note => { - return note.tags.includes(params.tagname) - }) + }).filter(note => listOfTags.every(tag => note.tags.includes(tag))) } return this.getContextNotes() @@ -353,9 +389,10 @@ class NoteList extends React.Component { } handleNoteClick (e, uniqueKey) { - let { router } = this.context - let { location } = this.props - let { shiftKeyDown, selectedNoteKeys } = this.state + const { router } = this.context + const { location } = this.props + let { selectedNoteKeys } = this.state + const { shiftKeyDown } = this.state if (shiftKeyDown && selectedNoteKeys.includes(uniqueKey)) { const newSelectedNoteKeys = selectedNoteKeys.filter((noteKey) => noteKey !== uniqueKey) @@ -381,10 +418,10 @@ class NoteList extends React.Component { } handleSortByChange (e) { - const { dispatch } = this.props + const { dispatch, params: { folderKey } } = this.props const config = { - sortBy: e.target.value + [folderKey]: { sortBy: e.target.value } } ConfigManager.set(config) @@ -413,20 +450,27 @@ class NoteList extends React.Component { if (this.notes[targetIndex].type === 'SNIPPET_NOTE') { dialog.showMessageBox(remote.getCurrentWindow(), { type: 'warning', - message: 'Sorry!', - detail: 'md/text import is available only a markdown note.', - buttons: ['OK', 'Cancel'] + message: i18n.__('Sorry!'), + detail: i18n.__('md/text import is available only a markdown note.'), + buttons: [i18n.__('OK'), i18n.__('Cancel')] }) } } handleDragStart (e, note) { - const { selectedNoteKeys } = this.state + let { selectedNoteKeys } = this.state + const noteKey = getNoteKey(note) + + if (!selectedNoteKeys.includes(noteKey)) { + selectedNoteKeys = [] + selectedNoteKeys.push(noteKey) + } + const notes = this.notes.map((note) => Object.assign({}, note)) const selectedNotes = findNotesByKeys(notes, selectedNoteKeys) const noteData = JSON.stringify(selectedNotes) e.dataTransfer.setData('note', noteData) - this.setState({ selectedNoteKeys: [] }) + this.selectNextNote() } handleNoteContextMenu (e, uniqueKey) { @@ -439,45 +483,106 @@ class NoteList extends React.Component { this.handleNoteClick(e, uniqueKey) } - const pinLabel = note.isPinned ? 'Remove pin' : 'Pin to Top' - const deleteLabel = 'Delete Note' + const pinLabel = note.isPinned ? i18n.__('Remove pin') : i18n.__('Pin to Top') + const deleteLabel = i18n.__('Delete Note') + const cloneNote = i18n.__('Clone Note') + const restoreNote = i18n.__('Restore Note') + const copyNoteLink = i18n.__('Copy Note Link') + const publishLabel = i18n.__('Publish Blog') + const updateLabel = i18n.__('Update Blog') + const openBlogLabel = i18n.__('Open Blog') - const menu = new Menu() - if (!location.pathname.match(/\/home|\/starred|\/trash/)) { - menu.append(new MenuItem({ - label: pinLabel, - click: this.pinToTop - })) + const templates = [] + + if (location.pathname.match(/\/trash/)) { + templates.push({ + label: restoreNote, + click: this.restoreNote + }, { + label: deleteLabel, + click: this.deleteNote + }) + } else { + if (!location.pathname.match(/\/starred/)) { + templates.push({ + label: pinLabel, + click: this.pinToTop + }) + } + templates.push({ + label: deleteLabel, + click: this.deleteNote + }, { + label: cloneNote, + click: this.cloneNote.bind(this) + }, { + label: copyNoteLink, + click: this.copyNoteLink(note) + }) + if (note.type === 'MARKDOWN_NOTE') { + if (note.blog && note.blog.blogLink && note.blog.blogId) { + templates.push({ + label: updateLabel, + click: this.publishMarkdown.bind(this) + }, { + label: openBlogLabel, + click: () => this.openBlog.bind(this)(note) + }) + } else { + templates.push({ + label: publishLabel, + click: this.publishMarkdown.bind(this) + }) + } + } } - menu.append(new MenuItem({ - label: deleteLabel, - click: this.deleteNote - })) - menu.popup() + context.popup(templates) } - pinToTop () { + updateSelectedNotes (updateFunc, cleanSelection = true) { const { selectedNoteKeys } = this.state const { dispatch } = this.props const notes = this.notes.map((note) => Object.assign({}, note)) const selectedNotes = findNotesByKeys(notes, selectedNoteKeys) + if (!_.isFunction(updateFunc)) { + console.warn('Update function is not defined. No update will happen') + updateFunc = (note) => { return note } + } + Promise.all( - selectedNotes.map((note) => { - note.isPinned = !note.isPinned - return dataApi - .updateNote(note.storage, note.key, note) - }) - ) - .then((updatedNotes) => { - updatedNotes.forEach((note) => { - dispatch({ - type: 'UPDATE_NOTE', - note + selectedNotes.map((note) => { + note = updateFunc(note) + return dataApi + .updateNote(note.storage, note.key, note) }) - }) + ) + .then((updatedNotes) => { + updatedNotes.forEach((note) => { + dispatch({ + type: 'UPDATE_NOTE', + note + }) + }) + }) + + if (cleanSelection) { + this.selectNextNote() + } + } + + pinToTop () { + this.updateSelectedNotes((note) => { + note.isPinned = !note.isPinned + return note + }) + } + + restoreNote () { + this.updateSelectedNotes((note) => { + note.isTrashed = false + return note }) - this.setState({ selectedNoteKeys: [] }) } deleteNote () { @@ -486,22 +591,15 @@ class NoteList extends React.Component { const notes = this.notes.map((note) => Object.assign({}, note)) const selectedNotes = findNotesByKeys(notes, selectedNoteKeys) const firstNote = selectedNotes[0] + const { confirmDeletion } = this.props.config.ui if (firstNote.isTrashed) { - const noteExp = selectedNotes.length > 1 ? 'notes' : 'note' - const dialogueButtonIndex = dialog.showMessageBox(remote.getCurrentWindow(), { - type: 'warning', - message: 'Confirm note deletion', - detail: `This will permanently remove ${selectedNotes.length} ${noteExp}.`, - buttons: ['Confirm', 'Cancel'] - }) - if (dialogueButtonIndex === 1) return + if (!confirmDeleteNote(confirmDeletion, true)) return + Promise.all( - selectedNoteKeys.map((uniqueKey) => { - const storageKey = uniqueKey.split('-')[0] - const noteKey = uniqueKey.split('-')[1] + selectedNotes.map((note) => { return dataApi - .deleteNote(storageKey, noteKey) + .deleteNote(note.storage, note.key) }) ) .then((data) => { @@ -518,6 +616,8 @@ class NoteList extends React.Component { }) console.log('Notes were all deleted') } else { + if (!confirmDeleteNote(confirmDeletion, false)) return + Promise.all( selectedNotes.map((note) => { note.isTrashed = true @@ -543,6 +643,157 @@ class NoteList extends React.Component { this.setState({ selectedNoteKeys: [] }) } + cloneNote () { + const { selectedNoteKeys } = this.state + const { dispatch, location } = this.props + const { storage, folder } = this.resolveTargetFolder() + const notes = this.notes.map((note) => Object.assign({}, note)) + const selectedNotes = findNotesByKeys(notes, selectedNoteKeys) + const firstNote = selectedNotes[0] + const eventName = firstNote.type === 'MARKDOWN_NOTE' ? 'ADD_MARKDOWN' : 'ADD_SNIPPET' + + AwsMobileAnalyticsConfig.recordDynamicCustomEvent(eventName) + AwsMobileAnalyticsConfig.recordDynamicCustomEvent('ADD_ALLNOTE') + dataApi + .createNote(storage.key, { + type: firstNote.type, + folder: folder.key, + title: firstNote.title + ' ' + i18n.__('copy'), + content: firstNote.content + }) + .then((note) => { + attachmentManagement.cloneAttachments(firstNote, note) + return note + }) + .then((note) => { + dispatch({ + type: 'UPDATE_NOTE', + note: note + }) + + this.setState({ + selectedNoteKeys: [note.key] + }) + + hashHistory.push({ + pathname: location.pathname, + query: {key: note.key} + }) + }) + } + + copyNoteLink (note) { + const noteLink = `[${note.title}](:note:${note.key})` + return copy(noteLink) + } + + save (note) { + const { dispatch } = this.props + dataApi + .updateNote(note.storage, note.key, note) + .then((note) => { + dispatch({ + type: 'UPDATE_NOTE', + note: note + }) + }) + } + + publishMarkdown () { + if (this.pendingPublish) { + clearTimeout(this.pendingPublish) + } + this.pendingPublish = setTimeout(() => { + this.publishMarkdownNow() + }, 1000) + } + + publishMarkdownNow () { + const {selectedNoteKeys} = this.state + const notes = this.notes.map((note) => Object.assign({}, note)) + const selectedNotes = findNotesByKeys(notes, selectedNoteKeys) + const firstNote = selectedNotes[0] + const config = ConfigManager.get() + const {address, token, authMethod, username, password} = config.blog + let authToken = '' + if (authMethod === 'USER') { + authToken = `Basic ${window.btoa(`${username}:${password}`)}` + } else { + authToken = `Bearer ${token}` + } + const contentToRender = firstNote.content.replace(`# ${firstNote.title}`, '') + const markdown = new Markdown() + const data = { + title: firstNote.title, + content: markdown.render(contentToRender), + status: 'publish' + } + + let url = '' + let method = '' + if (firstNote.blog && firstNote.blog.blogId) { + url = `${address}${WP_POST_PATH}/${firstNote.blog.blogId}` + method = 'PUT' + } else { + url = `${address}${WP_POST_PATH}` + method = 'POST' + } + // eslint-disable-next-line no-undef + fetch(url, { + method: method, + body: JSON.stringify(data), + headers: { + 'Authorization': authToken, + 'Content-Type': 'application/json' + } + }).then(res => res.json()) + .then(response => { + if (_.isNil(response.link) || _.isNil(response.id)) { + return Promise.reject() + } + firstNote.blog = { + blogLink: response.link, + blogId: response.id + } + this.save(firstNote) + this.confirmPublish(firstNote) + }) + .catch((error) => { + console.error(error) + this.confirmPublishError() + }) + } + + confirmPublishError () { + const { remote } = electron + const { dialog } = remote + const alertError = { + type: 'warning', + message: i18n.__('Publish Failed'), + detail: i18n.__('Check and update your blog setting and try again.'), + buttons: [i18n.__('Confirm')] + } + dialog.showMessageBox(remote.getCurrentWindow(), alertError) + } + + confirmPublish (note) { + const buttonIndex = dialog.showMessageBox(remote.getCurrentWindow(), { + type: 'warning', + message: i18n.__('Publish Succeeded'), + detail: `${note.title} is published at ${note.blog.blogLink}`, + buttons: [i18n.__('Confirm'), i18n.__('Open Blog')] + }) + + if (buttonIndex === 1) { + this.openBlog(note) + } + } + + openBlog (note) { + const { shell } = electron + shell.openExternal(note.blog.blogLink) + } + importFromFile () { const options = { filters: [ @@ -635,19 +886,39 @@ class NoteList extends React.Component { dialog.showMessageBox(remote.getCurrentWindow(), { type: 'warning', message: message, - buttons: ['OK'] + buttons: [i18n.__('OK')] }) } + getNoteStorage (note) { // note.storage = storage key + return this.props.data.storageMap.toJS()[note.storage] + } + + getNoteFolder (note) { // note.folder = folder key + return _.find(this.getNoteStorage(note).folders, ({ key }) => key === note.folder) + } + + getViewType () { + const { pathname } = this.props.location + const folder = /\/folders\/[a-zA-Z0-9]+/.test(pathname) + const storage = /\/storages\/[a-zA-Z0-9]+/.test(pathname) && !folder + const allNotes = pathname === '/home' + if (allNotes) return 'ALL' + if (folder) return 'FOLDER' + if (storage) return 'STORAGE' + } + render () { - let { location, notes, config, dispatch } = this.props - let { selectedNoteKeys } = this.state - let sortFunc = config.sortBy === 'CREATED_AT' + const { location, config, params: { folderKey } } = this.props + let { notes } = this.props + const { selectedNoteKeys } = this.state + const sortBy = _.get(config, [folderKey, 'sortBy'], config.sortBy.default) + const sortFunc = sortBy === 'CREATED_AT' ? sortByCreatedAt - : config.sortBy === 'ALPHABETICAL' + : sortBy === 'ALPHABETICAL' ? sortByAlphabetical : sortByUpdatedAt - const sortedNotes = location.pathname.match(/\/home|\/starred|\/trash/) + const sortedNotes = location.pathname.match(/\/starred|\/trash/) ? this.getNotes().sort(sortFunc) : this.sortByPin(this.getNotes().sort(sortFunc)) this.notes = notes = sortedNotes.filter((note) => { @@ -655,7 +926,7 @@ class NoteList extends React.Component { if (note.isTrashed !== true || location.pathname === '/trashed') return true }) - moment.locale('en', { + moment.updateLocale('en', { relativeTime: { future: 'in %s', past: '%s ago', @@ -674,20 +945,30 @@ class NoteList extends React.Component { } }) + const viewType = this.getViewType() + + const autoSelectFirst = + notes.length === 1 || + selectedNoteKeys.length === 0 || + notes.every(note => !selectedNoteKeys.includes(note.key)) + const noteList = notes - .map(note => { + .map((note, index) => { if (note == null) { return null } const isDefault = config.listStyle === 'DEFAULT' const uniqueKey = getNoteKey(note) - const isActive = selectedNoteKeys.includes(uniqueKey) + + const isActive = + selectedNoteKeys.includes(uniqueKey) || + notes.length === 1 || + (autoSelectFirst && index === 0) const dateDisplay = moment( - config.sortBy === 'CREATED_AT' + sortBy === 'CREATED_AT' ? note.createdAt : note.updatedAt ).fromNow('D') - const key = `${note.storage}-${note.key}` if (isDefault) { return ( @@ -700,6 +981,9 @@ class NoteList extends React.Component { handleNoteClick={this.handleNoteClick.bind(this)} handleDragStart={this.handleDragStart.bind(this)} pathname={location.pathname} + folderName={this.getNoteFolder(note).name} + storageName={this.getNoteStorage(note).name} + viewType={viewType} /> ) } @@ -713,6 +997,9 @@ class NoteList extends React.Component { handleNoteClick={this.handleNoteClick.bind(this)} handleDragStart={this.handleDragStart.bind(this)} pathname={location.pathname} + folderName={this.getNoteFolder(note).name} + storageName={this.getNoteStorage(note).name} + viewType={viewType} /> ) }) @@ -727,16 +1014,17 @@ class NoteList extends React.Component {
- - ) diff --git a/browser/main/SideNav/PreferenceButton.js b/browser/main/SideNav/PreferenceButton.js index 9f483a28..187171f4 100644 --- a/browser/main/SideNav/PreferenceButton.js +++ b/browser/main/SideNav/PreferenceButton.js @@ -2,13 +2,14 @@ import PropTypes from 'prop-types' import React from 'react' import CSSModules from 'browser/lib/CSSModules' import styles from './PreferenceButton.styl' +import i18n from 'browser/lib/i18n' const PreferenceButton = ({ onClick }) => ( ) diff --git a/browser/main/SideNav/PreferenceButton.styl b/browser/main/SideNav/PreferenceButton.styl index 97a48982..54513cb6 100644 --- a/browser/main/SideNav/PreferenceButton.styl +++ b/browser/main/SideNav/PreferenceButton.styl @@ -48,4 +48,5 @@ body[data-theme="dark"] line-height normal border-radius 2px opacity 0 - transition 0.1s \ No newline at end of file + transition 0.1s + white-space nowrap diff --git a/browser/main/SideNav/SideNav.styl b/browser/main/SideNav/SideNav.styl index a0ffb2e7..ecab70d0 100644 --- a/browser/main/SideNav/SideNav.styl +++ b/browser/main/SideNav/SideNav.styl @@ -30,11 +30,33 @@ display flex flex-direction column -.tag-title - padding-left 15px - padding-bottom 13px - p - color $ui-button-default-color +.tag-control + display flex + height 30px + line-height 25px + overflow hidden + .tag-control-title + padding-left 15px + padding-bottom 13px + flex 1 + p + color $ui-button-default-color + .tag-control-sortTagsBy + user-select none + font-size 12px + color $ui-inactive-text-color + margin-left 12px + margin-right 12px + .tag-control-sortTagsBy-select + appearance: none; + margin-left 5px + color $ui-inactive-text-color + padding 0 + border none + background-color transparent + outline none + cursor pointer + font-size 12px .tagList overflow-y auto @@ -95,3 +117,8 @@ body[data-theme="solarized-dark"] .root, .root--folded background-color $ui-solarized-dark-backgroundColor border-right 1px solid $ui-solarized-dark-borderColor + +body[data-theme="monokai"] + .root, .root--folded + background-color $ui-monokai-backgroundColor + border-right 1px solid $ui-monokai-borderColor diff --git a/browser/main/SideNav/StorageItem.js b/browser/main/SideNav/StorageItem.js index bbf87306..d72f0a8f 100644 --- a/browser/main/SideNav/StorageItem.js +++ b/browser/main/SideNav/StorageItem.js @@ -8,46 +8,49 @@ import CreateFolderModal from 'browser/main/modals/CreateFolderModal' import RenameFolderModal from 'browser/main/modals/RenameFolderModal' import dataApi from 'browser/main/lib/dataApi' import StorageItemChild from 'browser/components/StorageItem' -import eventEmitter from 'browser/main/lib/eventEmitter' import _ from 'lodash' -import * as path from 'path' +import { SortableElement } from 'react-sortable-hoc' +import i18n from 'browser/lib/i18n' +import context from 'browser/lib/context' const { remote } = require('electron') -const { Menu, MenuItem, dialog } = remote +const { dialog } = remote +const escapeStringRegexp = require('escape-string-regexp') +const path = require('path') class StorageItem extends React.Component { constructor (props) { super(props) + const { storage } = this.props + this.state = { - isOpen: true + isOpen: !!storage.isOpen } } handleHeaderContextMenu (e) { - const menu = Menu.buildFromTemplate([ + context.popup([ { - label: 'Add Folder', + label: i18n.__('Add Folder'), click: (e) => this.handleAddFolderButtonClick(e) }, { type: 'separator' }, { - label: 'Unlink Storage', + label: i18n.__('Unlink Storage'), click: (e) => this.handleUnlinkStorageClick(e) } ]) - - menu.popup() } handleUnlinkStorageClick (e) { const index = dialog.showMessageBox(remote.getCurrentWindow(), { type: 'warning', - message: 'Unlink Storage', - detail: 'This work will just detatches a storage from Boostnote. (Any data won\'t be deleted.)', - buttons: ['Confirm', 'Cancel'] + message: i18n.__('Unlink Storage'), + detail: i18n.__('This work will just detatches a storage from Boostnote. (Any data won\'t be deleted.)'), + buttons: [i18n.__('Confirm'), i18n.__('Cancel')] }) if (index === 0) { @@ -66,8 +69,18 @@ class StorageItem extends React.Component { } handleToggleButtonClick (e) { + const { storage, dispatch } = this.props + const isOpen = !this.state.isOpen + dataApi.toggleStorage(storage.key, isOpen) + .then((storage) => { + dispatch({ + type: 'EXPAND_STORAGE', + storage, + isOpen + }) + }) this.setState({ - isOpen: !this.state.isOpen + isOpen: isOpen }) } @@ -92,23 +105,23 @@ class StorageItem extends React.Component { } handleFolderButtonContextMenu (e, folder) { - const menu = Menu.buildFromTemplate([ + context.popup([ { - label: 'Rename Folder', + label: i18n.__('Rename Folder'), click: (e) => this.handleRenameFolderClick(e, folder) }, { type: 'separator' }, { - label: 'Export Folder', + label: i18n.__('Export Folder'), submenu: [ { - label: 'Export as txt', + label: i18n.__('Export as txt'), click: (e) => this.handleExportFolderClick(e, folder, 'txt') }, { - label: 'Export as md', + label: i18n.__('Export as md'), click: (e) => this.handleExportFolderClick(e, folder, 'md') } ] @@ -117,12 +130,10 @@ class StorageItem extends React.Component { type: 'separator' }, { - label: 'Delete Folder', + label: i18n.__('Delete Folder'), click: (e) => this.handleFolderDeleteClick(e, folder) } ]) - - menu.popup() } handleRenameFolderClick (e, folder) { @@ -136,8 +147,8 @@ class StorageItem extends React.Component { handleExportFolderClick (e, folder, fileType) { const options = { properties: ['openDirectory', 'createDirectory'], - buttonLabel: 'Select directory', - title: 'Select a folder to export the files to', + buttonLabel: i18n.__('Select directory'), + title: i18n.__('Select a folder to export the files to'), multiSelections: false } dialog.showOpenDialog(remote.getCurrentWindow(), options, @@ -161,9 +172,9 @@ class StorageItem extends React.Component { handleFolderDeleteClick (e, folder) { const index = dialog.showMessageBox(remote.getCurrentWindow(), { type: 'warning', - message: 'Delete Folder', - detail: 'This will delete all notes in the folder and can not be undone.', - buttons: ['Confirm', 'Cancel'] + message: i18n.__('Delete Folder'), + detail: i18n.__('This will delete all notes in the folder and can not be undone.'), + buttons: [i18n.__('Confirm'), i18n.__('Cancel')] }) if (index === 0) { @@ -193,33 +204,16 @@ class StorageItem extends React.Component { dropNote (storage, folder, dispatch, location, noteData) { noteData = noteData.filter((note) => folder.key !== note.folder) if (noteData.length === 0) return - const newNoteData = noteData.map((note) => Object.assign({}, note, {storage: storage, folder: folder.key})) Promise.all( - newNoteData.map((note) => dataApi.createNote(storage.key, note)) + noteData.map((note) => dataApi.moveNote(note.storage, note.key, storage.key, folder.key)) ) .then((createdNoteData) => { - createdNoteData.forEach((note) => { + createdNoteData.forEach((newNote) => { dispatch({ - type: 'UPDATE_NOTE', - note: note - }) - }) - }) - .catch((err) => { - console.error(`error on create notes: ${err}`) - }) - .then(() => { - return Promise.all( - noteData.map((note) => dataApi.deleteNote(note.storage, note.key)) - ) - }) - .then((deletedNoteData) => { - deletedNoteData.forEach((note) => { - dispatch({ - type: 'DELETE_NOTE', - storageKey: note.storageKey, - noteKey: note.noteKey + type: 'MOVE_NOTE', + originNote: noteData.find((note) => note.content === newNote.oldContent), + note: newNote }) }) }) @@ -238,8 +232,10 @@ class StorageItem extends React.Component { render () { const { storage, location, isFolded, data, dispatch } = this.props const { folderNoteMap, trashedSet } = data - const folderList = storage.folders.map((folder) => { - const isActive = !!(location.pathname.match(new RegExp('\/storages\/' + storage.key + '\/folders\/' + folder.key))) + const SortableStorageItemChild = SortableElement(StorageItemChild) + const folderList = storage.folders.map((folder, index) => { + let folderRegex = new RegExp(escapeStringRegexp(path.sep) + 'storages' + escapeStringRegexp(path.sep) + storage.key + escapeStringRegexp(path.sep) + 'folders' + escapeStringRegexp(path.sep) + folder.key) + const isActive = !!(location.pathname.match(folderRegex)) const noteSet = folderNoteMap.get(storage.key + '-' + folder.key) let noteCount = 0 @@ -252,8 +248,9 @@ class StorageItem extends React.Component { noteCount = noteSet.size - trashedNoteCount } return ( - this.handleFolderButtonClick(folder.key)(e)} handleContextMenu={(e) => this.handleFolderButtonContextMenu(e, folder)} @@ -268,16 +265,16 @@ class StorageItem extends React.Component { ) }) - const isActive = location.pathname.match(new RegExp('\/storages\/' + storage.key + '$')) + const isActive = location.pathname.match(new RegExp(escapeStringRegexp(path.sep) + 'storages' + escapeStringRegexp(path.sep) + storage.key + '$')) return (
this.handleHeaderContextMenu(e)} > diff --git a/browser/main/SideNav/StorageItem.styl b/browser/main/SideNav/StorageItem.styl index 619498ec..a06ecb11 100644 --- a/browser/main/SideNav/StorageItem.styl +++ b/browser/main/SideNav/StorageItem.styl @@ -44,7 +44,7 @@ height 36px padding-left 25px padding-right 15px - line-height 22px + line-height 36px cursor pointer font-size 14px border none @@ -147,7 +147,7 @@ body[data-theme="dark"] background-color $ui-dark-button--active-backgroundColor &:active color $ui-dark-text-color - background-color $ui-dark-button--active-backgroundColor + background-color $ui-dark-button--active-backgroundColor .header--active .header-addFolderButton @@ -180,7 +180,7 @@ body[data-theme="dark"] &:active, &:active:hover color $ui-dark-text-color background-color $ui-dark-button--active-backgroundColor - + diff --git a/browser/main/SideNav/SwitchButton.styl b/browser/main/SideNav/SwitchButton.styl index 54e21b03..36099140 100644 --- a/browser/main/SideNav/SwitchButton.styl +++ b/browser/main/SideNav/SwitchButton.styl @@ -29,6 +29,7 @@ border-radius 2px opacity 0 transition 0.1s + white-space nowrap body[data-theme="white"] .non-active-button diff --git a/browser/main/SideNav/TagButton.js b/browser/main/SideNav/TagButton.js index 87d92c49..d91ae2c4 100644 --- a/browser/main/SideNav/TagButton.js +++ b/browser/main/SideNav/TagButton.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types' import React from 'react' import CSSModules from 'browser/lib/CSSModules' import styles from './SwitchButton.styl' +import i18n from 'browser/lib/i18n' const TagButton = ({ onClick, isTagActive @@ -12,7 +13,7 @@ const TagButton = ({ : '../resources/icon/icon-tag.svg' } /> - Tags + {i18n.__('Tags')} ) diff --git a/browser/main/SideNav/index.js b/browser/main/SideNav/index.js index 4c162f1e..c4fa417b 100644 --- a/browser/main/SideNav/index.js +++ b/browser/main/SideNav/index.js @@ -1,6 +1,7 @@ import PropTypes from 'prop-types' import React from 'react' import CSSModules from 'browser/lib/CSSModules' +import dataApi from 'browser/main/lib/dataApi' import styles from './SideNav.styl' import { openModal } from 'browser/main/lib/modal' import PreferencesModal from '../modals/PreferencesModal' @@ -14,6 +15,9 @@ import EventEmitter from 'browser/main/lib/eventEmitter' import PreferenceButton from './PreferenceButton' import ListButton from './ListButton' import TagButton from './TagButton' +import {SortableContainer} from 'react-sortable-hoc' +import i18n from 'browser/lib/i18n' +import context from 'browser/lib/context' class SideNav extends React.Component { // TODO: should not use electron stuff v0.7 @@ -65,8 +69,19 @@ class SideNav extends React.Component { router.push('/alltags') } + onSortEnd (storage) { + return ({oldIndex, newIndex}) => { + const { dispatch } = this.props + dataApi + .reorderFolder(storage.key, oldIndex, newIndex) + .then((data) => { + dispatch({ type: 'REORDER_FOLDER', storage: data.storage }) + }) + } + } + SideNavComponent (isFolded, storageList) { - const { location, data } = this.props + const { location, data, config } = this.props const isHomeActive = !!location.pathname.match(/^\/home$/) const isStarredActive = !!location.pathname.match(/^\/starred$/) @@ -86,20 +101,36 @@ class SideNav extends React.Component { isTrashedActive={isTrashedActive} handleStarredButtonClick={(e) => this.handleStarredButtonClick(e)} handleTrashedButtonClick={(e) => this.handleTrashedButtonClick(e)} - counterTotalNote={data.noteMap._map.size} + counterTotalNote={data.noteMap._map.size - data.trashedSet._set.size} counterStarredNote={data.starredSet._set.size} counterDelNote={data.trashedSet._set.size} + handleFilterButtonContextMenu={this.handleFilterButtonContextMenu.bind(this)} /> - +
) } else { component = (
-
-

Tags

+
+
+

{i18n.__('Tags')}

+
+
+ + +
{this.tagListComponent(data)} @@ -112,26 +143,62 @@ class SideNav extends React.Component { } tagListComponent () { - const { data, location } = this.props - const tagList = data.tagNoteMap.map((tag, key) => { - return key - }) + const { data, location, config } = this.props + const relatedTags = this.getRelatedTags(this.getActiveTags(location.pathname), data.noteMap) + let tagList = _.sortBy(data.tagNoteMap.map( + (tag, name) => ({ name, size: tag.size, related: relatedTags.has(name) }) + ), ['name']).filter( + tag => tag.size > 0 + ) + if (config.sortTagsBy === 'COUNTER') { + tagList = _.sortBy(tagList, item => (0 - item.size)) + } + if (config.ui.showOnlyRelatedTags && (relatedTags.size > 0)) { + tagList = tagList.filter( + tag => tag.related + ) + } return ( - tagList.map(tag => ( - - )) + tagList.map(tag => { + return ( + + ) + }) ) } + getRelatedTags (activeTags, noteMap) { + if (activeTags.length === 0) { + return new Set() + } + const relatedNotes = noteMap.map( + note => ({key: note.key, tags: note.tags}) + ).filter( + note => activeTags.every(tag => note.tags.includes(tag)) + ) + const relatedTags = new Set() + relatedNotes.forEach(note => note.tags.map(tag => relatedTags.add(tag))) + return relatedTags + } + getTagActive (path, tag) { + return this.getActiveTags(path).includes(tag) + } + + getActiveTags (path) { const pathSegments = path.split('/') - const pathTag = pathSegments[pathSegments.length - 1] - return pathTag === tag + const tags = pathSegments[pathSegments.length - 1] + return (tags === 'alltags') + ? [] + : tags.split(' ') } handleClickTagListItem (name) { @@ -139,19 +206,74 @@ class SideNav extends React.Component { router.push(`/tags/${name}`) } + handleSortTagsByChange (e) { + const { dispatch } = this.props + + const config = { + sortTagsBy: e.target.value + } + + ConfigManager.set(config) + dispatch({ + type: 'SET_CONFIG', + config + }) + } + + handleClickNarrowToTag (tag) { + const { router } = this.context + const { location } = this.props + const listOfTags = this.getActiveTags(location.pathname) + const indexOfTag = listOfTags.indexOf(tag) + if (indexOfTag > -1) { + listOfTags.splice(indexOfTag, 1) + } else { + listOfTags.push(tag) + } + router.push(`/tags/${listOfTags.join(' ')}`) + } + + emptyTrash (entries) { + const { dispatch } = this.props + const deletionPromises = entries.map((note) => { + return dataApi.deleteNote(note.storage, note.key) + }) + Promise.all(deletionPromises) + .then((arrayOfStorageAndNoteKeys) => { + arrayOfStorageAndNoteKeys.forEach(({ storageKey, noteKey }) => { + dispatch({ type: 'DELETE_NOTE', storageKey, noteKey }) + }) + }) + .catch((err) => { + console.error('Cannot Delete note: ' + err) + }) + console.log('Trash emptied') + } + + handleFilterButtonContextMenu (event) { + const { data } = this.props + const trashedNotes = data.trashedSet.toJS().map((uniqueKey) => data.noteMap.get(uniqueKey)) + context.popup([ + { label: i18n.__('Empty Trash'), click: () => this.emptyTrash(trashedNotes) } + ]) + } + render () { const { data, location, config, dispatch } = this.props const isFolded = config.isSideNavFolded const storageList = data.storageMap.map((storage, key) => { - return }) const style = {} diff --git a/browser/main/StatusBar/StatusBar.styl b/browser/main/StatusBar/StatusBar.styl index 9f189fec..52cc4b02 100644 --- a/browser/main/StatusBar/StatusBar.styl +++ b/browser/main/StatusBar/StatusBar.styl @@ -69,3 +69,14 @@ body[data-theme="dark"] navDarkButtonColor() border-color $ui-dark-borderColor border-left 1px solid $ui-dark-borderColor + +body[data-theme="monokai"] + navButtonColor() + .zoom + border-color $ui-dark-borderColor + color $ui-monokai-text-color + &:hover + transition 0.15s + color $ui-monokai-active-color + &:active + color $ui-monokai-active-color diff --git a/browser/main/StatusBar/index.js b/browser/main/StatusBar/index.js index 49c1b40c..8b48e3d3 100644 --- a/browser/main/StatusBar/index.js +++ b/browser/main/StatusBar/index.js @@ -3,10 +3,12 @@ import React from 'react' import CSSModules from 'browser/lib/CSSModules' import styles from './StatusBar.styl' import ZoomManager from 'browser/main/lib/ZoomManager' +import i18n from 'browser/lib/i18n' +import context from 'browser/lib/context' const electron = require('electron') const { remote, ipcRenderer } = electron -const { Menu, MenuItem, dialog } = remote +const { dialog } = remote const zoomOptions = [0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0] @@ -14,9 +16,9 @@ class StatusBar extends React.Component { updateApp () { const index = dialog.showMessageBox(remote.getCurrentWindow(), { type: 'warning', - message: 'Update Boostnote', - detail: 'New Boostnote is ready to be installed.', - buttons: ['Restart & Install', 'Not Now'] + message: i18n.__('Update Boostnote'), + detail: i18n.__('New Boostnote is ready to be installed.'), + buttons: [i18n.__('Restart & Install'), i18n.__('Not Now')] }) if (index === 0) { @@ -25,16 +27,16 @@ class StatusBar extends React.Component { } handleZoomButtonClick (e) { - const menu = new Menu() + const templates = [] zoomOptions.forEach((zoom) => { - menu.append(new MenuItem({ + templates.push({ label: Math.floor(zoom * 100) + '%', click: () => this.handleZoomMenuItemClick(zoom) - })) + }) }) - menu.popup(remote.getCurrentWindow()) + context.popup(templates) } handleZoomMenuItemClick (zoomFactor) { @@ -62,7 +64,7 @@ class StatusBar extends React.Component { {status.updateReady ? : null } diff --git a/browser/main/TopBar/TopBar.styl b/browser/main/TopBar/TopBar.styl index eb0fc12f..7654f66f 100644 --- a/browser/main/TopBar/TopBar.styl +++ b/browser/main/TopBar/TopBar.styl @@ -40,6 +40,32 @@ $control-height = 34px padding-bottom 2px background-color $ui-noteList-backgroundColor +.control-search-input-clear + height 16px + width 16px + position absolute + right 40px + top 10px + z-index 300 + border none + background-color transparent + color #999 + &:hover .control-search-input-clear-tooltip + opacity 1 + +.control-search-input-clear-tooltip + tooltip() + position fixed + pointer-events none + top 50px + left 433px + z-index 200 + padding 5px + line-height normal + border-radius 2px + opacity 0 + transition 0.1s + .control-search-optionList position fixed z-index 200 @@ -207,4 +233,26 @@ body[data-theme="solarized-dark"] background-color $ui-solarized-dark-noteList-backgroundColor input background-color $ui-solarized-dark-noteList-backgroundColor - color $ui-solarized-dark-text-color \ No newline at end of file + color $ui-solarized-dark-text-color + +body[data-theme="monokai"] + .root, .root--expanded + background-color $ui-monokai-noteList-backgroundColor + + .control + border-color $ui-monokai-borderColor + .control-search + background-color $ui-monokai-noteList-backgroundColor + + .control-search-icon + absolute top bottom left + line-height 32px + width 35px + color $ui-monokai-inactive-text-color + background-color $ui-monokai-noteList-backgroundColor + + .control-search-input + background-color $ui-monokai-noteList-backgroundColor + input + background-color $ui-monokai-noteList-backgroundColor + color $ui-monokai-text-color diff --git a/browser/main/TopBar/index.js b/browser/main/TopBar/index.js index 11d31abd..a5687ecb 100644 --- a/browser/main/TopBar/index.js +++ b/browser/main/TopBar/index.js @@ -5,6 +5,7 @@ import styles from './TopBar.styl' import _ from 'lodash' import ee from 'browser/main/lib/eventEmitter' import NewNoteButton from 'browser/main/NewNoteButton' +import i18n from 'browser/lib/i18n' class TopBar extends React.Component { constructor (props) { @@ -22,14 +23,37 @@ class TopBar extends React.Component { this.focusSearchHandler = () => { this.handleOnSearchFocus() } + + this.codeInitHandler = this.handleCodeInit.bind(this) } componentDidMount () { + const { params } = this.props + const searchWord = params.searchword + if (searchWord !== undefined) { + this.setState({ + search: searchWord, + isSearching: true + }) + } ee.on('top:focus-search', this.focusSearchHandler) + ee.on('code:init', this.codeInitHandler) } componentWillUnmount () { ee.off('top:focus-search', this.focusSearchHandler) + ee.off('code:init', this.codeInitHandler) + } + + handleSearchClearButton (e) { + const { router } = this.context + this.setState({ + search: '', + isSearching: false + }) + this.refs.search.childNodes[0].blur + router.push('/searched') + e.preventDefault() } handleKeyDown (e) { @@ -39,6 +63,23 @@ class TopBar extends React.Component { isIME: false }) + // Clear search on ESC + if (e.keyCode === 27) { + return this.handleSearchClearButton(e) + } + + // Next note on DOWN key + if (e.keyCode === 40) { + ee.emit('list:next') + e.preventDefault() + } + + // Prev note on UP key + if (e.keyCode === 38) { + ee.emit('list:prior') + e.preventDefault() + } + // When the key is an alphabet, del, enter or ctr if (e.keyCode <= 90 || e.keyCode >= 186 && e.keyCode <= 222) { this.setState({ @@ -64,23 +105,26 @@ class TopBar extends React.Component { this.setState({ isConfirmTranslation: true }) - router.push('/searched') + const keyword = this.refs.searchInput.value + router.push(`/searched/${encodeURIComponent(keyword)}`) this.setState({ - search: this.refs.searchInput.value + search: keyword }) } } handleSearchChange (e) { const { router } = this.context + const keyword = this.refs.searchInput.value if (this.state.isAlphabet || this.state.isConfirmTranslation) { - router.push('/searched') + router.push(`/searched/${encodeURIComponent(keyword)}`) } else { e.preventDefault() } this.setState({ - search: this.refs.searchInput.value + search: keyword }) + ee.emit('top:search', keyword) } handleSearchFocus (e) { @@ -108,13 +152,18 @@ class TopBar extends React.Component { } handleOnSearchFocus () { + const el = this.refs.search.childNodes[0] if (this.state.isSearching) { - this.refs.search.childNodes[0].blur() + el.blur() } else { - this.refs.search.childNodes[0].focus() + el.select() } } + handleCodeInit () { + ee.emit('top:search', this.refs.searchInput.value) + } + render () { const { config, style, location } = this.props return ( @@ -136,19 +185,19 @@ class TopBar extends React.Component { onChange={(e) => this.handleSearchChange(e)} onKeyDown={(e) => this.handleKeyDown(e)} onKeyUp={(e) => this.handleKeyUp(e)} - placeholder='Search' + placeholder={i18n.__('Search')} type='text' className='searchInput' /> + {this.state.search !== '' && + + }
- {this.state.search > 0 && - - } -
{location.pathname === '/trashed' ? '' diff --git a/browser/main/global.styl b/browser/main/global.styl index 27d1ae73..e4505a4e 100644 --- a/browser/main/global.styl +++ b/browser/main/global.styl @@ -15,6 +15,12 @@ body font-weight 200 -webkit-font-smoothing antialiased +::-webkit-scrollbar + width 12px + +::-webkit-scrollbar-thumb + background-color rgba(0, 0, 0, 0.15) + button, input, select, textarea font-family DEFAULT_FONTS @@ -85,9 +91,11 @@ modalBackColor = white absolute top left bottom right background-color modalBackColor z-index modalZIndex + 1 - + body[data-theme="dark"] + ::-webkit-scrollbar-thumb + background-color rgba(0, 0, 0, 0.3) .ModalBase .modalBack background-color $ui-dark-backgroundColor @@ -97,7 +105,7 @@ body[data-theme="dark"] .CodeMirror font-family inherit !important line-height 1.4em - height 96% + height 100% .CodeMirror > div > textarea margin-bottom -1em .CodeMirror-focused .CodeMirror-selected @@ -108,15 +116,43 @@ body[data-theme="dark"] background #B1D7FE ::selection background #B1D7FE +.CodeMirror-foldmarker + font-family: arial + +.CodeMirror-foldgutter + width: .7em + +.CodeMirror-foldgutter-open, +.CodeMirror-foldgutter-folded + cursor: pointer + +.CodeMirror-foldgutter-open:after + content: "\25BE" + +.CodeMirror-foldgutter-folded:after + content: "\25B8" .sortableItemHelper z-index modalZIndex + 5 body[data-theme="solarized-dark"] + ::-webkit-scrollbar-thumb + background-color rgba(0, 0, 0, 0.3) .ModalBase .modalBack background-color $ui-solarized-dark-backgroundColor .sortableItemHelper color: $ui-solarized-dark-text-color +body[data-theme="monokai"] + ::-webkit-scrollbar-thumb + background-color rgba(0, 0, 0, 0.3) + .ModalBase + .modalBack + background-color $ui-monokai-backgroundColor + .sortableItemHelper + color: $ui-monokai-text-color +body[data-theme="default"] + .SideNav ::-webkit-scrollbar-thumb + background-color rgba(255, 255, 255, 0.3) diff --git a/browser/main/index.js b/browser/main/index.js index d7ed7bfd..6e8bdcc5 100644 --- a/browser/main/index.js +++ b/browser/main/index.js @@ -8,6 +8,7 @@ import { Router, Route, IndexRoute, IndexRedirect, hashHistory } from 'react-rou import { syncHistoryWithStore } from 'react-router-redux' require('./lib/ipcClient') require('../lib/customMeta') +import i18n from 'browser/lib/i18n' const electron = require('electron') @@ -23,6 +24,45 @@ document.addEventListener('dragover', function (e) { e.stopPropagation() }) +// prevent menu from popup when alt pressed +// but still able to toggle menu when only alt is pressed +let isAltPressing = false +let isAltWithMouse = false +let isAltWithOtherKey = false +let isOtherKey = false + +document.addEventListener('keydown', function (e) { + if (e.key === 'Alt') { + isAltPressing = true + if (isOtherKey) { + isAltWithOtherKey = true + } + } else { + if (isAltPressing) { + isAltWithOtherKey = true + } + isOtherKey = true + } +}) + +document.addEventListener('mousedown', function (e) { + if (isAltPressing) { + isAltWithMouse = true + } +}) + +document.addEventListener('keyup', function (e) { + if (e.key === 'Alt') { + if (isAltWithMouse || isAltWithOtherKey) { + e.preventDefault() + } + isAltWithMouse = false + isAltWithOtherKey = false + isAltPressing = false + isOtherKey = false + } +}) + document.addEventListener('click', function (e) { const className = e.target.className if (!className && typeof (className) !== 'string') return @@ -46,9 +86,9 @@ function notify (...args) { function updateApp () { const index = dialog.showMessageBox(remote.getCurrentWindow(), { type: 'warning', - message: 'Update Boostnote', - detail: 'New Boostnote is ready to be installed.', - buttons: ['Restart & Install', 'Not Now'] + message: i18n.__('Update Boostnote'), + detail: i18n.__('New Boostnote is ready to be installed.'), + buttons: [i18n.__('Restart & Install'), i18n.__('Not Now')] }) if (index === 0) { @@ -63,7 +103,9 @@ ReactDOM.render(( - + + + diff --git a/browser/main/lib/ConfigManager.js b/browser/main/lib/ConfigManager.js index 3d70a7a3..434b0d22 100644 --- a/browser/main/lib/ConfigManager.js +++ b/browser/main/lib/ConfigManager.js @@ -1,5 +1,7 @@ import _ from 'lodash' import RcParser from 'browser/lib/RcParser' +import i18n from 'browser/lib/i18n' +import ee from 'browser/main/lib/eventEmitter' const OSX = global.process.platform === 'darwin' const win = global.process.platform === 'win32' @@ -14,14 +16,18 @@ export const DEFAULT_CONFIG = { isSideNavFolded: false, listWidth: 280, navWidth: 200, - sortBy: 'UPDATED_AT', // 'CREATED_AT', 'UPDATED_AT', 'APLHABETICAL' + sortBy: { + default: 'UPDATED_AT' // 'CREATED_AT', 'UPDATED_AT', 'APLHABETICAL' + }, + sortTagsBy: 'ALPHABETICAL', // 'ALPHABETICAL', 'COUNTER' listStyle: 'DEFAULT', // 'DEFAULT', 'SMALL' amaEnabled: true, hotkey: { - toggleFinder: OSX ? 'Cmd + Alt + S' : 'Super + Alt + S', - toggleMain: OSX ? 'Cmd + Alt + L' : 'Super + Alt + E' + toggleMain: OSX ? 'Command + Alt + L' : 'Super + Alt + E', + toggleMode: OSX ? 'Command + M' : 'Ctrl + M' }, ui: { + language: 'en', theme: 'default', showCopyNotification: true, disableDirectWrite: false, @@ -34,9 +40,13 @@ export const DEFAULT_CONFIG = { fontFamily: win ? 'Segoe UI' : 'Monaco, Consolas', indentType: 'space', indentSize: '2', + enableRulers: false, + rulers: [80, 120], + displayLineNumbers: true, switchPreview: 'BLUR', // Available value: RIGHTCLICK, BLUR scrollPastEnd: false, - type: 'SPLIT' + type: 'SPLIT', + fetchUrlTitle: true }, preview: { fontSize: '14', @@ -46,7 +56,23 @@ export const DEFAULT_CONFIG = { latexInlineOpen: '$', latexInlineClose: '$', latexBlockOpen: '$$', - latexBlockClose: '$$' + latexBlockClose: '$$', + plantUMLServerAddress: 'http://www.plantuml.com/plantuml', + scrollPastEnd: false, + smartQuotes: true, + breaks: true, + smartArrows: false, + allowCustomCSS: false, + customCSS: '', + sanitize: 'STRICT' // 'STRICT', 'ALLOW_STYLES', 'NONE' + }, + blog: { + type: 'wordpress', // Available value: wordpress, add more types in the future plz + address: 'http://wordpress.com/wp-json', + authMethod: 'JWT', // Available value: JWT, USER + token: '', + username: '', + password: '' } } @@ -118,10 +144,14 @@ function set (updates) { document.body.setAttribute('data-theme', 'white') } else if (newConfig.ui.theme === 'solarized-dark') { document.body.setAttribute('data-theme', 'solarized-dark') + } else if (newConfig.ui.theme === 'monokai') { + document.body.setAttribute('data-theme', 'monokai') } else { document.body.setAttribute('data-theme', 'default') } + i18n.setLocale(newConfig.ui.language) + let editorTheme = document.getElementById('editorTheme') if (editorTheme == null) { editorTheme = document.createElement('link') @@ -144,14 +174,27 @@ function set (updates) { ipcRenderer.send('config-renew', { config: get() }) + ee.emit('config-renew') } function assignConfigValues (originalConfig, rcConfig) { const config = Object.assign({}, DEFAULT_CONFIG, originalConfig, rcConfig) config.hotkey = Object.assign({}, DEFAULT_CONFIG.hotkey, originalConfig.hotkey, rcConfig.hotkey) + config.blog = Object.assign({}, DEFAULT_CONFIG.blog, originalConfig.blog, rcConfig.blog) config.ui = Object.assign({}, DEFAULT_CONFIG.ui, originalConfig.ui, rcConfig.ui) config.editor = Object.assign({}, DEFAULT_CONFIG.editor, originalConfig.editor, rcConfig.editor) config.preview = Object.assign({}, DEFAULT_CONFIG.preview, originalConfig.preview, rcConfig.preview) + + rewriteHotkey(config) + + return config +} + +function rewriteHotkey (config) { + const keys = [...Object.keys(config.hotkey)] + keys.forEach(key => { + config.hotkey[key] = config.hotkey[key].replace(/Cmd/g, 'Command') + }) return config } diff --git a/browser/main/lib/dataApi/addStorage.js b/browser/main/lib/dataApi/addStorage.js index 630c0bd3..bfd6698a 100644 --- a/browser/main/lib/dataApi/addStorage.js +++ b/browser/main/lib/dataApi/addStorage.js @@ -37,7 +37,8 @@ function addStorage (input) { key, name: input.name, type: input.type, - path: input.path + path: input.path, + isOpen: false } return Promise.resolve(newStorage) @@ -48,7 +49,8 @@ function addStorage (input) { key: newStorage.key, type: newStorage.type, name: newStorage.name, - path: newStorage.path + path: newStorage.path, + isOpen: false }) localStorage.setItem('storages', JSON.stringify(rawStorages)) diff --git a/browser/main/lib/dataApi/attachmentManagement.js b/browser/main/lib/dataApi/attachmentManagement.js new file mode 100644 index 00000000..d1e0ab62 --- /dev/null +++ b/browser/main/lib/dataApi/attachmentManagement.js @@ -0,0 +1,429 @@ +const uniqueSlug = require('unique-slug') +const fs = require('fs') +const path = require('path') +const findStorage = require('browser/lib/findStorage') +const mdurl = require('mdurl') +const fse = require('fs-extra') +const escapeStringRegexp = require('escape-string-regexp') +const sander = require('sander') +import i18n from 'browser/lib/i18n' + +const STORAGE_FOLDER_PLACEHOLDER = ':storage' +const DESTINATION_FOLDER = 'attachments' +const PATH_SEPARATORS = escapeStringRegexp(path.posix.sep) + escapeStringRegexp(path.win32.sep) + +/** + * @description + * Copies a copy of an attachment to the storage folder specified by the given key and return the generated attachment name. + * Renames the file to match a unique file name. + * + * @param {String} sourceFilePath The source path of the attachment to be copied + * @param {String} storageKey Storage key of the destination storage + * @param {String} noteKey Key of the current note. Will be used as subfolder in :storage + * @param {boolean} useRandomName determines whether a random filename for the new file is used. If false the source file name is used + * @return {Promise} name (inclusive extension) of the generated file + */ +function copyAttachment (sourceFilePath, storageKey, noteKey, useRandomName = true) { + return new Promise((resolve, reject) => { + if (!sourceFilePath) { + reject('sourceFilePath has to be given') + } + + if (!storageKey) { + reject('storageKey has to be given') + } + + if (!noteKey) { + reject('noteKey has to be given') + } + + try { + if (!fs.existsSync(sourceFilePath)) { + reject('source file does not exist') + } + + const targetStorage = findStorage.findStorage(storageKey) + + const inputFileStream = fs.createReadStream(sourceFilePath) + let destinationName + if (useRandomName) { + destinationName = `${uniqueSlug()}${path.extname(sourceFilePath)}` + } else { + destinationName = path.basename(sourceFilePath) + } + const destinationDir = path.join(targetStorage.path, DESTINATION_FOLDER, noteKey) + createAttachmentDestinationFolder(targetStorage.path, noteKey) + const outputFile = fs.createWriteStream(path.join(destinationDir, destinationName)) + inputFileStream.pipe(outputFile) + inputFileStream.on('end', () => { + resolve(destinationName) + }) + } catch (e) { + return reject(e) + } + }) +} + +function createAttachmentDestinationFolder (destinationStoragePath, noteKey) { + let destinationDir = path.join(destinationStoragePath, DESTINATION_FOLDER) + if (!fs.existsSync(destinationDir)) { + fs.mkdirSync(destinationDir) + } + destinationDir = path.join(destinationStoragePath, DESTINATION_FOLDER, noteKey) + if (!fs.existsSync(destinationDir)) { + fs.mkdirSync(destinationDir) + } +} + +/** + * @description Moves attachments from the old location ('/images') to the new one ('/attachments/noteKey) + * @param markdownContent of the current note + * @param storagePath Storage path of the current note + * @param noteKey Key of the current note + */ +function migrateAttachments (markdownContent, storagePath, noteKey) { + if (noteKey !== undefined && sander.existsSync(path.join(storagePath, 'images'))) { + const attachments = getAttachmentsInMarkdownContent(markdownContent) || [] + if (attachments.length) { + createAttachmentDestinationFolder(storagePath, noteKey) + } + for (const attachment of attachments) { + const attachmentBaseName = path.basename(attachment) + const possibleLegacyPath = path.join(storagePath, 'images', attachmentBaseName) + if (sander.existsSync(possibleLegacyPath)) { + const destinationPath = path.join(storagePath, DESTINATION_FOLDER, attachmentBaseName) + if (!sander.existsSync(destinationPath)) { + sander.copyFileSync(possibleLegacyPath).to(destinationPath) + } + } + } + } +} + +/** + * @description Fixes the URLs embedded in the generated HTML so that they again refer actual local files. + * @param {String} renderedHTML HTML in that the links should be fixed + * @param {String} storagePath Path of the current storage + * @returns {String} postprocessed HTML in which all :storage references are mapped to the actual paths. + */ +function fixLocalURLS (renderedHTML, storagePath) { + return renderedHTML.replace(new RegExp('/?' + STORAGE_FOLDER_PLACEHOLDER + '.*?"', 'g'), function (match) { + var encodedPathSeparators = new RegExp(mdurl.encode(path.win32.sep) + '|' + mdurl.encode(path.posix.sep), 'g') + return match.replace(encodedPathSeparators, path.sep).replace(new RegExp('/?' + STORAGE_FOLDER_PLACEHOLDER, 'g'), 'file:///' + path.join(storagePath, DESTINATION_FOLDER)) + }) +} + +/** + * @description Generates the markdown code for a given attachment + * @param {String} fileName Name of the attachment + * @param {String} path Path of the attachment + * @param {Boolean} showPreview Indicator whether the generated markdown should show a preview of the image. Note that at the moment only previews for images are supported + * @returns {String} Generated markdown code + */ +function generateAttachmentMarkdown (fileName, path, showPreview) { + return `${showPreview ? '!' : ''}[${fileName}](${path})` +} + +/** + * @description Handles the drop-event of a file. Includes the necessary markdown code and copies the file to the corresponding storage folder. + * The method calls {CodeEditor#insertAttachmentMd()} to include the generated markdown at the needed place! + * @param {CodeEditor} codeEditor Markdown editor. Its insertAttachmentMd() method will be called to include the markdown code + * @param {String} storageKey Key of the current storage + * @param {String} noteKey Key of the current note + * @param {Event} dropEvent DropEvent + */ +function handleAttachmentDrop (codeEditor, storageKey, noteKey, dropEvent) { + const file = dropEvent.dataTransfer.files[0] + const filePath = file.path + const originalFileName = path.basename(filePath) + const fileType = file['type'] + + copyAttachment(filePath, storageKey, noteKey).then((fileName) => { + const showPreview = fileType.startsWith('image') + const imageMd = generateAttachmentMarkdown(originalFileName, path.join(STORAGE_FOLDER_PLACEHOLDER, noteKey, fileName), showPreview) + codeEditor.insertAttachmentMd(imageMd) + }) +} + +/** + * @description Creates a new file in the storage folder belonging to the current note and inserts the correct markdown code + * @param {CodeEditor} codeEditor Markdown editor. Its insertAttachmentMd() method will be called to include the markdown code + * @param {String} storageKey Key of the current storage + * @param {String} noteKey Key of the current note + * @param {DataTransferItem} dataTransferItem Part of the past-event + */ +function handlePastImageEvent (codeEditor, storageKey, noteKey, dataTransferItem) { + if (!codeEditor) { + throw new Error('codeEditor has to be given') + } + if (!storageKey) { + throw new Error('storageKey has to be given') + } + + if (!noteKey) { + throw new Error('noteKey has to be given') + } + if (!dataTransferItem) { + throw new Error('dataTransferItem has to be given') + } + + const blob = dataTransferItem.getAsFile() + const reader = new FileReader() + let base64data + const targetStorage = findStorage.findStorage(storageKey) + const destinationDir = path.join(targetStorage.path, DESTINATION_FOLDER, noteKey) + createAttachmentDestinationFolder(targetStorage.path, noteKey) + + const imageName = `${uniqueSlug()}.png` + const imagePath = path.join(destinationDir, imageName) + + reader.onloadend = function () { + base64data = reader.result.replace(/^data:image\/png;base64,/, '') + base64data += base64data.replace('+', ' ') + const binaryData = new Buffer(base64data, 'base64').toString('binary') + fs.writeFileSync(imagePath, binaryData, 'binary') + const imageReferencePath = path.join(STORAGE_FOLDER_PLACEHOLDER, noteKey, imageName) + const imageMd = generateAttachmentMarkdown(imageName, imageReferencePath, true) + codeEditor.insertAttachmentMd(imageMd) + } + reader.readAsDataURL(blob) +} + +/** +* @description Returns all attachment paths of the given markdown +* @param {String} markdownContent content in which the attachment paths should be found +* @returns {String[]} Array of the relative paths (starting with :storage) of the attachments of the given markdown +*/ +function getAttachmentsInMarkdownContent (markdownContent) { + const preparedInput = markdownContent.replace(new RegExp('[' + PATH_SEPARATORS + ']', 'g'), path.sep) + const regexp = new RegExp('/?' + STORAGE_FOLDER_PLACEHOLDER + '(' + escapeStringRegexp(path.sep) + ')' + '?([a-zA-Z0-9]|-)*' + '(' + escapeStringRegexp(path.sep) + ')' + '([a-zA-Z0-9]|\\.)+(\\.[a-zA-Z0-9]+)?', 'g') + return preparedInput.match(regexp) +} + +/** + * @description Returns an array of the absolute paths of the attachments referenced in the given markdown code + * @param {String} markdownContent content in which the attachment paths should be found + * @param {String} storagePath path of the current storage + * @returns {String[]} Absolute paths of the referenced attachments + */ +function getAbsolutePathsOfAttachmentsInContent (markdownContent, storagePath) { + const temp = getAttachmentsInMarkdownContent(markdownContent) || [] + const result = [] + for (const relativePath of temp) { + result.push(relativePath.replace(new RegExp(STORAGE_FOLDER_PLACEHOLDER, 'g'), path.join(storagePath, DESTINATION_FOLDER))) + } + return result +} + +/** + * @description Moves the attachments of the current note to the new location. + * Returns a modified version of the given content so that the links to the attachments point to the new note key. + * @param {String} oldPath Source of the note to be moved + * @param {String} newPath Destination of the note to be moved + * @param {String} noteKey Old note key + * @param {String} newNoteKey New note key + * @param {String} noteContent Content of the note to be moved + * @returns {String} Modified version of noteContent in which the paths of the attachments are fixed + */ +function moveAttachments (oldPath, newPath, noteKey, newNoteKey, noteContent) { + const src = path.join(oldPath, DESTINATION_FOLDER, noteKey) + const dest = path.join(newPath, DESTINATION_FOLDER, newNoteKey) + if (fse.existsSync(src)) { + fse.moveSync(src, dest) + } + return replaceNoteKeyWithNewNoteKey(noteContent, noteKey, newNoteKey) +} + +/** + * Modifies the given content so that in all attachment references the oldNoteKey is replaced by the new one + * @param noteContent content that should be modified + * @param oldNoteKey note key to be replaced + * @param newNoteKey note key serving as a replacement + * @returns {String} modified note content + */ +function replaceNoteKeyWithNewNoteKey (noteContent, oldNoteKey, newNoteKey) { + if (noteContent) { + const preparedInput = noteContent.replace(new RegExp('[' + PATH_SEPARATORS + ']', 'g'), path.sep) + return preparedInput.replace(new RegExp(STORAGE_FOLDER_PLACEHOLDER + escapeStringRegexp(path.sep) + oldNoteKey, 'g'), path.join(STORAGE_FOLDER_PLACEHOLDER, newNoteKey)) + } + return noteContent +} + +/** + * @description Deletes all :storage and noteKey references from the given input. + * @param input Input in which the references should be deleted + * @param noteKey Key of the current note + * @returns {String} Input without the references + */ +function removeStorageAndNoteReferences (input, noteKey) { + return input.replace(new RegExp(mdurl.encode(path.sep), 'g'), path.sep).replace(new RegExp(STORAGE_FOLDER_PLACEHOLDER + '(' + escapeStringRegexp(path.sep) + noteKey + ')?', 'g'), DESTINATION_FOLDER) +} + +/** + * @description Deletes the attachment folder specified by the given storageKey and noteKey + * @param storageKey Key of the storage of the note to be deleted + * @param noteKey Key of the note to be deleted + */ +function deleteAttachmentFolder (storageKey, noteKey) { + const storagePath = findStorage.findStorage(storageKey) + const noteAttachmentPath = path.join(storagePath.path, DESTINATION_FOLDER, noteKey) + sander.rimrafSync(noteAttachmentPath) +} + +/** + * @description Deletes all attachments stored in the attachment folder of the give not that are not referenced in the markdownContent + * @param markdownContent Content of the note. All unreferenced notes will be deleted + * @param storageKey StorageKey of the current note. Is used to determine the belonging attachment folder. + * @param noteKey NoteKey of the current note. Is used to determine the belonging attachment folder. + */ +function deleteAttachmentsNotPresentInNote (markdownContent, storageKey, noteKey) { + if (storageKey == null || noteKey == null || markdownContent == null) { + return + } + const targetStorage = findStorage.findStorage(storageKey) + const attachmentFolder = path.join(targetStorage.path, DESTINATION_FOLDER, noteKey) + const attachmentsInNote = getAttachmentsInMarkdownContent(markdownContent) + const attachmentsInNoteOnlyFileNames = [] + if (attachmentsInNote) { + for (let i = 0; i < attachmentsInNote.length; i++) { + attachmentsInNoteOnlyFileNames.push(attachmentsInNote[i].replace(new RegExp(STORAGE_FOLDER_PLACEHOLDER + escapeStringRegexp(path.sep) + noteKey + escapeStringRegexp(path.sep), 'g'), '')) + } + } + if (fs.existsSync(attachmentFolder)) { + fs.readdir(attachmentFolder, (err, files) => { + if (err) { + console.error('Error reading directory "' + attachmentFolder + '". Error:') + console.error(err) + return + } + files.forEach(file => { + if (!attachmentsInNoteOnlyFileNames.includes(file)) { + const absolutePathOfFile = path.join(targetStorage.path, DESTINATION_FOLDER, noteKey, file) + fs.unlink(absolutePathOfFile, (err) => { + if (err) { + console.error('Could not delete "%s"', absolutePathOfFile) + console.error(err) + return + } + console.info('File "' + absolutePathOfFile + '" deleted because it was not included in the content of the note') + }) + } + }) + }) + } else { + console.info('Attachment folder ("' + attachmentFolder + '") did not exist..') + } +} + +/** + * Clones the attachments of a given note. + * Copies the attachments to their new destination and updates the content of the new note so that the attachment-links again point to the correct destination. + * @param oldNote Note that is being cloned + * @param newNote Clone of the note + */ +function cloneAttachments (oldNote, newNote) { + if (newNote.type === 'MARKDOWN_NOTE') { + const oldStorage = findStorage.findStorage(oldNote.storage) + const newStorage = findStorage.findStorage(newNote.storage) + const attachmentsPaths = getAbsolutePathsOfAttachmentsInContent(oldNote.content, oldStorage.path) || [] + + const destinationFolder = path.join(newStorage.path, DESTINATION_FOLDER, newNote.key) + if (!sander.existsSync(destinationFolder)) { + sander.mkdirSync(destinationFolder) + } + + for (const attachment of attachmentsPaths) { + const destination = path.join(newStorage.path, DESTINATION_FOLDER, newNote.key, path.basename(attachment)) + sander.copyFileSync(attachment).to(destination) + } + newNote.content = replaceNoteKeyWithNewNoteKey(newNote.content, oldNote.key, newNote.key) + } else { + console.debug('Cloning of the attachment was skipped since it only works for MARKDOWN_NOTEs') + } +} + +function generateFileNotFoundMarkdown () { + return '**' + i18n.__('⚠ You have pasted a link referring an attachment that could not be found in the storage location of this note. Pasting links referring attachments is only supported if the source and destination location is the same storage. Please Drag&Drop the attachment instead! ⚠') + '**' +} + +/** + * Determines whether a given text is a link to an boostnote attachment + * @param text Text that might contain a attachment link + * @return {Boolean} Result of the test + */ +function isAttachmentLink (text) { + if (text) { + return text.match(new RegExp('.*\\[.*\\]\\( *' + escapeStringRegexp(STORAGE_FOLDER_PLACEHOLDER) + '[' + PATH_SEPARATORS + ']' + '.*\\).*', 'gi')) != null + } + return false +} + +/** + * @description Handles the paste of an attachment link. Copies the referenced attachment to the location belonging to the new note. + * Returns a modified version of the pasted text so that it matches the copied attachment (resp. the new location) + * @param storageKey StorageKey of the current note + * @param noteKey NoteKey of the currentNote + * @param linkText Text that was pasted + * @return {Promise} Promise returning the modified text + */ +function handleAttachmentLinkPaste (storageKey, noteKey, linkText) { + if (storageKey != null && noteKey != null && linkText != null) { + const storagePath = findStorage.findStorage(storageKey).path + const attachments = getAttachmentsInMarkdownContent(linkText) || [] + const replaceInstructions = [] + const copies = [] + for (const attachment of attachments) { + const absPathOfAttachment = attachment.replace(new RegExp(STORAGE_FOLDER_PLACEHOLDER, 'g'), path.join(storagePath, DESTINATION_FOLDER)) + copies.push( + sander.exists(absPathOfAttachment) + .then((fileExists) => { + if (!fileExists) { + const fileNotFoundRegexp = new RegExp('!?' + escapeStringRegexp('[') + '[\\w|\\d|\\s|\\.]*\\]\\(\\s*' + STORAGE_FOLDER_PLACEHOLDER + '[\\w|\\d|\\-|' + PATH_SEPARATORS + ']*' + escapeStringRegexp(path.basename(absPathOfAttachment)) + escapeStringRegexp(')')) + replaceInstructions.push({regexp: fileNotFoundRegexp, replacement: this.generateFileNotFoundMarkdown()}) + return Promise.resolve() + } + return this.copyAttachment(absPathOfAttachment, storageKey, noteKey) + .then((fileName) => { + const replaceLinkRegExp = new RegExp(escapeStringRegexp('(') + ' *' + STORAGE_FOLDER_PLACEHOLDER + '[\\w|\\d|\\-|' + PATH_SEPARATORS + ']*' + escapeStringRegexp(path.basename(absPathOfAttachment)) + ' *' + escapeStringRegexp(')')) + replaceInstructions.push({ + regexp: replaceLinkRegExp, + replacement: '(' + path.join(STORAGE_FOLDER_PLACEHOLDER, noteKey, fileName) + ')' + }) + return Promise.resolve() + }) + }) + ) + } + return Promise.all(copies).then(() => { + let modifiedLinkText = linkText + for (const replaceInstruction of replaceInstructions) { + modifiedLinkText = modifiedLinkText.replace(replaceInstruction.regexp, replaceInstruction.replacement) + } + return modifiedLinkText + }) + } else { + console.log('One if the parameters was null -> Do nothing..') + return Promise.resolve(linkText) + } +} + +module.exports = { + copyAttachment, + fixLocalURLS, + generateAttachmentMarkdown, + handleAttachmentDrop, + handlePastImageEvent, + getAttachmentsInMarkdownContent, + getAbsolutePathsOfAttachmentsInContent, + removeStorageAndNoteReferences, + deleteAttachmentFolder, + deleteAttachmentsNotPresentInNote, + moveAttachments, + cloneAttachments, + isAttachmentLink, + handleAttachmentLinkPaste, + generateFileNotFoundMarkdown, + migrateAttachments, + STORAGE_FOLDER_PLACEHOLDER, + DESTINATION_FOLDER +} diff --git a/browser/main/lib/dataApi/copyFile.js b/browser/main/lib/dataApi/copyFile.js new file mode 100755 index 00000000..2dc66309 --- /dev/null +++ b/browser/main/lib/dataApi/copyFile.js @@ -0,0 +1,31 @@ +const fs = require('fs') +const path = require('path') + +/** + * @description Copy a file from source to destination + * @param {String} srcPath + * @param {String} dstPath + * @return {Promise} an image path + */ +function copyFile (srcPath, dstPath) { + if (!path.extname(dstPath)) { + dstPath = path.join(dstPath, path.basename(srcPath)) + } + + return new Promise((resolve, reject) => { + const dstFolder = path.dirname(dstPath) + if (!fs.existsSync(dstFolder)) fs.mkdirSync(dstFolder) + + const input = fs.createReadStream(srcPath) + const output = fs.createWriteStream(dstPath) + + output.on('error', reject) + input.on('error', reject) + input.on('end', () => { + resolve(dstPath) + }) + input.pipe(output) + }) +} + +module.exports = copyFile diff --git a/browser/main/lib/dataApi/copyImage.js b/browser/main/lib/dataApi/copyImage.js deleted file mode 100644 index ae79f8fb..00000000 --- a/browser/main/lib/dataApi/copyImage.js +++ /dev/null @@ -1,31 +0,0 @@ -const fs = require('fs') -const path = require('path') -const { findStorage } = require('browser/lib/findStorage') - -/** - * @description To copy an image and return the path. - * @param {String} filePath - * @param {String} storageKey - * @return {String} an image path - */ -function copyImage (filePath, storageKey) { - return new Promise((resolve, reject) => { - try { - const targetStorage = findStorage(storageKey) - - const inputImage = fs.createReadStream(filePath) - const imageExt = path.extname(filePath) - const imageName = Math.random().toString(36).slice(-16) - const basename = `${imageName}${imageExt}` - const imageDir = path.join(targetStorage.path, 'images') - if (!fs.existsSync(imageDir)) fs.mkdirSync(imageDir) - const outputImage = fs.createWriteStream(path.join(imageDir, basename)) - inputImage.pipe(outputImage) - resolve(basename) - } catch (e) { - return reject(e) - } - }) -} - -module.exports = copyImage diff --git a/browser/main/lib/dataApi/createNote.js b/browser/main/lib/dataApi/createNote.js index 4b667385..e5d44489 100644 --- a/browser/main/lib/dataApi/createNote.js +++ b/browser/main/lib/dataApi/createNote.js @@ -52,12 +52,12 @@ function createNote (storageKey, input) { return storage }) .then(function saveNote (storage) { - let key = keygen() + let key = keygen(true) let isUnique = false while (!isUnique) { try { sander.statSync(path.join(storage.path, 'notes', key + '.cson')) - key = keygen() + key = keygen(true) } catch (err) { if (err.code === 'ENOENT') { isUnique = true diff --git a/browser/main/lib/dataApi/createSnippet.js b/browser/main/lib/dataApi/createSnippet.js new file mode 100644 index 00000000..5d189217 --- /dev/null +++ b/browser/main/lib/dataApi/createSnippet.js @@ -0,0 +1,26 @@ +import fs from 'fs' +import crypto from 'crypto' +import consts from 'browser/lib/consts' +import fetchSnippet from 'browser/main/lib/dataApi/fetchSnippet' + +function createSnippet (snippetFile) { + return new Promise((resolve, reject) => { + const newSnippet = { + id: crypto.randomBytes(16).toString('hex'), + name: 'Unnamed snippet', + prefix: [], + content: '' + } + fetchSnippet(null, snippetFile).then((snippets) => { + snippets.push(newSnippet) + fs.writeFile(snippetFile || consts.SNIPPET_FILE, JSON.stringify(snippets, null, 4), (err) => { + if (err) reject(err) + resolve(newSnippet) + }) + }).catch((err) => { + reject(err) + }) + }) +} + +module.exports = createSnippet diff --git a/browser/main/lib/dataApi/deleteFolder.js b/browser/main/lib/dataApi/deleteFolder.js index 908677e1..0c7486f5 100644 --- a/browser/main/lib/dataApi/deleteFolder.js +++ b/browser/main/lib/dataApi/deleteFolder.js @@ -5,6 +5,7 @@ const resolveStorageNotes = require('./resolveStorageNotes') const CSON = require('@rokt33r/season') const sander = require('sander') const { findStorage } = require('browser/lib/findStorage') +const deleteSingleNote = require('./deleteNote') /** * @param {String} storageKey @@ -49,11 +50,7 @@ function deleteFolder (storageKey, folderKey) { const deleteAllNotes = targetNotes .map(function deleteNote (note) { - const notePath = path.join(storage.path, 'notes', note.key + '.cson') - return sander.unlink(notePath) - .catch(function (err) { - console.warn('Failed to delete', notePath, err) - }) + return deleteSingleNote(storageKey, note.key) }) return Promise.all(deleteAllNotes) .then(() => storage) diff --git a/browser/main/lib/dataApi/deleteNote.js b/browser/main/lib/dataApi/deleteNote.js index 49498a30..46ec2b55 100644 --- a/browser/main/lib/dataApi/deleteNote.js +++ b/browser/main/lib/dataApi/deleteNote.js @@ -1,6 +1,7 @@ const resolveStorageData = require('./resolveStorageData') const path = require('path') const sander = require('sander') +const attachmentManagement = require('./attachmentManagement') const { findStorage } = require('browser/lib/findStorage') function deleteNote (storageKey, noteKey) { @@ -25,6 +26,10 @@ function deleteNote (storageKey, noteKey) { storageKey } }) + .then(function deleteAttachments (storageInfo) { + attachmentManagement.deleteAttachmentFolder(storageInfo.storageKey, storageInfo.noteKey) + return storageInfo + }) } module.exports = deleteNote diff --git a/browser/main/lib/dataApi/deleteSnippet.js b/browser/main/lib/dataApi/deleteSnippet.js new file mode 100644 index 00000000..0e446886 --- /dev/null +++ b/browser/main/lib/dataApi/deleteSnippet.js @@ -0,0 +1,17 @@ +import fs from 'fs' +import consts from 'browser/lib/consts' +import fetchSnippet from 'browser/main/lib/dataApi/fetchSnippet' + +function deleteSnippet (snippet, snippetFile) { + return new Promise((resolve, reject) => { + fetchSnippet(null, snippetFile).then((snippets) => { + snippets = snippets.filter(currentSnippet => currentSnippet.id !== snippet.id) + fs.writeFile(snippetFile || consts.SNIPPET_FILE, JSON.stringify(snippets, null, 4), (err) => { + if (err) reject(err) + resolve(snippet) + }) + }) + }) +} + +module.exports = deleteSnippet diff --git a/browser/main/lib/dataApi/exportFolder.js b/browser/main/lib/dataApi/exportFolder.js index bb3b2834..3e998f15 100644 --- a/browser/main/lib/dataApi/exportFolder.js +++ b/browser/main/lib/dataApi/exportFolder.js @@ -1,6 +1,7 @@ import { findStorage } from 'browser/lib/findStorage' import resolveStorageData from './resolveStorageData' import resolveStorageNotes from './resolveStorageNotes' +import filenamify from 'filenamify' import * as path from 'path' import * as fs from 'fs' @@ -45,7 +46,7 @@ function exportFolder (storageKey, folderKey, fileType, exportDir) { notes .filter(note => note.folder === folderKey && note.isTrashed === false && note.type === 'MARKDOWN_NOTE') .forEach(snippet => { - const notePath = path.join(exportDir, `${snippet.title}.${fileType}`) + const notePath = path.join(exportDir, `${filenamify(snippet.title, {replacement: '_'})}.${fileType}`) fs.writeFileSync(notePath, snippet.content) }) diff --git a/browser/main/lib/dataApi/exportNote.js b/browser/main/lib/dataApi/exportNote.js new file mode 100755 index 00000000..e4fec5f4 --- /dev/null +++ b/browser/main/lib/dataApi/exportNote.js @@ -0,0 +1,94 @@ +import copyFile from 'browser/main/lib/dataApi/copyFile' +import { findStorage } from 'browser/lib/findStorage' + +const fs = require('fs') +const path = require('path') + +/** + * Export note together with images + * + * If images is stored in the storage, creates 'images' subfolder in target directory + * and copies images to it. Changes links to images in the content of the note + * + * @param {String} storageKey or storage path + * @param {String} noteContent Content to export + * @param {String} targetPath Path to exported file + * @param {function} outputFormatter + * @return {Promise.<*[]>} + */ +function exportNote (storageKey, noteContent, targetPath, outputFormatter) { + const storagePath = path.isAbsolute(storageKey) ? storageKey : findStorage(storageKey).path + const exportTasks = [] + + if (!storagePath) { + throw new Error('Storage path is not found') + } + + let exportedData = noteContent + + if (outputFormatter) { + exportedData = outputFormatter(exportedData, exportTasks) + } + + const tasks = prepareTasks(exportTasks, storagePath, path.dirname(targetPath)) + + return Promise.all(tasks.map((task) => copyFile(task.src, task.dst))) + .then(() => { + return saveToFile(exportedData, targetPath) + }).catch((err) => { + rollbackExport(tasks) + throw err + }) +} + +function prepareTasks (tasks, storagePath, targetPath) { + return tasks.map((task) => { + if (!path.isAbsolute(task.src)) { + task.src = path.join(storagePath, task.src) + } + + if (!path.isAbsolute(task.dst)) { + task.dst = path.join(targetPath, task.dst) + } + + return task + }) +} + +function saveToFile (data, filename) { + return new Promise((resolve, reject) => { + fs.writeFile(filename, data, (err) => { + if (err) return reject(err) + + resolve(filename) + }) + }) +} + +/** + * Remove exported files + * @param tasks Array of copy task objects. Object consists of two mandatory fields – `src` and `dst` + */ +function rollbackExport (tasks) { + const folders = new Set() + tasks.forEach((task) => { + let fullpath = task.dst + + if (!path.extname(task.dst)) { + fullpath = path.join(task.dst, path.basename(task.src)) + } + + if (fs.existsSync(fullpath)) { + fs.unlink(fullpath) + folders.add(path.dirname(fullpath)) + } + }) + + folders.forEach((folder) => { + if (fs.readdirSync(folder).length === 0) { + fs.rmdir(folder) + } + }) +} + +export default exportNote diff --git a/browser/main/lib/dataApi/fetchSnippet.js b/browser/main/lib/dataApi/fetchSnippet.js new file mode 100644 index 00000000..456a5090 --- /dev/null +++ b/browser/main/lib/dataApi/fetchSnippet.js @@ -0,0 +1,20 @@ +import fs from 'fs' +import consts from 'browser/lib/consts' + +function fetchSnippet (id, snippetFile) { + return new Promise((resolve, reject) => { + fs.readFile(snippetFile || consts.SNIPPET_FILE, 'utf8', (err, data) => { + if (err) { + reject(err) + } + const snippets = JSON.parse(data) + if (id) { + const snippet = snippets.find(snippet => { return snippet.id === id }) + resolve(snippet) + } + resolve(snippets) + }) + }) +} + +module.exports = fetchSnippet diff --git a/browser/main/lib/dataApi/index.js b/browser/main/lib/dataApi/index.js index 311ca2f3..4e2f0061 100644 --- a/browser/main/lib/dataApi/index.js +++ b/browser/main/lib/dataApi/index.js @@ -1,5 +1,6 @@ const dataApi = { init: require('./init'), + toggleStorage: require('./toggleStorage'), addStorage: require('./addStorage'), renameStorage: require('./renameStorage'), removeStorage: require('./removeStorage'), @@ -13,6 +14,10 @@ const dataApi = { deleteNote: require('./deleteNote'), moveNote: require('./moveNote'), migrateFromV5Storage: require('./migrateFromV5Storage'), + createSnippet: require('./createSnippet'), + deleteSnippet: require('./deleteSnippet'), + updateSnippet: require('./updateSnippet'), + fetchSnippet: require('./fetchSnippet'), _migrateFromV6Storage: require('./migrateFromV6Storage'), _resolveStorageData: require('./resolveStorageData'), diff --git a/browser/main/lib/dataApi/moveNote.js b/browser/main/lib/dataApi/moveNote.js index 4580062e..2d306cdf 100644 --- a/browser/main/lib/dataApi/moveNote.js +++ b/browser/main/lib/dataApi/moveNote.js @@ -1,10 +1,12 @@ const resolveStorageData = require('./resolveStorageData') const _ = require('lodash') const path = require('path') +const fs = require('fs') const CSON = require('@rokt33r/season') const keygen = require('browser/lib/keygen') const sander = require('sander') const { findStorage } = require('browser/lib/findStorage') +const attachmentManagement = require('./attachmentManagement') function moveNote (storageKey, noteKey, newStorageKey, newFolderKey) { let oldStorage, newStorage @@ -37,12 +39,12 @@ function moveNote (storageKey, noteKey, newStorageKey, newFolderKey) { return resolveStorageData(newStorage) .then(function findNewNoteKey (_newStorage) { newStorage = _newStorage - newNoteKey = keygen() + newNoteKey = keygen(true) let isUnique = false while (!isUnique) { try { sander.statSync(path.join(newStorage.path, 'notes', newNoteKey + '.cson')) - newNoteKey = keygen() + newNoteKey = keygen(true) } catch (err) { if (err.code === 'ENOENT') { isUnique = true @@ -62,11 +64,20 @@ function moveNote (storageKey, noteKey, newStorageKey, newFolderKey) { noteData.key = newNoteKey noteData.storage = newStorageKey noteData.updatedAt = new Date() + noteData.oldContent = noteData.content return noteData }) + .then(function moveAttachments (noteData) { + if (oldStorage.path === newStorage.path) { + return noteData + } + + noteData.content = attachmentManagement.moveAttachments(oldStorage.path, newStorage.path, noteKey, newNoteKey, noteData.content) + return noteData + }) .then(function writeAndReturn (noteData) { - CSON.writeFileSync(path.join(newStorage.path, 'notes', noteData.key + '.cson'), _.omit(noteData, ['key', 'storage'])) + CSON.writeFileSync(path.join(newStorage.path, 'notes', noteData.key + '.cson'), _.omit(noteData, ['key', 'storage', 'oldContent'])) return noteData }) .then(function deleteOldNote (data) { diff --git a/browser/main/lib/dataApi/resolveStorageData.js b/browser/main/lib/dataApi/resolveStorageData.js index af040c5d..681a102e 100644 --- a/browser/main/lib/dataApi/resolveStorageData.js +++ b/browser/main/lib/dataApi/resolveStorageData.js @@ -8,7 +8,8 @@ function resolveStorageData (storageCache) { key: storageCache.key, name: storageCache.name, type: storageCache.type, - path: storageCache.path + path: storageCache.path, + isOpen: storageCache.isOpen } const boostnoteJSONPath = path.join(storageCache.path, 'boostnote.json') diff --git a/browser/main/lib/dataApi/resolveStorageNotes.js b/browser/main/lib/dataApi/resolveStorageNotes.js index 5684f06e..fa3f19ae 100644 --- a/browser/main/lib/dataApi/resolveStorageNotes.js +++ b/browser/main/lib/dataApi/resolveStorageNotes.js @@ -27,9 +27,12 @@ function resolveStorageNotes (storage) { data.storage = storage.key return data } catch (err) { - console.error(notePath) + console.error(`error on note path: ${notePath}, error: ${err}`) } }) + .filter(function filterOnlyNoteObject (noteObj) { + return typeof noteObj === 'object' + }) return Promise.resolve(notes) } diff --git a/browser/main/lib/dataApi/toggleStorage.js b/browser/main/lib/dataApi/toggleStorage.js new file mode 100644 index 00000000..dbb625c3 --- /dev/null +++ b/browser/main/lib/dataApi/toggleStorage.js @@ -0,0 +1,28 @@ +const _ = require('lodash') +const resolveStorageData = require('./resolveStorageData') + +/** + * @param {String} key + * @param {Boolean} isOpen + * @return {Object} Storage meta data + */ +function toggleStorage (key, isOpen) { + let cachedStorageList + try { + cachedStorageList = JSON.parse(localStorage.getItem('storages')) + if (!_.isArray(cachedStorageList)) throw new Error('invalid storages') + } catch (err) { + console.log('error got') + console.error(err) + return Promise.reject(err) + } + const targetStorage = _.find(cachedStorageList, {key: key}) + if (targetStorage == null) return Promise.reject('Storage') + + targetStorage.isOpen = isOpen + localStorage.setItem('storages', JSON.stringify(cachedStorageList)) + + return resolveStorageData(targetStorage) +} + +module.exports = toggleStorage diff --git a/browser/main/lib/dataApi/updateNote.js b/browser/main/lib/dataApi/updateNote.js index 2fbd52c2..147fbc06 100644 --- a/browser/main/lib/dataApi/updateNote.js +++ b/browser/main/lib/dataApi/updateNote.js @@ -30,6 +30,9 @@ function validateInput (input) { validatedInput.isPinned = !!input.isPinned } + if (!_.isNil(input.blog)) { + validatedInput.blog = input.blog + } validatedInput.type = input.type switch (input.type) { case 'MARKDOWN_NOTE': diff --git a/browser/main/lib/dataApi/updateSnippet.js b/browser/main/lib/dataApi/updateSnippet.js new file mode 100644 index 00000000..f2310b8e --- /dev/null +++ b/browser/main/lib/dataApi/updateSnippet.js @@ -0,0 +1,33 @@ +import fs from 'fs' +import consts from 'browser/lib/consts' + +function updateSnippet (snippet, snippetFile) { + return new Promise((resolve, reject) => { + const snippets = JSON.parse(fs.readFileSync(snippetFile || consts.SNIPPET_FILE, 'utf-8')) + + for (let i = 0; i < snippets.length; i++) { + const currentSnippet = snippets[i] + + if (currentSnippet.id === snippet.id) { + if ( + currentSnippet.name === snippet.name && + currentSnippet.prefix === snippet.prefix && + currentSnippet.content === snippet.content + ) { + // if everything is the same then don't write to disk + resolve(snippets) + } else { + currentSnippet.name = snippet.name + currentSnippet.prefix = snippet.prefix + currentSnippet.content = snippet.content + fs.writeFile(snippetFile || consts.SNIPPET_FILE, JSON.stringify(snippets, null, 4), (err) => { + if (err) reject(err) + resolve(snippets) + }) + } + } + } + }) +} + +module.exports = updateSnippet diff --git a/browser/main/lib/ipcClient.js b/browser/main/lib/ipcClient.js index 12acfed2..0c916617 100644 --- a/browser/main/lib/ipcClient.js +++ b/browser/main/lib/ipcClient.js @@ -1,5 +1,4 @@ import ConfigManager from './ConfigManager' -import store from 'browser/main/store' const nodeIpc = require('node-ipc') const { remote, ipcRenderer } = require('electron') @@ -18,26 +17,12 @@ nodeIpc.connectTo( console.log(err) }) nodeIpc.of.node.on('connect', function () { - console.log('Conncted successfully') + console.log('Connected successfully') ipcRenderer.send('config-renew', {config: ConfigManager.get()}) }) nodeIpc.of.node.on('disconnect', function () { console.log('disconnected') }) - - nodeIpc.of.node.on('request-data-from-finder', function () { - console.log('throttle') - var { data } = store.getState() - console.log(data.starredSet.toJS()) - nodeIpc.of.node.emit('throttle-data', { - storageMap: data.storageMap.toJS(), - noteMap: data.noteMap.toJS(), - starredSet: data.starredSet.toJS(), - storageNoteMap: data.storageNoteMap.toJS(), - folderNoteMap: data.folderNoteMap.toJS(), - tagNoteMap: data.tagNoteMap.toJS() - }) - }) } ) diff --git a/browser/main/lib/shortcut.js b/browser/main/lib/shortcut.js new file mode 100644 index 00000000..a6f33196 --- /dev/null +++ b/browser/main/lib/shortcut.js @@ -0,0 +1,7 @@ +import ee from 'browser/main/lib/eventEmitter' + +module.exports = { + 'toggleMode': () => { + ee.emit('topbar:togglemodebutton') + } +} diff --git a/browser/main/lib/shortcutManager.js b/browser/main/lib/shortcutManager.js new file mode 100644 index 00000000..ac2a3a08 --- /dev/null +++ b/browser/main/lib/shortcutManager.js @@ -0,0 +1,40 @@ +import Mousetrap from 'mousetrap' +import CM from 'browser/main/lib/ConfigManager' +import ee from 'browser/main/lib/eventEmitter' +import { isObjectEqual } from 'browser/lib/utils' +require('mousetrap-global-bind') +import functions from './shortcut' + +let shortcuts = CM.get().hotkey + +ee.on('config-renew', function () { + // only update if hotkey changed ! + const newHotkey = CM.get().hotkey + if (!isObjectEqual(newHotkey, shortcuts)) { + updateShortcut(newHotkey) + } +}) + +function updateShortcut (newHotkey) { + Mousetrap.reset() + shortcuts = newHotkey + applyShortcuts(newHotkey) +} + +function formatShortcut (shortcut) { + return shortcut.toLowerCase().replace(/ /g, '') +} + +function applyShortcuts (shortcuts) { + for (const shortcut in shortcuts) { + const toggler = formatShortcut(shortcuts[shortcut]) + // only bind if the function for that shortcut exists + if (functions[shortcut]) { + Mousetrap.bindGlobal(toggler, functions[shortcut]) + } + } +} + +applyShortcuts(CM.get().hotkey) + +module.exports = applyShortcuts diff --git a/browser/main/modals/CreateFolderModal.js b/browser/main/modals/CreateFolderModal.js index e623ad8d..b061b0f3 100644 --- a/browser/main/modals/CreateFolderModal.js +++ b/browser/main/modals/CreateFolderModal.js @@ -7,6 +7,7 @@ import store from 'browser/main/store' import consts from 'browser/lib/consts' import ModalEscButton from 'browser/components/ModalEscButton' import AwsMobileAnalyticsConfig from 'browser/main/lib/AwsMobileAnalyticsConfig' +import i18n from 'browser/lib/i18n' class CreateFolderModal extends React.Component { constructor (props) { @@ -79,12 +80,12 @@ class CreateFolderModal extends React.Component { onKeyDown={(e) => this.handleKeyDown(e)} >
-
Create new folder
+
{i18n.__('Create new folder')}
this.handleCloseButtonClick(e)} />
-
Folder name
+
{i18n.__('Folder name')}
this.handleConfirmButtonClick(e)} > - Create + {i18n.__('Create')}
diff --git a/browser/main/modals/CreateFolderModal.styl b/browser/main/modals/CreateFolderModal.styl index 45f2e852..1b96e123 100644 --- a/browser/main/modals/CreateFolderModal.styl +++ b/browser/main/modals/CreateFolderModal.styl @@ -102,3 +102,29 @@ body[data-theme="solarized-dark"] .control-confirmButton colorSolarizedDarkPrimaryButton() + +body[data-theme="monokai"] + .root + modalMonokai() + width 500px + height 270px + overflow hidden + position relative + + .header + background-color transparent + border-color $ui-dark-borderColor + color $ui-monokai-text-color + + .control-folder-label + color $ui-monokai-text-color + + .control-folder-input + border 1px solid $ui-input--create-folder-modal + color white + + .description + color $ui-inactive-text-color + + .control-confirmButton + colorMonokaiPrimaryButton() diff --git a/browser/main/modals/NewNoteModal.js b/browser/main/modals/NewNoteModal.js index 24b150cb..f6aa2c67 100644 --- a/browser/main/modals/NewNoteModal.js +++ b/browser/main/modals/NewNoteModal.js @@ -6,13 +6,13 @@ import { hashHistory } from 'react-router' import ee from 'browser/main/lib/eventEmitter' import ModalEscButton from 'browser/components/ModalEscButton' import AwsMobileAnalyticsConfig from 'browser/main/lib/AwsMobileAnalyticsConfig' +import i18n from 'browser/lib/i18n' class NewNoteModal extends React.Component { constructor (props) { super(props) - this.state = { - } + this.state = {} } componentDidMount () { @@ -34,17 +34,20 @@ class NewNoteModal extends React.Component { title: '', content: '' }) - .then((note) => { + .then(note => { + const noteHash = note.key dispatch({ type: 'UPDATE_NOTE', note: note }) + hashHistory.push({ pathname: location.pathname, - query: {key: note.storage + '-' + note.key} + query: { key: noteHash } }) + ee.emit('list:jump', noteHash) ee.emit('detail:focus') - this.props.close() + setTimeout(this.props.close, 200) }) } @@ -58,7 +61,7 @@ class NewNoteModal extends React.Component { handleSnippetNoteButtonClick (e) { AwsMobileAnalyticsConfig.recordDynamicCustomEvent('ADD_SNIPPET') AwsMobileAnalyticsConfig.recordDynamicCustomEvent('ADD_ALLNOTE') - const { storage, folder, dispatch, location } = this.props + const { storage, folder, dispatch, location, config } = this.props dataApi .createNote(storage, { @@ -66,23 +69,27 @@ class NewNoteModal extends React.Component { folder: folder, title: '', description: '', - snippets: [{ - name: '', - mode: 'text', - content: '' - }] + snippets: [ + { + name: '', + mode: config.editor.snippetDefaultLanguage || 'text', + content: '' + } + ] }) - .then((note) => { + .then(note => { + const noteHash = note.key dispatch({ type: 'UPDATE_NOTE', note: note }) hashHistory.push({ pathname: location.pathname, - query: {key: note.storage + '-' + note.key} + query: { key: noteHash } }) + ee.emit('list:jump', noteHash) ee.emit('detail:focus') - this.props.close() + setTimeout(this.props.close, 200) }) } @@ -101,49 +108,65 @@ class NewNoteModal extends React.Component { render () { return ( -
this.handleKeyDown(e)} + onKeyDown={e => this.handleKeyDown(e)} >
-
Make a note
+
{i18n.__('Make a note')}
- this.handleCloseButtonClick(e)} /> + this.handleCloseButtonClick(e)} + />
- -
-
Tab to switch format
+
+ {i18n.__('Tab to switch format')} +
) } } -NewNoteModal.propTypes = { -} +NewNoteModal.propTypes = {} export default CSSModules(NewNoteModal, styles) diff --git a/browser/main/modals/NewNoteModal.styl b/browser/main/modals/NewNoteModal.styl index 748ab88c..db14133f 100644 --- a/browser/main/modals/NewNoteModal.styl +++ b/browser/main/modals/NewNoteModal.styl @@ -81,3 +81,19 @@ body[data-theme="solarized-dark"] .description color $ui-solarized-dark-text-color +body[data-theme="monokai"] + .root + background-color transparent + + .header + color $ui-monokai-text-color + + .control-button + border-color $ui-monokai-borderColor + color $ui-monokai-text-color + background-color transparent + &:focus + colorDarkPrimaryButton() + + .description + color $ui-monokai-text-color diff --git a/browser/main/modals/PreferencesModal/Blog.js b/browser/main/modals/PreferencesModal/Blog.js new file mode 100644 index 00000000..9f4d33f6 --- /dev/null +++ b/browser/main/modals/PreferencesModal/Blog.js @@ -0,0 +1,199 @@ +import React from 'react' +import CSSModules from 'browser/lib/CSSModules' +import styles from './ConfigTab.styl' +import ConfigManager from 'browser/main/lib/ConfigManager' +import store from 'browser/main/store' +import PropTypes from 'prop-types' +import _ from 'lodash' +import i18n from 'browser/lib/i18n' + +const electron = require('electron') +const { shell } = electron +const ipc = electron.ipcRenderer +class Blog extends React.Component { + constructor (props) { + super(props) + + this.state = { + config: props.config, + BlogAlert: null + } + } + + handleLinkClick (e) { + shell.openExternal(e.currentTarget.href) + e.preventDefault() + } + + clearMessage () { + _.debounce(() => { + this.setState({ + BlogAlert: null + }) + }, 2000)() + } + + componentDidMount () { + this.handleSettingDone = () => { + this.setState({BlogAlert: { + type: 'success', + message: i18n.__('Successfully applied!') + }}) + } + this.handleSettingError = (err) => { + this.setState({BlogAlert: { + type: 'error', + message: err.message != null ? err.message : i18n.__('Error occurs!') + }}) + } + this.oldBlog = this.state.config.blog + ipc.addListener('APP_SETTING_DONE', this.handleSettingDone) + ipc.addListener('APP_SETTING_ERROR', this.handleSettingError) + } + + handleBlogChange (e) { + const { config } = this.state + config.blog = { + password: !_.isNil(this.refs.passwordInput) ? this.refs.passwordInput.value : config.blog.password, + username: !_.isNil(this.refs.usernameInput) ? this.refs.usernameInput.value : config.blog.username, + token: !_.isNil(this.refs.tokenInput) ? this.refs.tokenInput.value : config.blog.token, + authMethod: this.refs.authMethodDropdown.value, + address: this.refs.addressInput.value, + type: this.refs.typeDropdown.value + } + this.setState({ + config + }) + if (_.isEqual(this.oldBlog, config.blog)) { + this.props.haveToSave() + } else { + this.props.haveToSave({ + tab: 'Blog', + type: 'warning', + message: i18n.__('You have to save!') + }) + } + } + + handleSaveButtonClick (e) { + const newConfig = { + blog: this.state.config.blog + } + + ConfigManager.set(newConfig) + + store.dispatch({ + type: 'SET_UI', + config: newConfig + }) + this.clearMessage() + this.props.haveToSave() + } + + render () { + const {config, BlogAlert} = this.state + const blogAlertElement = BlogAlert != null + ?

+ {BlogAlert.message} +

+ : null + return ( +
+
+
{i18n.__('Blog')}
+
+
+ {i18n.__('Blog Type')} +
+
+ +
+
+
+
{i18n.__('Blog Address')}
+
+ this.handleBlogChange(e)} + ref='addressInput' + value={config.blog.address} + type='text' + /> +
+
+
+ + {blogAlertElement} +
+
+
{i18n.__('Auth')}
+ +
+
+ {i18n.__('Authentication Method')} +
+
+ +
+
+ { config.blog.authMethod === 'JWT' && +
+
{i18n.__('Token')}
+
+ this.handleBlogChange(e)} + ref='tokenInput' + value={config.blog.token} + type='text' /> +
+
+ } + { config.blog.authMethod === 'USER' && +
+
+
{i18n.__('UserName')}
+
+ this.handleBlogChange(e)} + ref='usernameInput' + value={config.blog.username} + type='text' /> +
+
+
+
{i18n.__('Password')}
+
+ this.handleBlogChange(e)} + ref='passwordInput' + value={config.blog.password} + type='password' /> +
+
+
+ } +
+ ) + } +} + +Blog.propTypes = { + dispatch: PropTypes.func, + haveToSave: PropTypes.func +} + +export default CSSModules(Blog, styles) diff --git a/browser/main/modals/PreferencesModal/ConfigTab.styl b/browser/main/modals/PreferencesModal/ConfigTab.styl index f6f7ace9..31994d97 100644 --- a/browser/main/modals/PreferencesModal/ConfigTab.styl +++ b/browser/main/modals/PreferencesModal/ConfigTab.styl @@ -24,7 +24,7 @@ line-height 30px .group-section-label - width 150px + width 200px text-align left margin-right 10px font-size 14px @@ -133,6 +133,11 @@ colorSolarizedDarkControl() background-color $ui-solarized-dark-button-backgroundColor color $ui-solarized-dark-text-color +colorMonokaiControl() + border none + background-color $ui-monokai-button-backgroundColor + color $ui-monokai-text-color + body[data-theme="dark"] .root @@ -189,4 +194,29 @@ body[data-theme="solarized-dark"] select, .group-section-control-input colorSolarizedDarkControl() +body[data-theme="monokai"] + .root + color $ui-monokai-text-color + .group-header + color $ui-monokai-text-color + border-color $ui-monokai-borderColor + + .group-header2 + color $ui-monokai-text-color + + .group-section-control-input + border-color $ui-monokai-borderColor + + .group-control + border-color $ui-monokai-borderColor + .group-control-leftButton + colorDarkDefaultButton() + border-color $ui-monokai-borderColor + .group-control-rightButton + colorMonokaiPrimaryButton() + .group-hint + colorMonokaiControl() + .group-section-control + select, .group-section-control-input + colorMonokaiControl() diff --git a/browser/main/modals/PreferencesModal/Crowdfunding.js b/browser/main/modals/PreferencesModal/Crowdfunding.js index 3dccd27b..196c1cb3 100644 --- a/browser/main/modals/PreferencesModal/Crowdfunding.js +++ b/browser/main/modals/PreferencesModal/Crowdfunding.js @@ -1,6 +1,7 @@ import React from 'react' import CSSModules from 'browser/lib/CSSModules' import styles from './Crowdfunding.styl' +import i18n from 'browser/lib/i18n' const electron = require('electron') const { shell } = electron @@ -21,22 +22,22 @@ class Crowdfunding extends React.Component { render () { return (
-
Crowdfunding
-

Dear all,

+
{i18n.__('Crowdfunding')}
+

{i18n.__('Dear everyone,')}


-

Thanks for your using!

-

Boostnote is used in about 200 countries and regions, it is a awesome developer community.

+

{i18n.__('Thank you for using Boostnote!')}

+

{i18n.__('Boostnote is used in about 200 different countries and regions by an awesome community of developers.')}


-

To continue supporting this growth, and to satisfy community expectations,

-

we would like to invest more time in this project.

+

{i18n.__('To continue supporting this growth, and to satisfy community expectations,')}

+

{i18n.__('we would like to invest more time and resources in this project.')}


-

If you like this project and see its potential, you can help!

+

{i18n.__('If you like this project and see its potential, you can help by supporting us on OpenCollective!')}


-

Thanks,

-

Boostnote maintainers.

+

{i18n.__('Thanks,')}

+

{i18n.__('Boostnote maintainers')}


) diff --git a/browser/main/modals/PreferencesModal/Crowdfunding.styl b/browser/main/modals/PreferencesModal/Crowdfunding.styl index 930c33f0..326867d3 100644 --- a/browser/main/modals/PreferencesModal/Crowdfunding.styl +++ b/browser/main/modals/PreferencesModal/Crowdfunding.styl @@ -11,11 +11,12 @@ p font-size 16px .cf-link - width 250px height 35px border-radius 2px border none background-color alpha(#1EC38B, 90%) + padding-left 20px + padding-right 20px &:hover background-color #1EC38B transition 0.2s @@ -33,4 +34,10 @@ body[data-theme="solarized-dark"] .root color $ui-solarized-dark-text-color p - color $ui-solarized-dark-text-color \ No newline at end of file + color $ui-solarized-dark-text-color + +body[data-theme="monokai"] + .root + color $ui-monokai-text-color + p + color $ui-monokai-text-color diff --git a/browser/main/modals/PreferencesModal/FolderItem.js b/browser/main/modals/PreferencesModal/FolderItem.js index 9d1cd08f..dc9082b9 100644 --- a/browser/main/modals/PreferencesModal/FolderItem.js +++ b/browser/main/modals/PreferencesModal/FolderItem.js @@ -7,6 +7,7 @@ import dataApi from 'browser/main/lib/dataApi' import store from 'browser/main/store' import { SketchPicker } from 'react-color' import { SortableElement, SortableHandle } from 'react-sortable-hoc' +import i18n from 'browser/lib/i18n' class FolderItem extends React.Component { constructor (props) { @@ -150,12 +151,12 @@ class FolderItem extends React.Component {
@@ -179,18 +180,18 @@ class FolderItem extends React.Component { return (
- Are you sure to delete this folder? + {i18n.__('Are you sure to ')} {i18n.__(' delete')} {i18n.__('this folder?')}
@@ -231,12 +232,12 @@ class FolderItem extends React.Component {
diff --git a/browser/main/modals/PreferencesModal/FolderItem.styl b/browser/main/modals/PreferencesModal/FolderItem.styl index acc4cbfb..8bcf2b02 100644 --- a/browser/main/modals/PreferencesModal/FolderItem.styl +++ b/browser/main/modals/PreferencesModal/FolderItem.styl @@ -126,3 +126,26 @@ body[data-theme="solarized-dark"] .folderItem-right-dangerButton colorSolarizedDarkPrimaryButton() + +body[data-theme="monokai"] + .folderItem + &:hover + background-color $ui-monokai-button-backgroundColor + + .folderItem-left-danger + color $danger-color + + .folderItem-left-key + color $ui-dark-inactive-text-color + + .folderItem-left-colorButton + colorMonokaiPrimaryButton() + + .folderItem-right-button + colorMonokaiPrimaryButton() + + .folderItem-right-confirmButton + colorMonokaiPrimaryButton() + + .folderItem-right-dangerButton + colorMonokaiPrimaryButton() diff --git a/browser/main/modals/PreferencesModal/FolderList.js b/browser/main/modals/PreferencesModal/FolderList.js index 8585f641..e7cc6f94 100644 --- a/browser/main/modals/PreferencesModal/FolderList.js +++ b/browser/main/modals/PreferencesModal/FolderList.js @@ -6,6 +6,7 @@ import styles from './FolderList.styl' import store from 'browser/main/store' import FolderItem from './FolderItem' import { SortableContainer } from 'react-sortable-hoc' +import i18n from 'browser/lib/i18n' class FolderList extends React.Component { render () { @@ -24,7 +25,7 @@ class FolderList extends React.Component {
{folderList.length > 0 ? folderList - :
No Folders
+ :
{i18n.__('No Folders')}
}
) diff --git a/browser/main/modals/PreferencesModal/HotkeyTab.js b/browser/main/modals/PreferencesModal/HotkeyTab.js index 4b4a3060..671e1516 100644 --- a/browser/main/modals/PreferencesModal/HotkeyTab.js +++ b/browser/main/modals/PreferencesModal/HotkeyTab.js @@ -5,6 +5,7 @@ import styles from './ConfigTab.styl' import ConfigManager from 'browser/main/lib/ConfigManager' import store from 'browser/main/store' import _ from 'lodash' +import i18n from 'browser/lib/i18n' const electron = require('electron') const ipc = electron.ipcRenderer @@ -23,13 +24,13 @@ class HotkeyTab extends React.Component { this.handleSettingDone = () => { this.setState({keymapAlert: { type: 'success', - message: 'Successfully applied!' + message: i18n.__('Successfully applied!') }}) } this.handleSettingError = (err) => { this.setState({keymapAlert: { type: 'error', - message: err.message != null ? err.message : 'Error occurs!' + message: err.message != null ? err.message : i18n.__('Error occurs!') }}) } this.oldHotkey = this.state.config.hotkey @@ -66,8 +67,8 @@ class HotkeyTab extends React.Component { handleHotkeyChange (e) { const { config } = this.state config.hotkey = { - toggleFinder: this.refs.toggleFinder.value, - toggleMain: this.refs.toggleMain.value + toggleMain: this.refs.toggleMain.value, + toggleMode: this.refs.toggleMode.value } this.setState({ config @@ -78,7 +79,7 @@ class HotkeyTab extends React.Component { this.props.haveToSave({ tab: 'Hotkey', type: 'warning', - message: 'You have to save!' + message: i18n.__('You have to save!') }) } } @@ -103,9 +104,9 @@ class HotkeyTab extends React.Component { return (
-
Hotkey
+
{i18n.__('Hotkeys')}
-
Toggle Main
+
{i18n.__('Show/Hide Boostnote')}
this.handleHotkeyChange(e)} @@ -116,12 +117,12 @@ class HotkeyTab extends React.Component {
-
Toggle Finder (Quick search)
+
{i18n.__('Toggle editor mode')}
this.handleHotkeyChange(e)} - ref='toggleFinder' - value={config.hotkey.toggleFinder} + ref='toggleMode' + value={config.hotkey.toggleMode} type='text' />
@@ -131,18 +132,18 @@ class HotkeyTab extends React.Component { onClick={(e) => this.handleHintToggleButtonClick(e)} > {this.state.isHotkeyHintOpen - ? 'Hide Hint' - : 'Hint?' + ? i18n.__('Hide Help') + : i18n.__('Help') } {keymapAlertElement}
{this.state.isHotkeyHintOpen &&
-

Available Keys

+

{i18n.__('Available Keys')}

  • 0 to 9
  • A to Z
  • diff --git a/browser/main/modals/PreferencesModal/InfoTab.js b/browser/main/modals/PreferencesModal/InfoTab.js index 2a1db828..1b2d55bb 100644 --- a/browser/main/modals/PreferencesModal/InfoTab.js +++ b/browser/main/modals/PreferencesModal/InfoTab.js @@ -5,6 +5,7 @@ import ConfigManager from 'browser/main/lib/ConfigManager' import store from 'browser/main/store' import AwsMobileAnalyticsConfig from 'browser/main/lib/AwsMobileAnalyticsConfig' import _ from 'lodash' +import i18n from 'browser/lib/i18n' const electron = require('electron') const { shell, remote } = electron @@ -31,18 +32,18 @@ class InfoTab extends React.Component { } handleSaveButtonClick (e) { - let newConfig = { + const newConfig = { amaEnabled: this.state.config.amaEnabled } if (!newConfig.amaEnabled) { AwsMobileAnalyticsConfig.recordDynamicCustomEvent('DISABLE_AMA') this.setState({ - amaMessage: 'We hope we will gain your trust' + amaMessage: i18n.__('We hope we will gain your trust') }) } else { this.setState({ - amaMessage: 'Thank\'s for trust us' + amaMessage: i18n.__('Thank\'s for trusting us') }) } @@ -69,48 +70,48 @@ class InfoTab extends React.Component { return (
    -
    Community
    +
    {i18n.__('Community')}

    -
    Info
    +
    {i18n.__('About')}
    -
    Boostnote {appVersion}
    +
    {i18n.__('Boostnote')} {appVersion}
    - An open source note-taking app made for programmers just like you. + {i18n.__('An open source note-taking app made for programmers just like you.')}
    @@ -120,34 +121,36 @@ class InfoTab extends React.Component {
  • this.handleLinkClick(e)} - >Website + >{i18n.__('Website')}
  • this.handleLinkClick(e)} - >Development : Development configurations for Boostnote. + >{i18n.__('Development')}{i18n.__(' : Development configurations for Boostnote.')}
  • - Copyright (C) 2017 Maisin&Co. + {i18n.__('Copyright (C) 2017 - 2018 BoostIO')}
  • - License: GPL v3 + {i18n.__('License: GPL v3')}

-
Data collection policy
-
We collect only the number of DAU for Boostnote and **DO NOT collect** any detail information such as your note content.
-
You can see how it works on this.handleLinkClick(e)}>GitHub.
-
This data is only used for Boostnote improvements.
+
{i18n.__('Analytics')}
+
{i18n.__('Boostnote collects anonymous data for the sole purpose of improving the application, and strictly does not collect any personal information such the contents of your notes.')}
+
{i18n.__('You can see how it works on ')} this.handleLinkClick(e)}>GitHub.
+
+
{i18n.__('You can choose to enable or disable this option.')}
this.handleConfigChange(e)} checked={this.state.config.amaEnabled} ref='amaEnabled' type='checkbox' /> - Enable to send analytics to our servers
- + {i18n.__('Enable analytics to help improve Boostnote')}
+ +
{this.infoMessage()}
) diff --git a/browser/main/modals/PreferencesModal/InfoTab.styl b/browser/main/modals/PreferencesModal/InfoTab.styl index cc04a10f..491fc4d4 100644 --- a/browser/main/modals/PreferencesModal/InfoTab.styl +++ b/browser/main/modals/PreferencesModal/InfoTab.styl @@ -68,3 +68,10 @@ body[data-theme="solarized-dark"] .list a color $ui-solarized-dark-active-color + +body[data-theme="monokai"] + .root + color $ui-monokai-text-color +.list + a + color $ui-monokai-active-color diff --git a/browser/main/modals/PreferencesModal/PreferencesModal.styl b/browser/main/modals/PreferencesModal/PreferencesModal.styl index 57b5dbad..d21f6c28 100644 --- a/browser/main/modals/PreferencesModal/PreferencesModal.styl +++ b/browser/main/modals/PreferencesModal/PreferencesModal.styl @@ -116,3 +116,26 @@ body[data-theme="solarized-dark"] &:hover color white +body[data-theme="monokai"] + .root + background-color transparent + .top-bar + background-color transparent + border-color $ui-monokai-borderColor + p + color $ui-monokai-text-color + .nav + background-color transparent + border-color $ui-monokai-borderColor + .nav-button + background-color transparent + color $ui-monokai-text-color + &:hover + color $ui-monokai-text-color + + .nav-button--active + @extend .nav-button + color $ui-monokai-button--active-color + background-color $ui-monokai-button--active-backgroundColor + &:hover + color white diff --git a/browser/main/modals/PreferencesModal/SnippetEditor.js b/browser/main/modals/PreferencesModal/SnippetEditor.js new file mode 100644 index 00000000..4ce5dc34 --- /dev/null +++ b/browser/main/modals/PreferencesModal/SnippetEditor.js @@ -0,0 +1,95 @@ +import CodeMirror from 'codemirror' +import React from 'react' +import _ from 'lodash' +import styles from './SnippetTab.styl' +import CSSModules from 'browser/lib/CSSModules' +import dataApi from 'browser/main/lib/dataApi' + +const defaultEditorFontFamily = ['Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', 'monospace'] +const buildCMRulers = (rulers, enableRulers) => + enableRulers ? rulers.map(ruler => ({ column: ruler })) : [] + +class SnippetEditor extends React.Component { + + componentDidMount () { + this.props.onRef(this) + const { rulers, enableRulers } = this.props + this.cm = CodeMirror(this.refs.root, { + rulers: buildCMRulers(rulers, enableRulers), + lineNumbers: this.props.displayLineNumbers, + lineWrapping: true, + theme: this.props.theme, + indentUnit: this.props.indentSize, + tabSize: this.props.indentSize, + indentWithTabs: this.props.indentType !== 'space', + keyMap: this.props.keyMap, + scrollPastEnd: this.props.scrollPastEnd, + dragDrop: false, + foldGutter: true, + gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'], + autoCloseBrackets: { + pairs: '()[]{}\'\'""$$**``', + triples: '```"""\'\'\'', + explode: '[]{}``$$', + override: true + }, + mode: 'null' + }) + this.cm.setSize('100%', '100%') + let changeDelay = null + + this.cm.on('change', () => { + this.snippet.content = this.cm.getValue() + + clearTimeout(changeDelay) + changeDelay = setTimeout(() => { + this.saveSnippet() + }, 500) + }) + } + + componentWillUnmount () { + this.props.onRef(undefined) + } + + onSnippetChanged (newSnippet) { + this.snippet = newSnippet + this.cm.setValue(this.snippet.content) + } + + onSnippetNameOrPrefixChanged (newSnippet) { + this.snippet.name = newSnippet.name + this.snippet.prefix = newSnippet.prefix.toString().replace(/\s/g, '').split(',') + this.saveSnippet() + } + + saveSnippet () { + dataApi.updateSnippet(this.snippet).catch((err) => { throw err }) + } + + render () { + const { fontSize } = this.props + let fontFamily = this.props.fontFamily + fontFamily = _.isString(fontFamily) && fontFamily.length > 0 + ? [fontFamily].concat(defaultEditorFontFamily) + : defaultEditorFontFamily + return ( +
+ ) + } +} + +SnippetEditor.defaultProps = { + readOnly: false, + theme: 'xcode', + keyMap: 'sublime', + fontSize: 14, + fontFamily: 'Monaco, Consolas', + indentSize: 4, + indentType: 'space' +} + +export default CSSModules(SnippetEditor, styles) diff --git a/browser/main/modals/PreferencesModal/SnippetList.js b/browser/main/modals/PreferencesModal/SnippetList.js new file mode 100644 index 00000000..512ae997 --- /dev/null +++ b/browser/main/modals/PreferencesModal/SnippetList.js @@ -0,0 +1,95 @@ +import React from 'react' +import styles from './SnippetTab.styl' +import CSSModules from 'browser/lib/CSSModules' +import dataApi from 'browser/main/lib/dataApi' +import i18n from 'browser/lib/i18n' +import eventEmitter from 'browser/main/lib/eventEmitter' +import context from 'browser/lib/context' + +class SnippetList extends React.Component { + constructor (props) { + super(props) + this.state = { + snippets: [] + } + } + + componentDidMount () { + this.reloadSnippetList() + eventEmitter.on('snippetList:reload', this.reloadSnippetList.bind(this)) + } + + reloadSnippetList () { + dataApi.fetchSnippet().then(snippets => { + this.setState({snippets}) + this.props.onSnippetSelect(this.props.currentSnippet) + }) + } + + handleSnippetContextMenu (snippet) { + context.popup([{ + label: i18n.__('Delete snippet'), + click: () => this.deleteSnippet(snippet) + }]) + } + + deleteSnippet (snippet) { + dataApi.deleteSnippet(snippet).then(() => { + this.reloadSnippetList() + this.props.onSnippetDeleted(snippet) + }).catch(err => { throw err }) + } + + handleSnippetClick (snippet) { + this.props.onSnippetSelect(snippet) + } + + createSnippet () { + dataApi.createSnippet().then(() => { + this.reloadSnippetList() + // scroll to end of list when added new snippet + const snippetList = document.getElementById('snippets') + snippetList.scrollTop = snippetList.scrollHeight + }).catch(err => { throw err }) + } + + defineSnippetStyleName (snippet) { + const { currentSnippet } = this.props + if (currentSnippet == null) return + if (currentSnippet.id === snippet.id) { + return 'snippet-item-selected' + } else { + return 'snippet-item' + } + } + + render () { + const { snippets } = this.state + return ( +
+
+
+ +
+
+
    + { + snippets.map((snippet) => ( +
  • this.handleSnippetContextMenu(snippet)} + onClick={() => this.handleSnippetClick(snippet)}> + {snippet.name} +
  • + )) + } +
+
+ ) + } +} + +export default CSSModules(SnippetList, styles) diff --git a/browser/main/modals/PreferencesModal/SnippetTab.js b/browser/main/modals/PreferencesModal/SnippetTab.js new file mode 100644 index 00000000..e35ecd69 --- /dev/null +++ b/browser/main/modals/PreferencesModal/SnippetTab.js @@ -0,0 +1,117 @@ +import React from 'react' +import CSSModules from 'browser/lib/CSSModules' +import styles from './SnippetTab.styl' +import SnippetEditor from './SnippetEditor' +import i18n from 'browser/lib/i18n' +import dataApi from 'browser/main/lib/dataApi' +import SnippetList from './SnippetList' +import eventEmitter from 'browser/main/lib/eventEmitter' + +class SnippetTab extends React.Component { + constructor (props) { + super(props) + this.state = { + currentSnippet: null + } + this.changeDelay = null + } + + handleSnippetNameOrPrefixChange () { + clearTimeout(this.changeDelay) + this.changeDelay = setTimeout(() => { + // notify the snippet editor that the name or prefix of snippet has been changed + this.snippetEditor.onSnippetNameOrPrefixChanged(this.state.currentSnippet) + eventEmitter.emit('snippetList:reload') + }, 500) + } + + handleSnippetSelect (snippet) { + const { currentSnippet } = this.state + if (currentSnippet === null || currentSnippet.id !== snippet.id) { + dataApi.fetchSnippet(snippet.id).then(changedSnippet => { + // notify the snippet editor to load the content of the new snippet + this.snippetEditor.onSnippetChanged(changedSnippet) + this.setState({currentSnippet: changedSnippet}) + }) + } + } + + onSnippetNameOrPrefixChanged (e, type) { + const newSnippet = Object.assign({}, this.state.currentSnippet) + if (type === 'name') { + newSnippet.name = e.target.value + } else { + newSnippet.prefix = e.target.value + } + this.setState({ currentSnippet: newSnippet }) + this.handleSnippetNameOrPrefixChange() + } + + handleDeleteSnippet (snippet) { + // prevent old snippet still display when deleted + if (snippet.id === this.state.currentSnippet.id) { + this.setState({currentSnippet: null}) + } + } + + render () { + const { config, storageKey } = this.props + const { currentSnippet } = this.state + + let editorFontSize = parseInt(config.editor.fontSize, 10) + if (!(editorFontSize > 0 && editorFontSize < 101)) editorFontSize = 14 + let editorIndentSize = parseInt(config.editor.indentSize, 10) + if (!(editorFontSize > 0 && editorFontSize < 132)) editorIndentSize = 4 + return ( +
+
{i18n.__('Snippets')}
+ +
+
+
{i18n.__('Snippet name')}
+
+ { this.onSnippetNameOrPrefixChanged(e, 'name') }} + type='text' /> +
+
+
+
{i18n.__('Snippet prefix')}
+
+ { this.onSnippetNameOrPrefixChanged(e, 'prefix') }} + type='text' /> +
+
+
+ { this.snippetEditor = ref }} /> +
+
+
+ ) + } +} + +SnippetTab.PropTypes = { +} + +export default CSSModules(SnippetTab, styles) diff --git a/browser/main/modals/PreferencesModal/SnippetTab.styl b/browser/main/modals/PreferencesModal/SnippetTab.styl new file mode 100644 index 00000000..02307b64 --- /dev/null +++ b/browser/main/modals/PreferencesModal/SnippetTab.styl @@ -0,0 +1,198 @@ +@import('./Tab') +@import('./ConfigTab') + +.root + padding 15px + white-space pre + line-height 1.4 + color alpha($ui-text-color, 90%) + width 100% + font-size 14px + +.group + margin-bottom 45px + +.group-header + @extend .header + color $ui-text-color + +.group-header2 + font-size 20px + color $ui-text-color + margin-bottom 15px + margin-top 30px + +.group-section + margin-bottom 20px + display flex + line-height 30px + +.group-section-label + width 150px + text-align left + margin-right 10px + font-size 14px + +.group-section-control + flex 1 + margin-left 5px + +.group-section-control select + outline none + border 1px solid $ui-borderColor + font-size 16px + height 30px + width 250px + margin-bottom 5px + background-color transparent + +.group-section-control-input + height 30px + vertical-align middle + width 400px + font-size $tab--button-font-size + border solid 1px $border-color + border-radius 2px + padding 0 5px + outline none + &:disabled + background-color $ui-input--disabled-backgroundColor + +.group-control-button + height 30px + border none + border-top-right-radius 2px + border-bottom-right-radius 2px + colorPrimaryButton() + vertical-align middle + padding 0 20px + +.group-checkBoxSection + margin-bottom 15px + display flex + line-height 30px + padding-left 15px + +.group-control + padding-top 10px + box-sizing border-box + height 40px + text-align right + :global + .alert + display inline-block + position absolute + top 60px + right 15px + font-size 14px + .success + color #1EC38B + .error + color red + .warning + color #FFA500 + +.snippet-list + width 30% + height calc(100% - 200px) + position absolute + + .snippets + height calc(100% - 8px) + overflow scroll + background: #f5f5f5 + + .snippet-item + height 50px + font-size 15px + line-height 50px + padding 0 5% + cursor pointer + position relative + + &::after + width 90% + height 1px + background rgba(0, 0, 0, 0.1) + position absolute + top 100% + left 5% + content '' + + &:hover + background darken(#f5f5f5, 5) + + .snippet-item-selected + @extend .snippet-list .snippet-item + background darken(#f5f5f5, 5) + +.snippet-detail + width 70% + height calc(100% - 200px) + position absolute + left 33% + +.SnippetEditor + position absolute + width 100% + height 90% + +body[data-theme="default"], body[data-theme="white"] + .snippets + background $ui-backgroundColor + .snippet-item + color black + &::after + background $ui-borderColor + &:hover + background darken($ui-backgroundColor, 5) + .snippet-item-selected + background darken($ui-backgroundColor, 5) + +body[data-theme="dark"] + .snippets + background $ui-dark-backgroundColor + .snippet-item + color white + &::after + background $ui-dark-borderColor + &:hover + background darken($ui-dark-backgroundColor, 5) + .snippet-item-selected + background darken($ui-dark-backgroundColor, 5) + .snippet-detail + color white + .group-control-button + colorDarkPrimaryButton() + +body[data-theme="solarized-dark"] + .snippets + background $ui-solarized-dark-backgroundColor + .snippet-item + color white + &::after + background $ui-solarized-dark-borderColor + &:hover + background darken($ui-solarized-dark-backgroundColor, 5) + .snippet-item-selected + background darken($ui-solarized-dark-backgroundColor, 5) + .snippet-detail + color white + .group-control-button + colorSolarizedDarkPrimaryButton() + +body[data-theme="monokai"] + .snippets + background $ui-monokai-backgroundColor + .snippet-item + color White + &::after + background $ui-monokai-borderColor + &:hover + background darken($ui-monokai-backgroundColor, 5) + .snippet-item-selected + background darken($ui-monokai-backgroundColor, 5) + .snippet-detail + color white + .group-control-button + colorMonokaiPrimaryButton() diff --git a/browser/main/modals/PreferencesModal/StorageItem.js b/browser/main/modals/PreferencesModal/StorageItem.js index f2092835..3a2b075c 100644 --- a/browser/main/modals/PreferencesModal/StorageItem.js +++ b/browser/main/modals/PreferencesModal/StorageItem.js @@ -6,6 +6,7 @@ import consts from 'browser/lib/consts' import dataApi from 'browser/main/lib/dataApi' import store from 'browser/main/store' import FolderList from './FolderList' +import i18n from 'browser/lib/i18n' const { shell, remote } = require('electron') const { dialog } = remote @@ -22,7 +23,7 @@ class StorageItem extends React.Component { handleNewFolderButtonClick (e) { const { storage } = this.props const input = { - name: 'Untitled', + name: i18n.__('New Folder'), color: consts.FOLDER_COLORS[Math.floor(Math.random() * 7) % 7] } @@ -46,9 +47,9 @@ class StorageItem extends React.Component { handleUnlinkButtonClick (e) { const index = dialog.showMessageBox(remote.getCurrentWindow(), { type: 'warning', - message: 'Unlink Storage', - detail: 'Unlinking removes this linked storage from Boostnote. No data is removed, please manually delete the folder from your hard drive if needed.', - buttons: ['Unlink', 'Cancel'] + message: i18n.__('Unlink Storage'), + detail: i18n.__('Unlinking removes this linked storage from Boostnote. No data is removed, please manually delete the folder from your hard drive if needed.'), + buttons: [i18n.__('Unlink'), i18n.__('Cancel')] }) if (index === 0) { @@ -127,7 +128,7 @@ class StorageItem extends React.Component { Add Folder + >{i18n.__('Add Folder')}
diff --git a/browser/main/modals/PreferencesModal/StoragesTab.js b/browser/main/modals/PreferencesModal/StoragesTab.js index 5c328f8a..ad7472d2 100644 --- a/browser/main/modals/PreferencesModal/StoragesTab.js +++ b/browser/main/modals/PreferencesModal/StoragesTab.js @@ -4,6 +4,7 @@ import CSSModules from 'browser/lib/CSSModules' import styles from './StoragesTab.styl' import dataApi from 'browser/main/lib/dataApi' import StorageItem from './StorageItem' +import i18n from 'browser/lib/i18n' const electron = require('electron') const { shell, remote } = electron @@ -14,7 +15,7 @@ function browseFolder () { const defaultPath = remote.app.getPath('home') return new Promise((resolve, reject) => { dialog.showOpenDialog({ - title: 'Select Directory', + title: i18n.__('Select Directory'), defaultPath, properties: ['openDirectory', 'createDirectory'] }, function (targetPaths) { @@ -69,16 +70,16 @@ class StoragesTab extends React.Component { }) return (
-
Storages
+
{i18n.__('Storages')}
{storageList.length > 0 ? storageList - :
No storage found.
+ :
{i18n.__('No storage found.')}
}
@@ -140,13 +141,13 @@ class StoragesTab extends React.Component { return (
-
Add Storage
+
{i18n.__('Add Storage')}
- Name + {i18n.__('Name')}
-
Type
+
{i18n.__('Type')}
- 3rd party cloud integration: + {i18n.__('Setting up 3rd-party cloud storage integration:')}{' '} this.handleLinkClick(e)} - >Cloud-Syncing-and-Backup + >{i18n.__('Cloud-Syncing-and-Backup')}
-
Location +
{i18n.__('Location')}
this.handleAddStorageChange(e)} /> @@ -196,10 +197,10 @@ class StoragesTab extends React.Component {
+ >{i18n.__('Add')} + >{i18n.__('Cancel')}
diff --git a/browser/main/modals/PreferencesModal/StoragesTab.styl b/browser/main/modals/PreferencesModal/StoragesTab.styl index 230f0aed..9804d7e7 100644 --- a/browser/main/modals/PreferencesModal/StoragesTab.styl +++ b/browser/main/modals/PreferencesModal/StoragesTab.styl @@ -199,3 +199,40 @@ body[data-theme="solarized-dark"] colorDarkDefaultButton() border-color $ui-solarized-dark-borderColor +body[data-theme="monokai"] + .root + color $ui-monokai-text-color + + .folderList-item + border-bottom $ui-monokai-borderColor + + .folderList-empty + color $ui-monokai-text-color + + .list-empty + color $ui-monokai-text-color + .list-control-addStorageButton + border-color $ui-monokai-button-backgroundColor + background-color $ui-monokai-button-backgroundColor + color $ui-monokai-text-color + + .addStorage-header + color $ui-monokai-text-color + border-color $ui-monokai-borderColor + + .addStorage-body-section-name-input + border-color $$ui-monokai-borderColor + + .addStorage-body-section-type-description + color $ui-monokai-text-color + + .addStorage-body-section-path-button + colorPrimaryButton() + .addStorage-body-control + border-color $ui-monokai-borderColor + + .addStorage-body-control-createButton + colorDarkPrimaryButton() + .addStorage-body-control-cancelButton + colorDarkDefaultButton() + border-color $ui-monokai-borderColor diff --git a/browser/main/modals/PreferencesModal/UiTab.js b/browser/main/modals/PreferencesModal/UiTab.js index 1cd696e3..74047d44 100644 --- a/browser/main/modals/PreferencesModal/UiTab.js +++ b/browser/main/modals/PreferencesModal/UiTab.js @@ -9,6 +9,9 @@ import ReactCodeMirror from 'react-codemirror' import CodeMirror from 'codemirror' import 'codemirror-mode-elixir' import _ from 'lodash' +import i18n from 'browser/lib/i18n' +import { getLanguages } from 'browser/lib/Languages' +import normalizeEditorFontFamily from 'browser/lib/normalizeEditorFontFamily' const OSX = global.process.platform === 'darwin' @@ -26,16 +29,18 @@ class UiTab extends React.Component { componentDidMount () { CodeMirror.autoLoadMode(this.codeMirrorInstance.getCodeMirror(), 'javascript') + CodeMirror.autoLoadMode(this.customCSSCM.getCodeMirror(), 'css') + this.customCSSCM.getCodeMirror().setSize('400px', '400px') this.handleSettingDone = () => { this.setState({UiAlert: { type: 'success', - message: 'Successfully applied!' + message: i18n.__('Successfully applied!') }}) } this.handleSettingError = (err) => { this.setState({UiAlert: { type: 'error', - message: err.message != null ? err.message : 'Error occurs!' + message: err.message != null ? err.message : i18n.__('Error occurs!') }}) } ipc.addListener('APP_SETTING_DONE', this.handleSettingDone) @@ -61,8 +66,10 @@ class UiTab extends React.Component { const newConfig = { ui: { theme: this.refs.uiTheme.value, + language: this.refs.uiLanguage.value, showCopyNotification: this.refs.showCopyNotification.checked, confirmDeletion: this.refs.confirmDeletion.checked, + showOnlyRelatedTags: this.refs.showOnlyRelatedTags.checked, disableDirectWrite: this.refs.uiD2w != null ? this.refs.uiD2w.checked : false @@ -73,9 +80,14 @@ class UiTab extends React.Component { fontFamily: this.refs.editorFontFamily.value, indentType: this.refs.editorIndentType.value, indentSize: this.refs.editorIndentSize.value, + enableRulers: this.refs.enableEditorRulers.value === 'true', + rulers: this.refs.editorRulers.value.replace(/[^0-9,]/g, '').split(','), + displayLineNumbers: this.refs.editorDisplayLineNumbers.checked, switchPreview: this.refs.editorSwitchPreview.value, keyMap: this.refs.editorKeyMap.value, - scrollPastEnd: this.refs.scrollPastEnd.checked + snippetDefaultLanguage: this.refs.editorSnippetDefaultLanguage.value, + scrollPastEnd: this.refs.scrollPastEnd.checked, + fetchUrlTitle: this.refs.editorFetchUrlTitle.checked }, preview: { fontSize: this.refs.previewFontSize.value, @@ -85,7 +97,15 @@ class UiTab extends React.Component { latexInlineOpen: this.refs.previewLatexInlineOpen.value, latexInlineClose: this.refs.previewLatexInlineClose.value, latexBlockOpen: this.refs.previewLatexBlockOpen.value, - latexBlockClose: this.refs.previewLatexBlockClose.value + latexBlockClose: this.refs.previewLatexBlockClose.value, + plantUMLServerAddress: this.refs.previewPlantUMLServerAddress.value, + scrollPastEnd: this.refs.previewScrollPastEnd.checked, + smartQuotes: this.refs.previewSmartQuotes.checked, + breaks: this.refs.previewBreaks.checked, + smartArrows: this.refs.previewSmartArrows.checked, + sanitize: this.refs.previewSanitize.value, + allowCustomCSS: this.refs.previewAllowCustomCSS.checked, + customCSS: this.customCSSCM.getCodeMirror().getValue() } } @@ -103,7 +123,7 @@ class UiTab extends React.Component { this.props.haveToSave({ tab: 'UI', type: 'warning', - message: 'You have to save!' + message: i18n.__('You have to save!') }) } }) @@ -145,25 +165,43 @@ class UiTab extends React.Component { const themes = consts.THEMES const { config, codemirrorTheme } = this.state const codemirrorSampleCode = 'function iamHappy (happy) {\n\tif (happy) {\n\t console.log("I am Happy!")\n\t} else {\n\t console.log("I am not Happy!")\n\t}\n};' + const enableEditRulersStyle = config.editor.enableRulers ? 'block' : 'none' + const fontFamily = normalizeEditorFontFamily(config.editor.fontFamily) return (
-
UI
+
{i18n.__('Interface')}
- Color Theme + {i18n.__('Interface Theme')}
+ +
+ {i18n.__('Language')} +
+ +
+
+
@@ -181,7 +219,17 @@ class UiTab extends React.Component { ref='confirmDeletion' type='checkbox' />  - Show a confirmation dialog when deleting notes + {i18n.__('Show a confirmation dialog when deleting notes')} + +
+
+
{ @@ -194,7 +242,7 @@ class UiTab extends React.Component { disabled={OSX} type='checkbox' />  - Disable Direct Write(It will be applied after restarting) + {i18n.__('Disable Direct Write (It will be applied after restarting)')}
: null @@ -203,7 +251,7 @@ class UiTab extends React.Component {
- Editor Theme + {i18n.__('Editor Theme')}
- Editor Font Family + {i18n.__('Editor Font Family')}
- Editor Indent Style + {i18n.__('Editor Indent Style')}
- Switch to Preview + {i18n.__('Editor Rulers')} +
+
+
+ +
+ this.handleUIChange(e)} + type='text' + /> +
+
+ +
+
+ {i18n.__('Switch to Preview')}
- Editor Keymap + {i18n.__('Editor Keymap')}
-

⚠️ Please restart boostnote after you change the keymap

+

{i18n.__('⚠️ Please restart boostnote after you change the keymap')}

+
+
+ {i18n.__('Snippet Default Language')} +
+
+ +
+
+ +
+ +
+
-
Preview
+
+ +
+ +
{i18n.__('Preview')}
- Preview Font Size + {i18n.__('Preview Font Size')}
- Preview Font Family + {i18n.__('Preview Font Family')}
-
Code block Theme
+
{i18n.__('Code block Theme')}
this.handleUIChange(e)} + checked={this.state.config.preview.scrollPastEnd} + ref='previewScrollPastEnd' + type='checkbox' + />  + {i18n.__('Allow preview to scroll past the last line')} + +
+
+ +
+
+ +
+
+ +
+ +
+
+ {i18n.__('Sanitization')} +
+
+ +
+
- LaTeX Inline Open Delimiter + {i18n.__('LaTeX Inline Open Delimiter')}
- LaTeX Inline Close Delimiter + {i18n.__('LaTeX Inline Close Delimiter')}
- LaTeX Block Open Delimiter + {i18n.__('LaTeX Block Open Delimiter')}
- LaTeX Block Close Delimiter + {i18n.__('LaTeX Block Close Delimiter')}
+
+
+ {i18n.__('PlantUML Server')} +
+
+ this.handleUIChange(e)} + type='text' + /> +
+
+
+
+ {i18n.__('Custom CSS')} +
+
+ this.handleUIChange(e)} + checked={config.preview.allowCustomCSS} + ref='previewAllowCustomCSS' + type='checkbox' + />  + {i18n.__('Allow custom CSS for preview')} +
+ this.handleUIChange(e)} + ref={e => (this.customCSSCM = e)} + value={config.preview.customCSS} + options={{ + lineNumbers: true, + mode: 'css', + theme: codemirrorTheme + }} /> +
+
+
{UiAlertElement}
diff --git a/browser/main/modals/PreferencesModal/index.js b/browser/main/modals/PreferencesModal/index.js index 09885e1c..f3fc3751 100644 --- a/browser/main/modals/PreferencesModal/index.js +++ b/browser/main/modals/PreferencesModal/index.js @@ -1,16 +1,19 @@ import PropTypes from 'prop-types' import React from 'react' -import ReactDOM from 'react-dom' import { connect } from 'react-redux' import HotkeyTab from './HotkeyTab' import UiTab from './UiTab' import InfoTab from './InfoTab' import Crowdfunding from './Crowdfunding' import StoragesTab from './StoragesTab' +import SnippetTab from './SnippetTab' +import Blog from './Blog' import ModalEscButton from 'browser/components/ModalEscButton' import CSSModules from 'browser/lib/CSSModules' import styles from './PreferencesModal.styl' import RealtimeNotification from 'browser/components/RealtimeNotification' +import _ from 'lodash' +import i18n from 'browser/lib/i18n' class Preferences extends React.Component { constructor (props) { @@ -19,7 +22,8 @@ class Preferences extends React.Component { this.state = { currentTab: 'STORAGES', UIAlert: '', - HotkeyAlert: '' + HotkeyAlert: '', + BlogAlert: '' } } @@ -75,6 +79,22 @@ class Preferences extends React.Component { return ( ) + case 'BLOG': + return ( + this.setState({BlogAlert: alert})} + /> + ) + case 'SNIPPET': + return ( + + ) case 'STORAGES': default: return ( @@ -94,8 +114,7 @@ class Preferences extends React.Component { } getContentBoundingBox () { - const node = ReactDOM.findDOMNode(this.refs.content) - return node.getBoundingClientRect() + return this.refs.content.getBoundingClientRect() } haveToSaveNotif (type, message) { @@ -108,11 +127,13 @@ class Preferences extends React.Component { const content = this.renderContent() const tabs = [ - {target: 'STORAGES', label: 'Storages'}, - {target: 'HOTKEY', label: 'Hotkey', Hotkey: this.state.HotkeyAlert}, - {target: 'UI', label: 'UI', UI: this.state.UIAlert}, - {target: 'INFO', label: 'Community / Info'}, - {target: 'CROWDFUNDING', label: 'Crowdfunding'} + {target: 'STORAGES', label: i18n.__('Storage')}, + {target: 'HOTKEY', label: i18n.__('Hotkeys'), Hotkey: this.state.HotkeyAlert}, + {target: 'UI', label: i18n.__('Interface'), UI: this.state.UIAlert}, + {target: 'INFO', label: i18n.__('About')}, + {target: 'CROWDFUNDING', label: i18n.__('Crowdfunding')}, + {target: 'BLOG', label: i18n.__('Blog'), Blog: this.state.BlogAlert}, + {target: 'SNIPPET', label: i18n.__('Snippets')} ] const navButtons = tabs.map((tab) => { @@ -141,7 +162,7 @@ class Preferences extends React.Component { onKeyDown={(e) => this.handleKeyDown(e)} >
-

Your preferences for Boostnote

+

{i18n.__('Your preferences for Boostnote')}

this.handleEscButtonClick(e)} />
diff --git a/browser/main/modals/RenameFolderModal.js b/browser/main/modals/RenameFolderModal.js index bb08f36d..edbcee67 100644 --- a/browser/main/modals/RenameFolderModal.js +++ b/browser/main/modals/RenameFolderModal.js @@ -5,6 +5,7 @@ import styles from './RenameFolderModal.styl' import dataApi from 'browser/main/lib/dataApi' import store from 'browser/main/store' import ModalEscButton from 'browser/components/ModalEscButton' +import i18n from 'browser/lib/i18n' class RenameFolderModal extends React.Component { constructor (props) { @@ -72,13 +73,13 @@ class RenameFolderModal extends React.Component { onKeyDown={(e) => this.handleKeyDown(e)} >
-
Rename Folder
+
{i18n.__('Rename Folder')}
this.handleCloseButtonClick(e)} />
this.handleChange(e)} @@ -87,7 +88,7 @@ class RenameFolderModal extends React.Component {
diff --git a/browser/main/store.js b/browser/main/store.js index 36e7850d..a1b6b791 100644 --- a/browser/main/store.js +++ b/browser/main/store.js @@ -27,7 +27,7 @@ function data (state = defaultDataMap(), action) { action.notes.some((note) => { if (note === undefined) return true - const uniqueKey = note.storage + '-' + note.key + const uniqueKey = note.key const folderKey = note.storage + '-' + note.folder state.noteMap.set(uniqueKey, note) @@ -38,35 +38,19 @@ function data (state = defaultDataMap(), action) { if (note.isTrashed) { state.trashedSet.add(uniqueKey) } - - let storageNoteList = state.storageNoteMap.get(note.storage) - if (storageNoteList == null) { - storageNoteList = new Set(storageNoteList) - state.storageNoteMap.set(note.storage, storageNoteList) - } + const storageNoteList = getOrInitItem(state.storageNoteMap, note.storage) storageNoteList.add(uniqueKey) - let folderNoteSet = state.folderNoteMap.get(folderKey) - if (folderNoteSet == null) { - folderNoteSet = new Set(folderNoteSet) - state.folderNoteMap.set(folderKey, folderNoteSet) - } + const folderNoteSet = getOrInitItem(state.folderNoteMap, folderKey) folderNoteSet.add(uniqueKey) - note.tags.forEach((tag) => { - let tagNoteList = state.tagNoteMap.get(tag) - if (tagNoteList == null) { - tagNoteList = new Set(tagNoteList) - state.tagNoteMap.set(tag, tagNoteList) - } - tagNoteList.add(uniqueKey) - }) + assignToTags(note.tags, state, uniqueKey) }) return state case 'UPDATE_NOTE': { const note = action.note - const uniqueKey = note.storage + '-' + note.key + const uniqueKey = note.key const folderKey = note.storage + '-' + note.folder const oldNote = state.noteMap.get(uniqueKey) @@ -74,23 +58,19 @@ function data (state = defaultDataMap(), action) { state.noteMap = new Map(state.noteMap) state.noteMap.set(uniqueKey, note) - if (oldNote == null || oldNote.isStarred !== note.isStarred) { - state.starredSet = new Set(state.starredSet) - if (note.isStarred) { - state.starredSet.add(uniqueKey) - } else { - state.starredSet.delete(uniqueKey) - } - } + updateStarredChange(oldNote, note, state, uniqueKey) if (oldNote == null || oldNote.isTrashed !== note.isTrashed) { state.trashedSet = new Set(state.trashedSet) if (note.isTrashed) { state.trashedSet.add(uniqueKey) state.starredSet.delete(uniqueKey) + removeFromTags(note.tags, state, uniqueKey) } else { state.trashedSet.delete(uniqueKey) + assignToTags(note.tags, state, uniqueKey) + if (note.isStarred) { state.starredSet.add(uniqueKey) } @@ -107,54 +87,12 @@ function data (state = defaultDataMap(), action) { } // Update foldermap if folder changed or post created - if (oldNote == null || oldNote.folder !== note.folder) { - state.folderNoteMap = new Map(state.folderNoteMap) - let folderNoteSet = state.folderNoteMap.get(folderKey) - folderNoteSet = new Set(folderNoteSet) - folderNoteSet.add(uniqueKey) - state.folderNoteMap.set(folderKey, folderNoteSet) - - if (oldNote != null) { - const oldFolderKey = oldNote.storage + '-' + oldNote.folder - let oldFolderNoteList = state.folderNoteMap.get(oldFolderKey) - oldFolderNoteList = new Set(oldFolderNoteList) - oldFolderNoteList.delete(uniqueKey) - state.folderNoteMap.set(oldFolderKey, oldFolderNoteList) - } - } + updateFolderChange(oldNote, note, state, folderKey, uniqueKey) if (oldNote != null) { - const discardedTags = _.difference(oldNote.tags, note.tags) - const addedTags = _.difference(note.tags, oldNote.tags) - if (discardedTags.length + addedTags.length > 0) { - state.tagNoteMap = new Map(state.tagNoteMap) - - discardedTags.forEach((tag) => { - let tagNoteList = state.tagNoteMap.get(tag) - if (tagNoteList != null) { - tagNoteList = new Set(tagNoteList) - tagNoteList.delete(uniqueKey) - state.tagNoteMap.set(tag, tagNoteList) - } - }) - addedTags.forEach((tag) => { - let tagNoteList = state.tagNoteMap.get(tag) - tagNoteList = new Set(tagNoteList) - tagNoteList.add(uniqueKey) - - state.tagNoteMap.set(tag, tagNoteList) - }) - } + updateTagChanges(oldNote, note, state, uniqueKey) } else { - state.tagNoteMap = new Map(state.tagNoteMap) - note.tags.forEach((tag) => { - let tagNoteList = state.tagNoteMap.get(tag) - if (tagNoteList == null) { - tagNoteList = new Set(tagNoteList) - state.tagNoteMap.set(tag, tagNoteList) - } - tagNoteList.add(uniqueKey) - }) + assignToTags(note.tags, state, uniqueKey) } return state @@ -162,9 +100,9 @@ function data (state = defaultDataMap(), action) { case 'MOVE_NOTE': { const originNote = action.originNote - const originKey = originNote.storage + '-' + originNote.key + const originKey = originNote.key const note = action.note - const uniqueKey = note.storage + '-' + note.key + const uniqueKey = note.key const folderKey = note.storage + '-' + note.folder const oldNote = state.noteMap.get(uniqueKey) @@ -202,26 +140,10 @@ function data (state = defaultDataMap(), action) { originFolderList.delete(originKey) state.folderNoteMap.set(originFolderKey, originFolderList) - // From tagMap - if (originNote.tags.length > 0) { - state.tagNoteMap = new Map(state.tagNoteMap) - originNote.tags.forEach((tag) => { - let noteSet = state.tagNoteMap.get(tag) - noteSet = new Set(noteSet) - noteSet.delete(originKey) - state.tagNoteMap.set(tag, noteSet) - }) - } + removeFromTags(originNote.tags, state, originKey) } - if (oldNote == null || oldNote.isStarred !== note.isStarred) { - state.starredSet = new Set(state.starredSet) - if (note.isStarred) { - state.starredSet.add(uniqueKey) - } else { - state.starredSet.delete(uniqueKey) - } - } + updateStarredChange(oldNote, note, state, uniqueKey) if (oldNote == null || oldNote.isTrashed !== note.isTrashed) { state.trashedSet = new Set(state.trashedSet) @@ -238,66 +160,24 @@ function data (state = defaultDataMap(), action) { let noteSet = state.storageNoteMap.get(note.storage) noteSet = new Set(noteSet) noteSet.add(uniqueKey) - state.folderNoteMap.set(folderKey, noteSet) + state.storageNoteMap.set(folderKey, noteSet) } // Update foldermap if folder changed or post created - if (oldNote == null || oldNote.folder !== note.folder) { - state.folderNoteMap = new Map(state.folderNoteMap) - let folderNoteList = state.folderNoteMap.get(folderKey) - folderNoteList = new Set(folderNoteList) - folderNoteList.add(uniqueKey) - state.folderNoteMap.set(folderKey, folderNoteList) - - if (oldNote != null) { - const oldFolderKey = oldNote.storage + '-' + oldNote.folder - let oldFolderNoteList = state.folderNoteMap.get(oldFolderKey) - oldFolderNoteList = new Set(oldFolderNoteList) - oldFolderNoteList.delete(uniqueKey) - state.folderNoteMap.set(oldFolderKey, oldFolderNoteList) - } - } + updateFolderChange(oldNote, note, state, folderKey, uniqueKey) // Remove from old folder map if (oldNote != null) { - const discardedTags = _.difference(oldNote.tags, note.tags) - const addedTags = _.difference(note.tags, oldNote.tags) - if (discardedTags.length + addedTags.length > 0) { - state.tagNoteMap = new Map(state.tagNoteMap) - - discardedTags.forEach((tag) => { - let tagNoteList = state.tagNoteMap.get(tag) - if (tagNoteList != null) { - tagNoteList = new Set(tagNoteList) - tagNoteList.delete(uniqueKey) - state.tagNoteMap.set(tag, tagNoteList) - } - }) - addedTags.forEach((tag) => { - let tagNoteList = state.tagNoteMap.get(tag) - tagNoteList = new Set(tagNoteList) - tagNoteList.add(uniqueKey) - - state.tagNoteMap.set(tag, tagNoteList) - }) - } + updateTagChanges(oldNote, note, state, uniqueKey) } else { - state.tagNoteMap = new Map(state.tagNoteMap) - note.tags.forEach((tag) => { - let tagNoteList = state.tagNoteMap.get(tag) - if (tagNoteList == null) { - tagNoteList = new Set(tagNoteList) - state.tagNoteMap.set(tag, tagNoteList) - } - tagNoteList.add(uniqueKey) - }) + assignToTags(note.tags, state, uniqueKey) } return state } case 'DELETE_NOTE': { - const uniqueKey = action.storageKey + '-' + action.noteKey + const uniqueKey = action.noteKey const targetNote = state.noteMap.get(uniqueKey) state = Object.assign({}, state) @@ -329,16 +209,7 @@ function data (state = defaultDataMap(), action) { folderSet.delete(uniqueKey) state.folderNoteMap.set(folderKey, folderSet) - // From tagMap - if (targetNote.tags.length > 0) { - state.tagNoteMap = new Map(state.tagNoteMap) - targetNote.tags.forEach((tag) => { - let noteSet = state.tagNoteMap.get(tag) - noteSet = new Set(noteSet) - noteSet.delete(uniqueKey) - state.tagNoteMap.set(tag, noteSet) - }) - } + removeFromTags(targetNote.tags, state, uniqueKey) } state.noteMap = new Map(state.noteMap) state.noteMap.delete(uniqueKey) @@ -355,11 +226,9 @@ function data (state = defaultDataMap(), action) { state.storageMap.set(action.storage.key, action.storage) return state case 'EXPORT_FOLDER': - { - state = Object.assign({}, state) - state.storageMap = new Map(state.storageMap) - state.storageMap.set(action.storage.key, action.storage) - } + state = Object.assign({}, state) + state.storageMap = new Map(state.storageMap) + state.storageMap.set(action.storage.key, action.storage) return state case 'DELETE_FOLDER': { @@ -404,9 +273,7 @@ function data (state = defaultDataMap(), action) { // Delete key from tag map state.tagNoteMap = new Map(state.tagNoteMap) note.tags.forEach((tag) => { - let tagNoteSet = state.tagNoteMap.get(tag) - tagNoteSet = new Set(tagNoteSet) - state.tagNoteMap.set(tag, tagNoteSet) + const tagNoteSet = getOrInitItem(state.tagNoteMap, tag) tagNoteSet.delete(noteKey) }) } @@ -425,7 +292,7 @@ function data (state = defaultDataMap(), action) { state.folderNoteMap = new Map(state.folderNoteMap) state.tagNoteMap = new Map(state.tagNoteMap) action.notes.forEach((note) => { - const uniqueKey = note.storage + '-' + note.key + const uniqueKey = note.key const folderKey = note.storage + '-' + note.folder state.noteMap.set(uniqueKey, note) @@ -433,11 +300,7 @@ function data (state = defaultDataMap(), action) { state.starredSet.add(uniqueKey) } - let storageNoteList = state.storageNoteMap.get(note.storage) - if (storageNoteList == null) { - storageNoteList = new Set(storageNoteList) - state.storageNoteMap.set(note.storage, storageNoteList) - } + const storageNoteList = getOrInitItem(state.tagNoteMap, note.storage) storageNoteList.add(uniqueKey) let folderNoteSet = state.folderNoteMap.get(folderKey) @@ -448,11 +311,7 @@ function data (state = defaultDataMap(), action) { folderNoteSet.add(uniqueKey) note.tags.forEach((tag) => { - let tagNoteSet = state.tagNoteMap.get(tag) - if (tagNoteSet == null) { - tagNoteSet = new Set(tagNoteSet) - state.tagNoteMap.set(tag, tagNoteSet) - } + const tagNoteSet = getOrInitItem(state.tagNoteMap, tag) tagNoteSet.add(uniqueKey) }) }) @@ -485,7 +344,7 @@ function data (state = defaultDataMap(), action) { state.tagNoteMap = new Map(state.tagNoteMap) state.starredSet = new Set(state.starredSet) notes.forEach((note) => { - const noteKey = storage.key + '-' + note.key + const noteKey = note.key state.noteMap.delete(noteKey) state.starredSet.delete(noteKey) note.tags.forEach((tag) => { @@ -501,6 +360,12 @@ function data (state = defaultDataMap(), action) { state.storageMap = new Map(state.storageMap) state.storageMap.set(action.storage.key, action.storage) return state + case 'EXPAND_STORAGE': + state = Object.assign({}, state) + state.storageMap = new Map(state.storageMap) + action.storage.isOpen = action.isOpen + state.storageMap.set(action.storage.key, action.storage) + return state } return state } @@ -543,6 +408,73 @@ function status (state = defaultStatus, action) { return state } +function updateStarredChange (oldNote, note, state, uniqueKey) { + if (oldNote == null || oldNote.isStarred !== note.isStarred) { + state.starredSet = new Set(state.starredSet) + if (note.isStarred) { + state.starredSet.add(uniqueKey) + } else { + state.starredSet.delete(uniqueKey) + } + } +} + +function updateFolderChange (oldNote, note, state, folderKey, uniqueKey) { + if (oldNote == null || oldNote.folder !== note.folder) { + state.folderNoteMap = new Map(state.folderNoteMap) + let folderNoteList = state.folderNoteMap.get(folderKey) + folderNoteList = new Set(folderNoteList) + folderNoteList.add(uniqueKey) + state.folderNoteMap.set(folderKey, folderNoteList) + + if (oldNote != null) { + const oldFolderKey = oldNote.storage + '-' + oldNote.folder + let oldFolderNoteList = state.folderNoteMap.get(oldFolderKey) + oldFolderNoteList = new Set(oldFolderNoteList) + oldFolderNoteList.delete(uniqueKey) + state.folderNoteMap.set(oldFolderKey, oldFolderNoteList) + } + } +} + +function updateTagChanges (oldNote, note, state, uniqueKey) { + const discardedTags = _.difference(oldNote.tags, note.tags) + const addedTags = _.difference(note.tags, oldNote.tags) + if (discardedTags.length + addedTags.length > 0) { + removeFromTags(discardedTags, state, uniqueKey) + assignToTags(addedTags, state, uniqueKey) + } +} + +function assignToTags (tags, state, uniqueKey) { + state.tagNoteMap = new Map(state.tagNoteMap) + tags.forEach((tag) => { + const tagNoteList = getOrInitItem(state.tagNoteMap, tag) + tagNoteList.add(uniqueKey) + }) +} + +function removeFromTags (tags, state, uniqueKey) { + state.tagNoteMap = new Map(state.tagNoteMap) + tags.forEach(tag => { + let tagNoteList = state.tagNoteMap.get(tag) + if (tagNoteList != null) { + tagNoteList = new Set(tagNoteList) + tagNoteList.delete(uniqueKey) + state.tagNoteMap.set(tag, tagNoteList) + } + }) +} + +function getOrInitItem (target, key) { + let results = target.get(key) + if (results == null) { + results = new Set() + target.set(key, results) + } + return results +} + const reducer = combineReducers({ data, config, diff --git a/browser/styles/finder/index.styl b/browser/styles/finder/index.styl deleted file mode 100644 index d10cc854..00000000 --- a/browser/styles/finder/index.styl +++ /dev/null @@ -1,129 +0,0 @@ -@import '../../../node_modules/nib/lib/nib' -@import '../vars' -@import '../mixins/*' -global-reset() -@import '../shared/*' -@import '../theme/*' - -iptBgColor = #E6E6E6 -iptFocusBorderColor = #369DCD - -DEFAULT_FONTS = 'Lato', 'MS Gothic', 'Malgun Gothic', 'Sans-serif' - -body - font-family DEFAULT_FONTS - color textColor - font-size fontSize - width 100% - height 100% - overflow hidden -button, input - font-family DEFAULT_FONTS - -.Finder - absolute top bottom left right - .FinderInput - padding 11px - margin 0 auto - height 55px - box-sizing border-box - border-bottom solid 1px borderColor - background-color iptBgColor - z-index 200 - input - display block - width 100% - border solid 1px borderColor - padding 0 10px - font-size 1em - height 33px - border-radius 5px - box-sizing border-box - border-radius 5px - &:focus, &.focus - border-color iptFocusBorderColor - outline none - .FinderList - absolute left bottom - top 55px - border-right solid 1px borderColor - box-sizing border-box - width 250px - overflow-y auto - z-index 0 - user-select none - &>ul>li - .articleItem - padding 10px - border solid 2px transparent - box-sizing border-box - cursor pointer - white-space nowrap - overflow-x hidden - text-overflow ellipsis - .divider - box-sizing border-box - border-bottom solid 1px borderColor - &.active - .articleItem - border-color brandColor - - .FinderDetail - absolute right bottom - top 55px - left 250px - box-shadow 0px 0px 10px 0 #CCC - z-index 100 - .header - absolute top left right - height 55px - box-sizing border-box - padding 0 10px - border-bottom solid 1px borderColor - line-height 55px - font-size 18px - white-space nowrap - text-overflow ellipsis - overflow-x hidden - clearfix() - .left - float left - .right - float right - button - border-radius 16.5px - cursor pointer - height 33px - width 33px - border none - margin-right 5px - font-size 18px - color inactiveTextColor - background-color transparent - padding 0 - .tooltip - tooltip() - &.clipboardBtn .tooltip - margin-left -160px - margin-top 25px - &:hover - color textColor - .tooltip - opacity 1 - .content - position absolute - top 55px - padding 10px - bottom 0 - left 0 - right 0 - box-sizing border-box - overflow-y auto - .MarkdownPreview - marked() - &.empty - color lighten(inactiveTextColor, 10%) - user-select none - font-size 14px - .CodeEditor - absolute top bottom left right diff --git a/browser/styles/index.styl b/browser/styles/index.styl index e8a70e1f..a490b41c 100644 --- a/browser/styles/index.styl +++ b/browser/styles/index.styl @@ -5,7 +5,7 @@ $danger-color = #c9302c $danger-lighten-color = lighten(#c9302c, 5%) // Layouts -$statusBar-height = 0px +$statusBar-height = 28px $sideNav-width = 200px $sideNav--folded-width = 44px $topBar-height = 60px @@ -118,6 +118,16 @@ colorSolarizedDarkPrimaryButton() &:active:hover background-color $dark-primary-button-background--active +colorMonokaiPrimaryButton() + color $ui-monokai-text-color + background-color $ui-monokai-button-backgroundColor + border none + &:hover + background-color $dark-primary-button-background--hover + &:active + &:active:hover + background-color $dark-primary-button-background--active + // Danger button(Brand color) $danger-button-background = #c9302c @@ -188,7 +198,6 @@ modal() border-radius $modal-border-radius topBarButtonRight() - position absolute width 34px height 34px border-radius 17px @@ -348,4 +357,30 @@ modalSolarizedDark() width 100% background-color $ui-solarized-dark-backgroundColor overflow hidden + border-radius $modal-border-radius + +/******* Monokai theme ********/ +$ui-monokai-backgroundColor = #272822 +$ui-monokai-noteList-backgroundColor = #272822 +$ui-monokai-noteDetail-backgroundColor = #272822 + +$ui-monokai-text-color = #f8f8f2 +$ui-monokai-active-color = #f92672 + +$ui-monokai-borderColor = #373831 + +$ui-monokai-tag-backgroundColor = #f92672 + +$ui-monokai-button-backgroundColor = #373831 +$ui-monokai-button--active-color = white +$ui-monokai-button--active-backgroundColor = #f92672 +$ui-monokai-button--hover-backgroundColor = lighten($ui-dark-backgroundColor, 10%) +$ui-monokai-button--focus-borderColor = lighten(#369DCD, 25%) + +modalMonokai() + position relative + z-index $modal-z-index + width 100% + background-color $ui-monokai-backgroundColor + overflow hidden border-radius $modal-border-radius \ No newline at end of file diff --git a/browser/styles/theme/dark.styl b/browser/styles/theme/dark.styl index 9c593dd9..1c20cd17 100644 --- a/browser/styles/theme/dark.styl +++ b/browser/styles/theme/dark.styl @@ -378,52 +378,10 @@ body[data-theme="dark"] &:hover color themeDarkFocusButton - .Finder - .FinderInput - color themeDarkText - border-color themeDarkBorder - background-color themeDarkBackground - - input - color themeDarkText - border-color lighten(themeDarkBackground, 10%) - background-color lighten(themeDarkBackground, 10%) - - &:focus - border-color themeDarkTopicColor - - .FinderList - color themeDarkText - border-color themeDarkBorder - background-color themeDarkList - - .divider - border-color themeDarkBorder - - .FinderDetail - color themeDarkText - border-color themeDarkBorder - background-color themeDarkPreview - box-shadow 0px 0px 10px 0 darken(themeDarkBorder, 20%); - - .header - border-color themeDarkBorder - - .right - .clipboardBtn - transition 0.1s - - &:hover - color themeDarkFocusButton - - .tooltip - background-color themeDarkTooltip - .ArticleDetail-panel border-radius 0 // Markdown Preview - .Finder .FinderDetail .content, .ArticleDetail .ArticleDetail-panel .ArticleEditor .MarkdownPreview color themeDarkText diff --git a/contributing.md b/contributing.md index 066d53fa..867d4161 100644 --- a/contributing.md +++ b/contributing.md @@ -9,7 +9,7 @@ Thank you for your help in advance. ### About copyright of Pull Request -If you make a pull request, It means you agree to transfer the copyright of the code changes to Maisin&Co. +If you make a pull request, It means you agree to transfer the copyright of the code changes to BoostIO. It doesn't mean Boostnote will become a paid app. If we want to earn some money, We will try other way, which is some kind of cloud storage, Mobile app integration or some SPECIAL features. Because GPL v3 is too strict to be compatible with any other License, We thought this is needed to replace the license with much freer one(like BSD, MIT) somewhen. @@ -27,7 +27,7 @@ Because GPL v3 is too strict to be compatible with any other License, We thought ### Об авторских правах Pull Request -Если вы делаете pull request, значит вы согласны передать авторские права на изменения кода в Maisin&Co. +Если вы делаете pull request, значит вы согласны передать авторские права на изменения кода в BoostIO. Это не означает, что Boostnote станет платным приложением. Если мы захотим заработать немного денег, мы найдем другой способ. Например, использование облачного хранилища, интеграцией мобильных приложений или другими специальными функциями. Так как лицензия GPL v3 слишком строгая, чтобы быть совместимой с любой другой лицензией, мы думаем, что нужно заменить лицензию на более свободную (например, BSD, MIT). @@ -45,7 +45,7 @@ Because GPL v3 is too strict to be compatible with any other License, We thought ### Pull Request의 저작권에 관하여 -당신이 pull request를 요청하면, 코드 변경에 대한 저작권을 Maisin&Co에 양도한다는 것에 동의한다는 의미입니다. +당신이 pull request를 요청하면, 코드 변경에 대한 저작권을 BoostIO에 양도한다는 것에 동의한다는 의미입니다. 이것은 Boostnote가 유료화가 되는 것을 의미하는 건 아닙니다. 만약 우리가 자금이 필요하다면, 우리는 클라우드 연동, 모바일 앱 통합 혹은 특수한 기능 같은 것을 사용해 수입 창출을 시도할 것입니다. GPL v3 라이센스는 다른 라이센스와 혼합해 사용하기엔 너무 엄격하므로, 우리는 BSD, MIT 라이센스와 같은 더 자유로운 라이센스로 교체하는 것을 생각하고 있습니다. @@ -63,7 +63,7 @@ GPL v3 라이센스는 다른 라이센스와 혼합해 사용하기엔 너무 ### Pull requestの著作権について -Pull requestをすることはその変化分のコードの著作権をMaisin&Co.に譲渡することに同意することになります。 +Pull requestをすることはその変化分のコードの著作権をBoostIOに譲渡することに同意することになります。 アプリケーションのLicenseをいつでも変える選択肢を残したいと思うからです。 これはいずれかBoostnoteが有料の商用アプリになる可能性がある話ではありません。 @@ -83,7 +83,7 @@ Pull requestをすることはその変化分のコードの著作権をMaisin&C 感谢您对我们的支持。 ### 关于您提供的Pull Request的著作权(版权)问题 -如果您提供了一个Pull Request,这表示您将您所修改的代码的著作权移交给Maisin&Co。 +如果您提供了一个Pull Request,这表示您将您所修改的代码的著作权移交给BoostIO。 这并不表示Boostnote会成为一个需要付费的软件。如果我们想获得收益,我们会尝试一些其他的方法,比如说云存储、绑定手机软件等。 因为GPLv3过于严格,不能和其他的一些协议兼容,所以我们有可能在将来会把BoostNote的协议改为一些较为宽松的协议,比如说BSD、MIT。 diff --git a/dev-scripts/dev.js b/dev-scripts/dev.js new file mode 100644 index 00000000..000a1bfd --- /dev/null +++ b/dev-scripts/dev.js @@ -0,0 +1,76 @@ +const webpack = require('webpack') +const WebpackDevServer = require('webpack-dev-server') +const config = require('../webpack.config') +const signale = require('signale') +const { spawn } = require('child_process') +const electron = require('electron') +const port = 8080 +let server = null +let firstRun = true + +const options = { + publicPath: config.output.publicPath, + hot: true, + inline: true, + quiet: true +} + +function startServer () { + config.plugins.push(new webpack.HotModuleReplacementPlugin()) + config.entry.main.unshift( + `webpack-dev-server/client?http://localhost:${port}/`, + 'webpack/hot/dev-server' + ) + const compiler = webpack(config) + server = new WebpackDevServer(compiler, options) + + return new Promise((resolve, reject) => { + server.listen(port, 'localhost', function (err) { + if (err) { + reject(err) + } + signale.success(`Webpack Dev Server listening at localhost:${port}`) + signale.watch(`Waiting for webpack to bundle...`) + compiler.plugin('done', stats => { + if (!stats.hasErrors()) { + signale.success(`Bundle success !`) + resolve() + } else { + if (!firstRun) { + console.log(stats.compilation.errors[0]) + } else { + firstRun = false + reject(stats.compilation.errors[0]) + } + } + }) + }) + }) +} + +function startElectron () { + spawn(electron, ['--hot', './index.js']) + .on('close', () => { + server.close() + }) + .on('error', err => { + signale.error(err) + server.close() + }) + .on('disconnect', () => { + server.close() + }) + .on('exit', () => { + server.close() + }) +} + +startServer() + .then(() => { + startElectron() + signale.success('Electron started') + }) + .catch(err => { + signale.error(err) + process.exit(1) + }) diff --git a/docs/build.md b/docs/build.md index 08fd9ac5..f2b3e5ac 100644 --- a/docs/build.md +++ b/docs/build.md @@ -2,10 +2,9 @@ This page is also available in [Japanese](https://github.com/BoostIO/Boostnote/blob/master/docs/jp/build.md), [Korean](https://github.com/BoostIO/Boostnote/blob/master/docs/ko/build.md), [Russain](https://github.com/BoostIO/Boostnote/blob/master/docs/ru/build.md), [Simplified Chinese](https://github.com/BoostIO/Boostnote/blob/master/docs/zh_CN/build.md), [French](https://github.com/BoostIO/Boostnote/blob/master/docs/fr/build.md) and [German](https://github.com/BoostIO/Boostnote/blob/master/docs/de/build.md). ## Environments -* npm: 4.x -* node: 7.x -You should use `npm v4.x` because `$ grunt pre-build` fails on `v5.x`. +* npm: 6.x +* node: 8.x ## Development @@ -21,17 +20,9 @@ $ yarn Build and run. ``` -$ yarn run dev-start +$ yarn run dev ``` -This command runs `yarn run webpack` and `yarn run hot` in parallel. It is the same as running these commands in two terminals. - -The `webpack` will watch for code changes and then apply them automatically. - -If the following error occurs: `Failed to load resource: net::ERR_CONNECTION_REFUSED`, please reload Boostnote. - -![net::ERR_CONNECTION_REFUSED](https://cloud.githubusercontent.com/assets/11307908/24343004/081e66ae-1279-11e7-8d9e-7f478043d835.png) - > ### Notice > There are some cases where you have to refresh the app manually. > 1. When editing a constructor method of a component @@ -44,8 +35,6 @@ You can build the program by using `grunt`. However, we don't recommend this bec So, we've prepared a separate script which just makes an executable file. -This build doesn't work on npm v5.3.0. So you need to use v5.2.0 when you build it. - ``` grunt pre-build ``` diff --git a/docs/de/build.md b/docs/de/build.md index 44b744ca..a3d8e274 100644 --- a/docs/de/build.md +++ b/docs/de/build.md @@ -1,11 +1,10 @@ # Build -Diese Seite ist auch verfügbar in [Japanese](https://github.com/BoostIO/Boostnote/blob/master/docs/jp/build.md), [Korean](https://github.com/BoostIO/Boostnote/blob/master/docs/ko/build.md), [Russain](https://github.com/BoostIO/Boostnote/blob/master/docs/ru/build.md), [Simplified Chinese](https://github.com/BoostIO/Boostnote/blob/master/docs/zh_CN/build.md), [French](https://github.com/BoostIO/Boostnote/blob/master/docs/fr/build.md) and [German](https://github.com/BoostIO/Boostnote/blob/master/docs/de/build.md). +Diese Seite ist auch verfügbar in [Japanisch](https://github.com/BoostIO/Boostnote/blob/master/docs/jp/build.md), [Koreanisch](https://github.com/BoostIO/Boostnote/blob/master/docs/ko/build.md), [Russisch](https://github.com/BoostIO/Boostnote/blob/master/docs/ru/build.md), [Vereinfachtem Chinesisch](https://github.com/BoostIO/Boostnote/blob/master/docs/zh_CN/build.md), [Französisch](https://github.com/BoostIO/Boostnote/blob/master/docs/fr/build.md) und [Deutsch](https://github.com/BoostIO/Boostnote/blob/master/docs/de/build.md). ## Umgebungen -* npm: 4.x -* node: 7.x -Du solltest `npm v4.x` benutzen weil `$ grunt pre-build` scheitert mit Version `v5.x`. +* npm: 6.x +* node: 8.x ## Entwicklung @@ -21,17 +20,9 @@ $ yarn Bauen und Ausführen. ``` -$ yarn run dev-start +$ yarn run dev ``` -Dieser Befehl startet `yarn run webpack` und `yarn run hot` parallel. Es hat den selben Effekt wie beide Befehle separat in zwei Terminals zu starten. - -Das `webpack` überprüft den Code auf Änderungen und wendet diese dann automatisch an. - -Wenn folgender Fehler passiert: `Failed to load resource: net::ERR_CONNECTION_REFUSED`, bitte Boostnote neu starten. - -![net::ERR_CONNECTION_REFUSED](https://cloud.githubusercontent.com/assets/11307908/24343004/081e66ae-1279-11e7-8d9e-7f478043d835.png) - > ### Notiz > Es gibt einige Fälle bei denen die App manuell zu refreshen ist. > 1. Wenn eine "constructor method" einer Komponente manuell editiert wird. @@ -44,11 +35,10 @@ Du kannst das Programm unter Verwendung von `grunt` bauen. Jedoch empfehlen wir Deshalb haben wir ein separates Script vorbereitet welches eine ausführbare Datei erstellt. -Dieser build funktioniert nicht mit npm v5.3.0. Deshalb musst du für den Build die Version v5.2.0 verwenden. - ``` grunt pre-build ``` + Du findest die ausführbare Datein in dem Verzeichnis `dist`. Beachte, der auto updater funktioniert nicht da die app nicht signiert ist. Wenn du es für notwendig erachtest, kannst du codesign or authenticode mit dieser ausführbaren Datei verwenden. diff --git a/docs/de/debug.md b/docs/de/debug.md index ee1a734c..6c3de3dc 100644 --- a/docs/de/debug.md +++ b/docs/de/debug.md @@ -1,25 +1,31 @@ # How to debug Boostnote (Electron app) -Diese Seite ist auch verfügbar in [Japanese](https://github.com/BoostIO/Boostnote/blob/master/docs/jp/debug.md), [Korean](https://github.com/BoostIO/Boostnote/blob/master/docs/ko/debug.md), [Russain](https://github.com/BoostIO/Boostnote/blob/master/docs/ru/debug.md), [Simplified Chinese](https://github.com/BoostIO/Boostnote/blob/master/docs/zh_CN/debug.md), [French](https://github.com/BoostIO/Boostnote/blob/master/docs/fr/debug.md) and [German](https://github.com/BoostIO/Boostnote/blob/add-german-documents/docs/de/debug.md). -Boostnote is eine Electron app, somit basiert sie auf Chromium; Entwickler können die `Developer Tools` verwenden, wie Google Chrome. +Diese Seite ist auch verfügbar in [Japanisch](https://github.com/BoostIO/Boostnote/blob/master/docs/jp/debug.md), [Koreanisch](https://github.com/BoostIO/Boostnote/blob/master/docs/ko/debug.md), [Russisch](https://github.com/BoostIO/Boostnote/blob/master/docs/ru/debug.md), [Vereinfachtem Chinesisch](https://github.com/BoostIO/Boostnote/blob/master/docs/zh_CN/debug.md), [Französisch](https://github.com/BoostIO/Boostnote/blob/master/docs/fr/debug.md) und [Deutsch](https://github.com/BoostIO/Boostnote/blob/master/docs/de/debug.md). + + +Boostnote ist eine Electron App und basiert auf Chromium. + +Zum Entwicklen verwendest du am Besten die `Developer Tools` von Google Chrome verwenden. Diese kannst du ganz einfach im unter dem Menüpunkt `View` mit `Toggle Developer Tools` aktivieren: -Du kannst die `Developer Tools` so einschalten: ![how_to_toggle_devTools](https://cloud.githubusercontent.com/assets/11307908/24343585/162187e2-127c-11e7-9c01-23578db03ecf.png) -Die `Developer Tools` schauen dann ungefähr so aus: +Die Anzeige der `Developer Tools` sieht in etwa so aus: + ![Developer_Tools](https://cloud.githubusercontent.com/assets/11307908/24343545/eff9f3a6-127b-11e7-94cf-cb67bfda634a.png) -Wenn Fehler vorkommen, werden die Fehlermeldungen in der `console` ausgegeben. ## Debugging -Zum Beispiel kannst du mit dem `debugger` Haltepunkte im Code setzen wie hier veranschaulicht: + +Fehlermeldungen werden in der Regel in der `console` ausgegeben, die du über den gleichnamigen Reiter der `Developer Tools` anzeigen lassen kannst. Zum Debuggen kannst du beispielsweise über den `debugger` Haltepunkte im Code setzen. + ![debugger](https://cloud.githubusercontent.com/assets/11307908/24343879/9459efea-127d-11e7-9943-f60bf7f66d4a.png) -Das ist ledigtlich ein Beispiel, du kannst die Art von Debugging verwenden die für dich am besten ist. +Du kannst aber natürlich auch die Art von Debugging verwenden mit der du am besten zurecht kommst. ## Referenz + * [Official document of Google Chrome about debugging](https://developer.chrome.com/devtools) --- -Special thanks: Translated by [gino909](https://github.com/gino909) +Special thanks: Translated by [gino909](https://github.com/gino909), [mdeuerlein](https://github.com/mdeuerlein) diff --git a/docs/debug.md b/docs/debug.md index 30c217a4..78a71137 100644 --- a/docs/debug.md +++ b/docs/debug.md @@ -1,5 +1,5 @@ # How to debug Boostnote (Electron app) -This page is also available in [Japanese](https://github.com/BoostIO/Boostnote/blob/master/docs/jp/debug.md), [Korean](https://github.com/BoostIO/Boostnote/blob/master/docs/ko/debug.md), [Russain](https://github.com/BoostIO/Boostnote/blob/master/docs/ru/debug.md), [Simplified Chinese](https://github.com/BoostIO/Boostnote/blob/master/docs/zh_CN/debug.md), [French](https://github.com/BoostIO/Boostnote/blob/master/docs/fr/debug.md) and [German](https://github.com/BoostIO/Boostnote/blob/add-german-documents/docs/de/debug.md). +This page is also available in [Japanese](https://github.com/BoostIO/Boostnote/blob/master/docs/jp/debug.md), [Korean](https://github.com/BoostIO/Boostnote/blob/master/docs/ko/debug.md), [Russain](https://github.com/BoostIO/Boostnote/blob/master/docs/ru/debug.md), [Simplified Chinese](https://github.com/BoostIO/Boostnote/blob/master/docs/zh_CN/debug.md), [French](https://github.com/BoostIO/Boostnote/blob/master/docs/fr/debug.md) and [German](https://github.com/BoostIO/Boostnote/blob/master/docs/de/debug.md). Boostnote is an Electron app so it's based on Chromium; developers can use `Developer Tools` just like Google Chrome. diff --git a/docs/fr/build.md b/docs/fr/build.md index 0d718742..e66a11aa 100644 --- a/docs/fr/build.md +++ b/docs/fr/build.md @@ -1,11 +1,10 @@ # Build -Cette page est également disponible en [Japonais](https://github.com/BoostIO/Boostnote/blob/master/docs/jp/build.md), [Coréen](https://github.com/BoostIO/Boostnote/blob/master/docs/ko/build.md), [Russe](https://github.com/BoostIO/Boostnote/blob/master/docs/ru/build.md), et en [Chinois Simplifié](https://github.com/BoostIO/Boostnote/blob/master/docs/zh_CN/build.md). +Cette page est également disponible en [Anglais](https://github.com/BoostIO/Boostnote/blob/master/docs/build.md), [Japonais](https://github.com/BoostIO/Boostnote/blob/master/docs/jp/build.md), [Coréen](https://github.com/BoostIO/Boostnote/blob/master/docs/ko/build.md), [Russe](https://github.com/BoostIO/Boostnote/blob/master/docs/ru/build.md), [Chinois Simplifié](https://github.com/BoostIO/Boostnote/blob/master/docs/zh_CN/build.md) et en [Allemand](https://github.com/BoostIO/Boostnote/blob/master/docs/de/build.md) ## Environnements -* npm: 4.x -* node: 7.x -Il est conseillé d'utiliser `npm v4.x` car `$ grunt pre-build` ne marche pas sur la `v5.x`. +* npm: 6.x +* node: 8.x ## Développement @@ -20,17 +19,9 @@ $ yarn Build et start ``` -$ yarn run dev-start +$ yarn run dev ``` -Cette commande lance `yarn run webpack` et `yarn run hot` en parallèle. Cela revient au même que si on utilisait ces deux commandes dans 2 terminaux. - -La commande `webpack` va surveiller les changements de code et les appliquer automatiquement. - -Si l'erreur suivante apparait : `Failed to load resource: net::ERR_CONNECTION_REFUSED`, relancez Boostnote. - -![net::ERR_CONNECTION_REFUSED](https://cloud.githubusercontent.com/assets/11307908/24343004/081e66ae-1279-11e7-8d9e-7f478043d835.png) - > ### Notice > Il y a certains cas où vous voudrez relancer l'application manuellement. > 1. Quand vous éditez la méthode constructeur dans un composant @@ -43,8 +34,6 @@ Vous pouvez build le programme en utilisant `grunt`. Cependant, nous ne recomman Nous avons donc préparé un script séparé qui va rendre un fichier exécutable. -Le build ne fonctionne pas sur `npm v5.3.0`. Il faut donc utiliser `npm v5.2.0` quand vous faites le build. - ``` grunt pre-build ``` diff --git a/docs/fr/debug.md b/docs/fr/debug.md index 9395e4f9..f0b1be4b 100644 --- a/docs/fr/debug.md +++ b/docs/fr/debug.md @@ -1,5 +1,5 @@ # Comment débugger Boostnote (Application Electron) -Cette page est également disponible en [Japonais](https://github.com/BoostIO/Boostnote/blob/master/docs/jp/debug.md), [Coréen](https://github.com/BoostIO/Boostnote/blob/master/docs/ko/debug.md), [Russe](https://github.com/BoostIO/Boostnote/blob/master/docs/ru/debug.md), et en [Chinois Simplifié](https://github.com/BoostIO/Boostnote/blob/master/docs/zh_CN/debug.md) +Cette page est également disponible en [Angalis](https://github.com/BoostIO/Boostnote/blob/master/docs/debug.md), [Japonais](https://github.com/BoostIO/Boostnote/blob/master/docs/jp/debug.md), [Coréen](https://github.com/BoostIO/Boostnote/blob/master/docs/ko/debug.md), [Russe](https://github.com/BoostIO/Boostnote/blob/master/docs/ru/debug.md), [Chinois Simplifié](https://github.com/BoostIO/Boostnote/blob/master/docs/zh_CN/debug.md) et en [Allemand](https://github.com/BoostIO/Boostnote/blob/master/docs/de/debug.md) Boostnote est une application Electron donc basée sur Chromium. Il est possible d'utiliser les `Developer Tools` comme dans Google Chrome. @@ -19,4 +19,4 @@ Par exemple, vous pouvez utiliser le `debugger` pour placer un point d'arrêt da C'est une façon comme une autre de faire, vous pouvez trouver une façon de débugger que vous trouverez plus adaptée. ## Références -* [Documentation officiel de Google Chrome sur le debugging](https://developer.chrome.com/devtools) \ No newline at end of file +* [Documentation officiel de Google Chrome sur le debugging](https://developer.chrome.com/devtools) diff --git a/docs/jp/build.md b/docs/jp/build.md index 4d0fab33..00fad165 100644 --- a/docs/jp/build.md +++ b/docs/jp/build.md @@ -1,9 +1,15 @@ # Build +## 環境 + +* npm: 6.x +* node: 8.x + ## 開発 Webpack HRMを使います。 -次の命令から私達がしておいた設定を使うことができます。 +Boostnoteの最上位ディレクトリにて以下のコマンドを実行して、 +デフォルトの設定の開発環境を起動させます。 依存するパッケージをインストールします。 @@ -14,30 +20,20 @@ $ yarn ビルドして実行します。 ``` -$ yarn run dev-start +$ yarn run dev ``` -このコマンドは `yarn run webpack` と `yarn run hot`を並列に実行します。つまりこのコマンドは2つのターミナルで同時にこれらのコマンドを実行するのと同じことです。 - -そして、Webpackが自動的にコードの変更を確認し、それを適用してくれるようになります。 - -もし、 `Failed to load resource: net::ERR_CONNECTION_REFUSED`というエラーが起きた場合、Boostnoteをリロードしてください。 - -![net::ERR_CONNECTION_REFUSED](https://cloud.githubusercontent.com/assets/11307908/24343004/081e66ae-1279-11e7-8d9e-7f478043d835.png) - > ### 注意 > 時々、直接リフレッシュをする必要があります。 -> 1. コンポネントのコンストラクター関数を編集する場合 -> 2. 新しいCSSクラスを追加する場合(1.の理由と同じ: CSSクラス名はコンポネントごとに書きなおされまが、この作業はコンストラクターで行われます。) +> 1. コンポーネントのコンストラクタ関数を編集する場合 +> 2. 新しいCSSクラスを追加する場合(1.の理由と同じ: CSSクラス名はコンポーネントごとに書きなおされますが、この作業はコンストラクタで行われます。) ## 配布 Gruntを使います。 -実際の配布は`grunt`で実行できます。しかし、これにはCodesignとAuthenticodeの仮定が含まれるので、使っては行けないです。 +実際の配布は`grunt`で実行できます。しかし、これにはCodesignとAuthenticodeを実行するタスクが含まれるので、使用しないでください。 -それで、実行ファイルを作るスクリプトを用意しておきました。 - -このビルドはnpm v5.3.0では動かないのでv5.2.0で動かす必要があります。 +代わりに、実行ファイルを作るスクリプトを用意しておきました。 ``` grunt pre-build diff --git a/docs/ko/build.md b/docs/ko/build.md index 7bbe302f..17e9c712 100644 --- a/docs/ko/build.md +++ b/docs/ko/build.md @@ -1,8 +1,9 @@ # Build ## 환경 -* npm: 4.x -* node: 7.x + +* npm: 6.x +* node: 8.x `$ grunt pre-build`를 `npm v5.x`에서 실행할 수 없기 때문에, 반드시 `npm v4.x`를 사용하셔야 합니다. @@ -20,17 +21,9 @@ $ yarn 그 다음, 아래의 명령으로 빌드를 끝내고 자동적으로 어플리케이션을 실행합니다. ``` -$ yarn run dev-start +$ yarn run dev ``` -이 명령은 `yarn run webpack` 과 `yarn run hot`을 동시에 실행합니다. 이는 두개의 터미널에서 각각의 명령을 동시에 실행하는 것과 같습니다. - -`Webpack`은 코드의 변화를 자동으로 탐지하여 적용시키는 역할을 합니다. - -만약, `Failed to load resource: net::ERR_CONNECTION_REFUSED`과 같은 에러가 나타난다면 Boostnote를 리로드해주세요. - -![net::ERR_CONNECTION_REFUSED](https://cloud.githubusercontent.com/assets/11307908/24343004/081e66ae-1279-11e7-8d9e-7f478043d835.png) - > ### 주의 > 가끔 직접 리프레쉬를 해주어야 하는 경우가 있습니다. > 1. 콤포넌트의 컨스트럭터 함수를 수정할 경우 @@ -43,8 +36,6 @@ Boostnote에서는 배포 자동화를 위하여 그런트를 사용합니다. 그래서, 실행파일만을 만드는 스크립트를 준비해 뒀습니다. -이 빌드는 npm v5.3.0에서는 작동하지 않습니다. 그러므로, 성공적으로 빌드하기 위해서는 v5.2.0을 사용해야 합니다. - ``` grunt pre-build ``` diff --git a/docs/ru/build.md b/docs/ru/build.md index d106e482..8feebe5e 100644 --- a/docs/ru/build.md +++ b/docs/ru/build.md @@ -1,10 +1,9 @@ # Сборка ## Используемые инструменты -* npm: 4.x -* node: 7.x -Вы должны использовать `npm v4.x`, так как `$ grunt pre-build` не работает в `v5.x`. +* npm: 6.x +* node: 8.x ## Разработка @@ -20,17 +19,9 @@ $ yarn Соберите и запустите. ``` -$ yarn run dev-start +$ yarn run dev ``` -Эта команда выполняет `yarn run webpack` и `yarn run hot` параллельно. Результат будет такой же, если вы выполните эти две команды раздельно. - -`Webpack` будет следить за изменениями в коде и будет применять их автоматически. - -Если возникает следующая ошибка: `Failed to load resource: net::ERR_CONNECTION_REFUSED`, пожалуйста, перезапустите Boostnote. - -![net::ERR_CONNECTION_REFUSED](https://cloud.githubusercontent.com/assets/11307908/24343004/081e66ae-1279-11e7-8d9e-7f478043d835.png) - > ### Примечание > В некоторых случаях вам необходимо обновить приложение вручную. > 1. При редактировании метода конструктора компонента @@ -41,9 +32,7 @@ $ yarn run dev-start Мы используем Grunt для автоматического деплоя. Вы можете создать задачу, используя `grunt`. Однако мы не рекомендуем этого делать, так как задача по умолчанию включает в себя код и аутентификацию. -Мы подготовили отдельный скрипт, который просто создает исполняемый файл: - -This build doesn't work on npm v5.3.0. So you need to use v5.2.0 when you build it. +Мы подготовили отдельный скрипт, который просто создает исполняемый файл. ``` grunt pre-build diff --git a/docs/zh_CN/build.md b/docs/zh_CN/build.md index 9183ebb3..0fd83e27 100644 --- a/docs/zh_CN/build.md +++ b/docs/zh_CN/build.md @@ -1,37 +1,27 @@ # 构建Boostnote ## 环境 -* npm: 4.x -* node: 7.x -因为`$ grunt pre-build`的问题,您只能使用`npm v4.x`而不能使用`npm v5.x`。 +* npm: 6.x +* node: 8.x ## 开发 -我们使用Webpack HMR来开发Boostnote。 -在代码根目录下运行下列指令可以以默认配置运行Boostnote。 +我们使用Webpack HMR来开发Boostnote。 +在代码根目录下运行下列指令可以以默认配置运行Boostnote。 -### 首先使用yarn安装所需的依赖包。 +### 首先使用yarn安装所需的依赖包。 ``` $ yarn ``` -### 接着编译并且运行Boostnote。 +### 接着编译并且运行Boostnote。 ``` -$ yarn run dev-start +$ yarn run dev ``` -这个指令相当于在两个终端内同时运行`yarn run webpack`和`yarn run hot`。 - -如果出现错误`Failed to load resource: net::ERR_CONNECTION_REFUSED`,请尝试重新运行Boostnote。 -![net::ERR_CONNECTION_REFUSED](https://cloud.githubusercontent.com/assets/11307908/24343004/081e66ae-1279-11e7-8d9e-7f478043d835.png) - -### 然后您就可以进行开发了 - -当您对代码作出更改的时候,`webpack`会自动抓取并应用所有代码更改。 - > ### 提示 > 在如下情况中,您可能需要重新运行Boostnote才能应用代码更改 > 1. 当您在修改了一个组件的构造函数的时候When editing a constructor method of a component @@ -39,18 +29,16 @@ $ yarn run dev-start ## 部署 -我们使用Grunt来自动部署Boostnote。 -因为部署需要协同设计(codesign)与验证码(authenticode),所以您可以但我们不建议通过`grunt`来部署。 -所以我们准备了一个脚本文件来生成执行文件。 +我们使用Grunt来自动部署Boostnote。 +因为部署需要协同设计(codesign)与验证码(authenticode),所以您可以但我们不建议通过`grunt`来部署。 +所以我们准备了一个脚本文件来生成执行文件。 ``` grunt pre-build ``` -您只能使用`npm v5.2.0`而不能使用`npm v5.3.0`。 - -接下来您就可以在`dist`目录中找到可执行文件。 +接下来您就可以在`dist`目录中找到可执行文件。 > ### 提示 > 因为此可执行文件并没有被注册,所以自动更新不可用。 -> 如果需要,您也可将协同设计(codesign)与验证码(authenticode)使用于这个可执行文件中。 +> 如果需要,您也可将协同设计(codesign)与验证码(authenticode)使用于这个可执行文件中。 diff --git a/docs/zh_TW/build.md b/docs/zh_TW/build.md new file mode 100644 index 00000000..65b086ac --- /dev/null +++ b/docs/zh_TW/build.md @@ -0,0 +1,74 @@ +# 編譯 +此文件還提供下列的語言 [日文](https://github.com/BoostIO/Boostnote/blob/master/docs/jp/build.md), [韓文](https://github.com/BoostIO/Boostnote/blob/master/docs/ko/build.md), [俄文](https://github.com/BoostIO/Boostnote/blob/master/docs/ru/build.md), [簡體中文](https://github.com/BoostIO/Boostnote/blob/master/docs/zh_CN/build.md), [法文](https://github.com/BoostIO/Boostnote/blob/master/docs/fr/build.md) and [德文](https://github.com/BoostIO/Boostnote/blob/master/docs/de/build.md). + +## 環境 + +* npm: 6.x +* node: 8.x + +## 開發 + +我們使用 Webpack HMR 來開發 Boostnote。 + +在專案根目錄底下執行下列指令,將會以原始設置啟動 Boostnote。 + +**用 yarn 來安裝必要 packages** + +```bash +$ yarn +``` + +**開始開發** + +``` +$ yarn run dev +``` + +> ### Notice +> There are some cases where you have to refresh the app manually. +> 1. When editing a constructor method of a component +> 2. When adding a new css class (similar to 1: the CSS class is re-written by each component. This process occurs at the Constructor method.) + +## Deploy + +We use Grunt to automate deployment. +You can build the program by using `grunt`. However, we don't recommend this because the default task includes codesign and authenticode. + +So, we've prepared a separate script which just makes an executable file. + +``` +grunt pre-build +``` + +You will find the executable in the `dist` directory. Note, the auto updater won't work because the app isn't signed. + +If you find it necessary, you can use codesign or authenticode with this executable. + +## Make own distribution packages (deb, rpm) + +Distribution packages are created by exec `grunt build` on Linux platform (e.g. Ubuntu, Fedora). + +> Note: You can create both `.deb` and `.rpm` in a single environment. + +After installing the supported version of `node` and `npm`, install build dependency packages. + + +Ubuntu/Debian: + +``` +$ sudo apt-get install -y rpm fakeroot +``` + +Fedora: + +``` +$ sudo dnf install -y dpkg dpkg-dev rpm-build fakeroot +``` + +Then execute `grunt build`. + +``` +$ grunt build +``` + +You will find `.deb` and `.rpm` in the `dist` directory. diff --git a/extra_scripts/boost/boostNewLineIndentContinueMarkdownList.js b/extra_scripts/boost/boostNewLineIndentContinueMarkdownList.js new file mode 100644 index 00000000..aa766f6e --- /dev/null +++ b/extra_scripts/boost/boostNewLineIndentContinueMarkdownList.js @@ -0,0 +1,52 @@ +(function (mod) { + if (typeof exports === 'object' && typeof module === 'object') { // Common JS + mod(require('../codemirror/lib/codemirror')) + } else if (typeof define === 'function' && define.amd) { // AMD + define(['../codemirror/lib/codemirror'], mod) + } else { // Plain browser env + mod(CodeMirror) + } +})(function (CodeMirror) { + 'use strict' + + var listRE = /^(\s*)(>[> ]*|[*+-] \[[x ]\]\s|[*+-]\s|(\d+)([.)]))(\s*)/ + var emptyListRE = /^(\s*)(>[> ]*|[*+-] \[[x ]\]|[*+-]|(\d+)[.)])(\s*)$/ + var unorderedListRE = /[*+-]\s/ + + CodeMirror.commands.boostNewLineAndIndentContinueMarkdownList = function (cm) { + if (cm.getOption('disableInput')) return CodeMirror.Pass + var ranges = cm.listSelections() + var replacements = [] + for (var i = 0; i < ranges.length; i++) { + var pos = ranges[i].head + var eolState = cm.getStateAfter(pos.line) + var inList = eolState.list !== false + var inQuote = eolState.quote !== 0 + var line = cm.getLine(pos.line) + var match = listRE.exec(line) + if (!ranges[i].empty() || (!inList && !inQuote) || !match || pos.ch < match[2].length - 1) { + cm.execCommand('newlineAndIndent') + return + } + if (emptyListRE.test(line)) { + if (!/>\s*$/.test(line)) { + cm.replaceRange('', { + line: pos.line, ch: 0 + }, { + line: pos.line, ch: pos.ch + 1 + }) + } + replacements[i] = '\n' + } else { + var indent = match[1] + var after = match[5] + var bullet = unorderedListRE.test(match[2]) || match[2].indexOf('>') >= 0 + ? match[2].replace('x', ' ') + : (parseInt(match[3], 10) + 1) + match[4] + replacements[i] = '\n' + indent + bullet + after + } + } + + cm.replaceSelections(replacements) + } +}) diff --git a/index.js b/index.js index 5a34df9f..96f98e73 100644 --- a/index.js +++ b/index.js @@ -4,11 +4,6 @@ const path = require('path') var error = null -function isFinderCalled () { - var argv = process.argv.slice(1) - return argv.some((arg) => arg.match(/--finder/)) -} - function execMainApp () { const appRootPath = path.join(process.execPath, '../..') const updateDotExePath = path.join(appRootPath, 'Update.exe') @@ -78,8 +73,4 @@ function execMainApp () { require('./lib/main-app') } -if (isFinderCalled()) { - require('./lib/finder-app') -} else { - execMainApp() -} +execMainApp() diff --git a/lib/finder-app.js b/lib/finder-app.js deleted file mode 100755 index 8723c63a..00000000 --- a/lib/finder-app.js +++ /dev/null @@ -1,14 +0,0 @@ -const electron = require('electron') -const app = electron.app - -app.on('ready', function () { - if (process.platform === 'darwin') { - app.dock.hide() - } - - /* eslint-disable */ - finderWindow = require('./finder-window') - /* eslint-enable */ -}) - -module.exports = app diff --git a/lib/finder-window.js b/lib/finder-window.js deleted file mode 100644 index def6af49..00000000 --- a/lib/finder-window.js +++ /dev/null @@ -1,101 +0,0 @@ -const electron = require('electron') -const { app } = electron -const BrowserWindow = electron.BrowserWindow -const Menu = electron.Menu -const MenuItem = electron.MenuItem -const Tray = electron.Tray -const path = require('path') - -var config = { - width: 840, - height: 540, - show: false, - frame: false, - resizable: false, - zoomFactor: 1.0, - webPreferences: { - blinkFeatures: 'OverlayScrollbars' - }, - skipTaskbar: true, - standardWindow: false -} - -if (process.platform === 'darwin') { - config['always-on-top'] = true -} - -var finderWindow = new BrowserWindow(config) - -var url = path.resolve(__dirname, './finder.html') - -finderWindow.loadURL('file://' + url) -finderWindow.setSkipTaskbar(true) - -if (process.platform === 'darwin') { - finderWindow.setVisibleOnAllWorkspaces(true) -} - -finderWindow.on('blur', function () { - hideFinder() -}) - -finderWindow.on('close', function (e) { - e.preventDefault() - finderWindow.hide() -}) - -var trayIcon = process.platform === 'darwin' || process.platform === 'win32' - ? path.join(__dirname, '../resources/tray-icon-default.png') - : path.join(__dirname, '../resources/tray-icon.png') -var appIcon = new Tray(trayIcon) -appIcon.setToolTip('Boostnote') -if (process.platform === 'darwin') { - appIcon.setPressedImage(path.join(__dirname, '../resources/tray-icon-dark.png')) -} - -var trayMenu = new Menu() -trayMenu.append(new MenuItem({ - label: 'Open Main window', - click: function () { - finderWindow.webContents.send('open-main-from-tray') - } -})) - -if (process.env.platform !== 'linux' || process.env.DESKTOP_SESSION === 'cinnamon') { - trayMenu.append(new MenuItem({ - label: 'Open Finder window', - click: function () { - finderWindow.webContents.send('open-finder-from-tray') - } - })) -} - -trayMenu.append(new MenuItem({ - label: 'Quit', - click: function () { - finderWindow.webContents.send('quit-from-tray') - } -})) - -appIcon.setContextMenu(trayMenu) -appIcon.on('click', function (e) { - e.preventDefault() - appIcon.popUpContextMenu(trayMenu) -}) - -function hideFinder () { - if (process.platform === 'win32') { - finderWindow.minimize() - return - } - if (process.platform === 'darwin') { - Menu.sendActionToFirstResponder('hide:') - } - finderWindow.hide() -} - -app.on('before-quit', function (e) { - finderWindow.removeAllListeners() -}) - -module.exports = finderWindow diff --git a/lib/finder.html b/lib/finder.html deleted file mode 100644 index b57e3047..00000000 --- a/lib/finder.html +++ /dev/null @@ -1,65 +0,0 @@ - - - - - Boostnote Finder - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/lib/ipcServer.js b/lib/ipcServer.js index bd60a280..487a9a63 100644 --- a/lib/ipcServer.js +++ b/lib/ipcServer.js @@ -26,10 +26,6 @@ function toggleMainWindow () { } } -function toggleFinder () { - nodeIpc.server.broadcast('open-finder') -} - ipcMain.on('config-renew', (e, payload) => { nodeIpc.server.broadcast('config-renew', payload) @@ -37,11 +33,6 @@ ipcMain.on('config-renew', (e, payload) => { var { config } = payload var errors = [] - try { - globalShortcut.register(config.hotkey.toggleFinder, toggleFinder) - } catch (err) { - errors.push('toggleFinder') - } try { globalShortcut.register(config.hotkey.toggleMain, toggleMainWindow) } catch (err) { @@ -61,12 +52,6 @@ ipcMain.on('config-renew', (e, payload) => { nodeIpc.serve( path.join(app.getPath('userData'), 'boostnote.service'), function () { - nodeIpc.server.on('open-main-from-finder', toggleMainWindow) - - nodeIpc.server.on('quit-from-finder', function () { - app.quit() - }) - nodeIpc.server.on('connect', function (socket) { nodeIpc.log('ipc server >> socket joinned'.rainbow) socket.on('close', function () { @@ -76,14 +61,6 @@ nodeIpc.serve( nodeIpc.server.on('error', function (err) { nodeIpc.log('Node IPC error'.rainbow, err) }) - - // Todo: Direct connection between Main window renderer & Finder window renderer - nodeIpc.server.on('request-data-from-finder', function () { - nodeIpc.server.broadcast('request-data-from-finder') - }) - nodeIpc.server.on('throttle-data', function (payload) { - nodeIpc.server.broadcast('throttle-data', payload) - }) } ) diff --git a/lib/main-app.js b/lib/main-app.js index 85a0dead..1f3f1320 100644 --- a/lib/main-app.js +++ b/lib/main-app.js @@ -2,9 +2,6 @@ const electron = require('electron') const app = electron.app const Menu = electron.Menu const ipc = electron.ipcMain -const path = require('path') -const ChildProcess = require('child_process') -const _ = require('lodash') const GhReleases = require('electron-gh-releases') // electron.crashReporter.start() var ipcServer = null @@ -72,16 +69,9 @@ ipc.on('update-app-confirm', function (event, msg) { } }) -function spawnFinder () { - var finderArgv = [path.join(__dirname, 'finder-app.js'), '--finder'] - if (_.find(process.argv, a => a === '--hot')) finderArgv.push('--hot') - var finderProcess = ChildProcess - .execFile(process.execPath, finderArgv) - - app.on('before-quit', function () { - finderProcess.kill() - }) -} +app.on('window-all-closed', function () { + app.quit() +}) app.on('ready', function () { mainWindow = require('./main-window') @@ -90,20 +80,17 @@ app.on('ready', function () { var menu = Menu.buildFromTemplate(template) switch (process.platform) { case 'darwin': - spawnFinder() Menu.setApplicationMenu(menu) break case 'win32': - require('./finder-window') mainWindow.setMenu(menu) break case 'linux': - require('./finder-window') Menu.setApplicationMenu(menu) mainWindow.setMenu(menu) } - // Check update every hour + // Check update every day setInterval(function () { checkUpdate() }, 1000 * 60 * 60 * 24) @@ -119,10 +106,9 @@ app.on('ready', function () { checkUpdate() } }) - }, 10000) + }, 10 * 1000) ipcServer = require('./ipcServer') ipcServer.server.start() }) module.exports = app - diff --git a/lib/main-menu.js b/lib/main-menu.js index 0d49ab86..cda964c5 100644 --- a/lib/main-menu.js +++ b/lib/main-menu.js @@ -1,6 +1,7 @@ const electron = require('electron') const BrowserWindow = electron.BrowserWindow const shell = electron.shell +const ipc = electron.ipcMain const mainWindow = require('./main-window') const macOS = process.platform === 'darwin' @@ -135,6 +136,15 @@ const file = { { type: 'separator' }, + { + label: 'Format Table', + click () { + mainWindow.webContents.send('code:format-table') + } + }, + { + type: 'separator' + }, { label: 'Print', accelerator: 'CommandOrControl+P', @@ -208,6 +218,16 @@ const edit = { label: 'Select All', accelerator: 'Command+A', selector: 'selectAll:' + }, + { + type: 'separator' + }, + { + label: 'Add Tag', + accelerator: 'CommandOrControl+Shift+T', + click () { + mainWindow.webContents.send('editor:add-tag') + } } ] } @@ -234,14 +254,14 @@ const view = { }, { label: 'Next Note', - accelerator: 'Control+J', + accelerator: 'CommandOrControl+]', click () { mainWindow.webContents.send('list:next') } }, { label: 'Previous Note', - accelerator: 'Control+K', + accelerator: 'CommandOrControl+[', click () { mainWindow.webContents.send('list:prior') } @@ -251,14 +271,61 @@ const view = { }, { label: 'Focus Search', - accelerator: 'Control+S', + accelerator: 'CommandOrControl+Shift+L', click () { mainWindow.webContents.send('top:focus-search') } + }, + { + type: 'separator' + }, + { + label: 'Toggle Full Screen', + accelerator: macOS ? 'Command+Control+F' : 'F11', + click () { + mainWindow.setFullScreen(!mainWindow.isFullScreen()) + } + }, + { + type: 'separator' + }, + { + label: 'Toggle Side Bar', + accelerator: 'CommandOrControl+B', + click () { + mainWindow.webContents.send('editor:fullscreen') + } + }, + { + type: 'separator' + }, + { + role: 'zoomin', + accelerator: macOS ? 'CommandOrControl+Plus' : 'Control+=' + }, + { + role: 'zoomout' } ] } +let editorFocused + +// Define extra shortcut keys +mainWindow.webContents.on('before-input-event', (event, input) => { + // Synonyms for Search (Find) + if (input.control && input.key === 'l' && input.type === 'keyDown') { + if (!editorFocused) { + mainWindow.webContents.send('top:focus-search') + event.preventDefault() + } + } +}) + +ipc.on('editor:focused', (event, isFocused) => { + editorFocused = isFocused +}) + const window = { label: 'Window', submenu: [ diff --git a/lib/main-window.js b/lib/main-window.js index 5c0090fc..fa54d5ce 100644 --- a/lib/main-window.js +++ b/lib/main-window.js @@ -4,11 +4,14 @@ const BrowserWindow = electron.BrowserWindow const path = require('path') const Config = require('electron-config') const config = new Config() +const _ = require('lodash') var showMenu = process.platform !== 'win32' -const windowSize = config.get('windowsize') || { width: 1080, height: 720 } +const windowSize = config.get('windowsize') || { x: null, y: null, width: 1080, height: 720 } const mainWindow = new BrowserWindow({ + x: windowSize.x, + y: windowSize.y, width: windowSize.width, height: windowSize.height, minWidth: 500, @@ -16,7 +19,7 @@ const mainWindow = new BrowserWindow({ autoHideMenuBar: showMenu, webPreferences: { zoomFactor: 1.0, - blinkFeatures: 'OverlayScrollbars' + enableBlinkFeatures: 'OverlayScrollbars' }, icon: path.resolve(__dirname, '../resources/app.png') }) @@ -39,41 +42,26 @@ mainWindow.webContents.sendInputEvent({ keyCode: '\u0008' }) -if (process.platform !== 'linux' || process.env.DESKTOP_SESSION === 'cinnamon') { +if (process.platform === 'darwin') { mainWindow.on('close', function (e) { e.preventDefault() - if (process.platform === 'win32') { - quitApp() - } else { - if (mainWindow.isFullScreen()) { - mainWindow.once('leave-full-screen', function () { - mainWindow.hide() - }) - mainWindow.setFullScreen(false) - } else { + if (mainWindow.isFullScreen()) { + mainWindow.once('leave-full-screen', function () { mainWindow.hide() - } + }) + mainWindow.setFullScreen(false) + } else { + mainWindow.hide() } }) app.on('before-quit', function (e) { - storeWindowSize() mainWindow.removeAllListeners() }) -} else { - mainWindow.on('close', function () { - storeWindowSize() - }) - - app.on('window-all-closed', function () { - app.quit() - }) } -function quitApp () { - storeWindowSize() - app.quit() -} +mainWindow.on('resize', _.throttle(storeWindowSize, 500)) +mainWindow.on('move', _.throttle(storeWindowSize, 500)) function storeWindowSize () { try { diff --git a/lib/main.html b/lib/main.html index 11c1c62e..7366fa04 100644 --- a/lib/main.html +++ b/lib/main.html @@ -1,68 +1,83 @@ + - + + Boostnote +
-
+
+ +
@@ -73,14 +88,16 @@ + - + + @@ -88,7 +105,13 @@ + + + + + + @@ -97,14 +120,13 @@ -