diff --git a/.babelrc b/.babelrc index 270349d2..3a366286 100644 --- a/.babelrc +++ b/.babelrc @@ -7,7 +7,7 @@ "test": { "presets": ["env" ,"react", "es2015"], "plugins": [ - [ "babel-plugin-webpack-alias", { "config": "${PWD}/webpack.config.js" } ] + [ "babel-plugin-webpack-alias", { "config": "/webpack.config.js" } ] ] } } diff --git a/.eslintrc b/.eslintrc index 1709c9d8..be8cb903 100644 --- a/.eslintrc +++ b/.eslintrc @@ -18,7 +18,9 @@ "globals": { "FileReader": true, "localStorage": true, - "fetch": true + "fetch": true, + "Image": true, + "MutationObserver": true }, "env": { "jest": true diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..ea304082 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +issuehunt: BoostIo/Boostnote diff --git a/.gitignore b/.gitignore index ace5316c..aac64950 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ node_modules/* /secret *.log .idea -.vscode \ No newline at end of file +.vscode +package-lock.json diff --git a/.travis.yml b/.travis.yml index d9267f77..71b65671 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,9 @@ language: node_js node_js: - - 7 + - 8 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' + - 'if [[ ${TRAVIS_PULL_REQUEST_BRANCH:-$TRAVIS_BRANCH} = "master" ]]; then npm install -g grunt npm@6.4 && grunt pre-build; fi' after_success: - openssl aes-256-cbc -K $encrypted_440d7f9a3c38_key -iv $encrypted_440d7f9a3c38_iv -in .snapcraft/travis_snapcraft.cfg -out .snapcraft/snapcraft.cfg -d diff --git a/FAQ.md b/FAQ.md new file mode 100644 index 00000000..c7fc4016 --- /dev/null +++ b/FAQ.md @@ -0,0 +1,29 @@ +# Frequently Asked Questions + + +
Allowing dangerous HTML tags + +Sometimes it is useful to allow dangerous HTML tags to add interactivity to your notebook. One of the example is to use details/summary as a way to expand/collaps your todo-list. + +* How to enable: + * Go to **Preferences** → **Interface** → **Sanitization** → **Allow dangerous html tags** +* Example note: Multiple todo-list + * Create new notes + * Paste the below code, and you'll see that you can expand/collaps the todo-list, and you can have multiple todo-list in your note. + +```html +
What I want to do + +- [x] Create an awesome feature X +- [ ] Do my homework + +
+``` + +
+ +## Other questions + +You can ask [here][ISSUES] + +[ISSUES]: https://github.com/BoostIO/Boostnote/issues diff --git a/LICENSE b/LICENSE index 7472c9eb..2d1ab131 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 - 2018 BoostIO +Copyright (C) 2017 - 2019 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/browser/components/CodeEditor.js b/browser/components/CodeEditor.js index aa380e38..7080b1fc 100644 --- a/browser/components/CodeEditor.js +++ b/browser/components/CodeEditor.js @@ -2,28 +2,40 @@ import PropTypes from 'prop-types' import React from 'react' import _ from 'lodash' import CodeMirror from 'codemirror' +import hljs from 'highlight.js' import 'codemirror-mode-elixir' import attachmentManagement from 'browser/main/lib/dataApi/attachmentManagement' import convertModeName from 'browser/lib/convertModeName' -import { options, TableEditor, Alignment } from '@susisu/mte-kernel' +import { + options, + TableEditor, + Alignment +} 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 { isMarkdownTitleURL } from 'browser/lib/utils' import styles from '../components/CodeEditor.styl' -import fs from 'fs' const { ipcRenderer, remote, clipboard } = require('electron') import normalizeEditorFontFamily from 'browser/lib/normalizeEditorFontFamily' const spellcheck = require('browser/lib/spellcheck') -const buildEditorContextMenu = require('browser/lib/contextMenuBuilder') -import TurndownService from 'turndown' -import { gfm } from 'turndown-plugin-gfm' +const buildEditorContextMenu = require('browser/lib/contextMenuBuilder').buildEditorContextMenu +import { createTurndownService } from '../lib/turndown' +import {languageMaps} from '../lib/CMLanguageList' +import snippetManager from '../lib/SnippetManager' +import {generateInEditor, tocExistsInEditor} from 'browser/lib/markdown-toc-generator' +import markdownlint from 'markdownlint' +import Jsonlint from 'jsonlint-mod' +import { DEFAULT_CONFIG } from '../main/lib/ConfigManager' +import prettier from 'prettier' CodeMirror.modeURL = '../node_modules/codemirror/mode/%N/%N.js' const buildCMRulers = (rulers, enableRulers) => - (enableRulers ? rulers.map(ruler => ({ column: ruler })) : []) + (enableRulers ? rulers.map(ruler => ({ + column: ruler + })) : []) function translateHotkey (hotkey) { return hotkey.replace(/\s*\+\s*/g, '-').replace(/Command/g, 'Cmd').replace(/Control/g, 'Ctrl') @@ -42,6 +54,7 @@ export default class CodeEditor extends React.Component { this.focusHandler = () => { ipcRenderer.send('editor:focused', true) } + const debouncedDeletionOfAttachments = _.debounce(attachmentManagement.deleteAttachmentsNotPresentInNote, 30000) this.blurHandler = (editor, e) => { ipcRenderer.send('editor:focused', false) if (e == null) return null @@ -53,13 +66,13 @@ 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(), + const { storageKey, noteKey - ) + } = this.props + if (this.props.deleteUnusedAttachments === true) { + debouncedDeletionOfAttachments(this.editor.getValue(), storageKey, noteKey) + } } this.pasteHandler = (editor, e) => { e.preventDefault() @@ -72,6 +85,8 @@ export default class CodeEditor extends React.Component { this.searchHandler = (e, msg) => this.handleSearch(msg) this.searchState = null this.scrollToLineHandeler = this.scrollToLine.bind(this) + this.getCodeEditorLintConfig = this.getCodeEditorLintConfig.bind(this) + this.validatorOfMarkdown = this.validatorOfMarkdown.bind(this) this.formatTable = () => this.handleFormatTable() @@ -86,7 +101,7 @@ export default class CodeEditor extends React.Component { this.editorActivityHandler = () => this.handleEditorActivity() - this.turndownService = new TurndownService() + this.turndownService = createTurndownService() } handleSearch (msg) { @@ -94,7 +109,7 @@ export default class CodeEditor extends React.Component { const component = this if (component.searchState) cm.removeOverlay(component.searchState) - if (msg.length < 3) return + if (msg.length < 1) return cm.operation(function () { component.searchState = makeOverlay(msg, 'searching') @@ -124,7 +139,9 @@ export default class CodeEditor extends React.Component { } handleFormatTable () { - this.tableEditor.formatAll(options({textWidthOptions: {}})) + this.tableEditor.formatAll(options({ + textWidthOptions: {} + })) } handleEditorActivity () { @@ -135,7 +152,8 @@ export default class CodeEditor extends React.Component { updateDefaultKeyMap () { const { hotkey } = this.props - const expandSnippet = this.expandSnippet.bind(this) + const self = this + const expandSnippet = snippetManager.expandSnippet this.defaultKeyMap = CodeMirror.normalizeKeyMap({ Tab: function (cm) { @@ -155,14 +173,16 @@ export default class CodeEditor extends React.Component { } cm.execCommand('goLineEnd') } else if ( - !charBeforeCursor.match(/\t|\s|\r|\n/) && + !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') + const wordBeforeCursor = self.getWordBeforeCursor( + line, + cursor.line, + cursor.ch ) - if (expandSnippet(line, cursor, cm, snippets) === false) { + if (expandSnippet(wordBeforeCursor, cursor, cm) === false) { if (tabs) { cm.execCommand('insertTab') } else { @@ -184,6 +204,14 @@ export default class CodeEditor extends React.Component { 'Cmd-T': function (cm) { // Do nothing }, + [translateHotkey(hotkey.insertDate)]: function (cm) { + const dateNow = new Date() + cm.replaceSelection(dateNow.toLocaleDateString()) + }, + [translateHotkey(hotkey.insertDateTime)]: function (cm) { + const dateNow = new Date() + cm.replaceSelection(dateNow.toLocaleString()) + }, Enter: 'boostNewLineAndIndentContinueMarkdownList', 'Ctrl-C': cm => { if (cm.getOption('keyMap').substr(0, 3) === 'vim') { @@ -191,6 +219,37 @@ export default class CodeEditor extends React.Component { } return CodeMirror.Pass }, + [translateHotkey(hotkey.prettifyMarkdown)]: cm => { + // Default / User configured prettier options + const currentConfig = JSON.parse(self.props.prettierConfig) + + // Parser type will always need to be markdown so we override the option before use + currentConfig.parser = 'markdown' + + // Get current cursor position + const cursorPos = cm.getCursor() + currentConfig.cursorOffset = cm.doc.indexFromPos(cursorPos) + + // Prettify contents of editor + const formattedTextDetails = prettier.formatWithCursor(cm.doc.getValue(), currentConfig) + + const formattedText = formattedTextDetails.formatted + const formattedCursorPos = formattedTextDetails.cursorOffset + cm.doc.setValue(formattedText) + + // Reset Cursor position to be at the same markdown as was before prettifying + const newCursorPos = cm.doc.posFromIndex(formattedCursorPos) + cm.doc.setCursor(newCursorPos) + }, + [translateHotkey(hotkey.sortLines)]: cm => { + const selection = cm.doc.getSelection() + const appendLineBreak = /\n$/.test(selection) + + const sorted = _.split(selection.trim(), '\n').sort() + const sortedString = _.join(sorted, '\n') + (appendLineBreak ? '\n' : '') + + cm.doc.replaceSelection(sortedString) + }, [translateHotkey(hotkey.pasteSmartly)]: cm => { this.handlePaste(cm, true) } @@ -214,25 +273,10 @@ export default class CodeEditor extends React.Component { } componentDidMount () { - const { rulers, enableRulers } = this.props + const { rulers, enableRulers, enableMarkdownLint } = this.props eventEmitter.on('line:jump', this.scrollToLineHandeler) - 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' - ) - } - + snippetManager.init() this.updateDefaultKeyMap() this.value = this.props.value @@ -241,7 +285,7 @@ export default class CodeEditor extends React.Component { value: this.props.value, linesHighlighted: this.props.linesHighlighted, lineNumbers: this.props.displayLineNumbers, - lineWrapping: true, + lineWrapping: this.props.lineWrapping, theme: this.props.theme, indentUnit: this.props.indentSize, tabSize: this.props.indentSize, @@ -251,17 +295,25 @@ export default class CodeEditor extends React.Component { inputStyle: 'textarea', dragDrop: false, foldGutter: true, - gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'], + lint: enableMarkdownLint ? this.getCodeEditorLintConfig() : false, + gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'], autoCloseBrackets: { - pairs: '()[]{}\'\'""$$**``', - triples: '```"""\'\'\'', - explode: '[]{}``$$', + pairs: this.props.matchingPairs, + triples: this.props.matchingTriples, + explode: this.props.explodingPairs, override: true }, - extraKeys: this.defaultKeyMap + extraKeys: this.defaultKeyMap, + prettierConfig: this.props.prettierConfig }) - this.setMode(this.props.mode) + document.querySelector('.CodeMirror-lint-markers').style.display = enableMarkdownLint ? 'inline-block' : 'none' + + if (!this.props.mode && this.props.value && this.props.autoDetect) { + this.autoDetectLanguage(this.props.value) + } else { + this.setMode(this.props.mode) + } this.editor.on('focus', this.focusHandler) this.editor.on('blur', this.blurHandler) @@ -298,43 +350,117 @@ export default class CodeEditor extends React.Component { }) this.editorKeyMap = CodeMirror.normalizeKeyMap({ - 'Tab': () => { this.tableEditor.nextCell(this.tableEditorOptions) }, - 'Shift-Tab': () => { this.tableEditor.previousCell(this.tableEditorOptions) }, - 'Enter': () => { this.tableEditor.nextRow(this.tableEditorOptions) }, - 'Ctrl-Enter': () => { this.tableEditor.escape(this.tableEditorOptions) }, - 'Cmd-Enter': () => { this.tableEditor.escape(this.tableEditorOptions) }, - 'Shift-Ctrl-Left': () => { this.tableEditor.alignColumn(Alignment.LEFT, this.tableEditorOptions) }, - 'Shift-Cmd-Left': () => { this.tableEditor.alignColumn(Alignment.LEFT, this.tableEditorOptions) }, - 'Shift-Ctrl-Right': () => { this.tableEditor.alignColumn(Alignment.RIGHT, this.tableEditorOptions) }, - 'Shift-Cmd-Right': () => { this.tableEditor.alignColumn(Alignment.RIGHT, this.tableEditorOptions) }, - 'Shift-Ctrl-Up': () => { this.tableEditor.alignColumn(Alignment.CENTER, this.tableEditorOptions) }, - 'Shift-Cmd-Up': () => { this.tableEditor.alignColumn(Alignment.CENTER, this.tableEditorOptions) }, - 'Shift-Ctrl-Down': () => { this.tableEditor.alignColumn(Alignment.NONE, this.tableEditorOptions) }, - 'Shift-Cmd-Down': () => { this.tableEditor.alignColumn(Alignment.NONE, this.tableEditorOptions) }, - 'Ctrl-Left': () => { this.tableEditor.moveFocus(0, -1, this.tableEditorOptions) }, - 'Cmd-Left': () => { this.tableEditor.moveFocus(0, -1, this.tableEditorOptions) }, - 'Ctrl-Right': () => { this.tableEditor.moveFocus(0, 1, this.tableEditorOptions) }, - 'Cmd-Right': () => { this.tableEditor.moveFocus(0, 1, this.tableEditorOptions) }, - 'Ctrl-Up': () => { this.tableEditor.moveFocus(-1, 0, this.tableEditorOptions) }, - 'Cmd-Up': () => { this.tableEditor.moveFocus(-1, 0, this.tableEditorOptions) }, - 'Ctrl-Down': () => { this.tableEditor.moveFocus(1, 0, this.tableEditorOptions) }, - 'Cmd-Down': () => { this.tableEditor.moveFocus(1, 0, this.tableEditorOptions) }, - 'Ctrl-K Ctrl-I': () => { this.tableEditor.insertRow(this.tableEditorOptions) }, - 'Cmd-K Cmd-I': () => { this.tableEditor.insertRow(this.tableEditorOptions) }, - 'Ctrl-L Ctrl-I': () => { this.tableEditor.deleteRow(this.tableEditorOptions) }, - 'Cmd-L Cmd-I': () => { this.tableEditor.deleteRow(this.tableEditorOptions) }, - 'Ctrl-K Ctrl-J': () => { this.tableEditor.insertColumn(this.tableEditorOptions) }, - 'Cmd-K Cmd-J': () => { this.tableEditor.insertColumn(this.tableEditorOptions) }, - 'Ctrl-L Ctrl-J': () => { this.tableEditor.deleteColumn(this.tableEditorOptions) }, - 'Cmd-L Cmd-J': () => { this.tableEditor.deleteColumn(this.tableEditorOptions) }, - 'Alt-Shift-Ctrl-Left': () => { this.tableEditor.moveColumn(-1, this.tableEditorOptions) }, - 'Alt-Shift-Cmd-Left': () => { this.tableEditor.moveColumn(-1, this.tableEditorOptions) }, - 'Alt-Shift-Ctrl-Right': () => { this.tableEditor.moveColumn(1, this.tableEditorOptions) }, - 'Alt-Shift-Cmd-Right': () => { this.tableEditor.moveColumn(1, this.tableEditorOptions) }, - 'Alt-Shift-Ctrl-Up': () => { this.tableEditor.moveRow(-1, this.tableEditorOptions) }, - 'Alt-Shift-Cmd-Up': () => { this.tableEditor.moveRow(-1, this.tableEditorOptions) }, - 'Alt-Shift-Ctrl-Down': () => { this.tableEditor.moveRow(1, this.tableEditorOptions) }, - 'Alt-Shift-Cmd-Down': () => { this.tableEditor.moveRow(1, this.tableEditorOptions) } + 'Tab': () => { + this.tableEditor.nextCell(this.tableEditorOptions) + }, + 'Shift-Tab': () => { + this.tableEditor.previousCell(this.tableEditorOptions) + }, + 'Enter': () => { + this.tableEditor.nextRow(this.tableEditorOptions) + }, + 'Ctrl-Enter': () => { + this.tableEditor.escape(this.tableEditorOptions) + }, + 'Cmd-Enter': () => { + this.tableEditor.escape(this.tableEditorOptions) + }, + 'Shift-Ctrl-Left': () => { + this.tableEditor.alignColumn(Alignment.LEFT, this.tableEditorOptions) + }, + 'Shift-Cmd-Left': () => { + this.tableEditor.alignColumn(Alignment.LEFT, this.tableEditorOptions) + }, + 'Shift-Ctrl-Right': () => { + this.tableEditor.alignColumn(Alignment.RIGHT, this.tableEditorOptions) + }, + 'Shift-Cmd-Right': () => { + this.tableEditor.alignColumn(Alignment.RIGHT, this.tableEditorOptions) + }, + 'Shift-Ctrl-Up': () => { + this.tableEditor.alignColumn(Alignment.CENTER, this.tableEditorOptions) + }, + 'Shift-Cmd-Up': () => { + this.tableEditor.alignColumn(Alignment.CENTER, this.tableEditorOptions) + }, + 'Shift-Ctrl-Down': () => { + this.tableEditor.alignColumn(Alignment.NONE, this.tableEditorOptions) + }, + 'Shift-Cmd-Down': () => { + this.tableEditor.alignColumn(Alignment.NONE, this.tableEditorOptions) + }, + 'Ctrl-Left': () => { + this.tableEditor.moveFocus(0, -1, this.tableEditorOptions) + }, + 'Cmd-Left': () => { + this.tableEditor.moveFocus(0, -1, this.tableEditorOptions) + }, + 'Ctrl-Right': () => { + this.tableEditor.moveFocus(0, 1, this.tableEditorOptions) + }, + 'Cmd-Right': () => { + this.tableEditor.moveFocus(0, 1, this.tableEditorOptions) + }, + 'Ctrl-Up': () => { + this.tableEditor.moveFocus(-1, 0, this.tableEditorOptions) + }, + 'Cmd-Up': () => { + this.tableEditor.moveFocus(-1, 0, this.tableEditorOptions) + }, + 'Ctrl-Down': () => { + this.tableEditor.moveFocus(1, 0, this.tableEditorOptions) + }, + 'Cmd-Down': () => { + this.tableEditor.moveFocus(1, 0, this.tableEditorOptions) + }, + 'Ctrl-K Ctrl-I': () => { + this.tableEditor.insertRow(this.tableEditorOptions) + }, + 'Cmd-K Cmd-I': () => { + this.tableEditor.insertRow(this.tableEditorOptions) + }, + 'Ctrl-L Ctrl-I': () => { + this.tableEditor.deleteRow(this.tableEditorOptions) + }, + 'Cmd-L Cmd-I': () => { + this.tableEditor.deleteRow(this.tableEditorOptions) + }, + 'Ctrl-K Ctrl-J': () => { + this.tableEditor.insertColumn(this.tableEditorOptions) + }, + 'Cmd-K Cmd-J': () => { + this.tableEditor.insertColumn(this.tableEditorOptions) + }, + 'Ctrl-L Ctrl-J': () => { + this.tableEditor.deleteColumn(this.tableEditorOptions) + }, + 'Cmd-L Cmd-J': () => { + this.tableEditor.deleteColumn(this.tableEditorOptions) + }, + 'Alt-Shift-Ctrl-Left': () => { + this.tableEditor.moveColumn(-1, this.tableEditorOptions) + }, + 'Alt-Shift-Cmd-Left': () => { + this.tableEditor.moveColumn(-1, this.tableEditorOptions) + }, + 'Alt-Shift-Ctrl-Right': () => { + this.tableEditor.moveColumn(1, this.tableEditorOptions) + }, + 'Alt-Shift-Cmd-Right': () => { + this.tableEditor.moveColumn(1, this.tableEditorOptions) + }, + 'Alt-Shift-Ctrl-Up': () => { + this.tableEditor.moveRow(-1, this.tableEditorOptions) + }, + 'Alt-Shift-Cmd-Up': () => { + this.tableEditor.moveRow(-1, this.tableEditorOptions) + }, + 'Alt-Shift-Ctrl-Down': () => { + this.tableEditor.moveRow(1, this.tableEditorOptions) + }, + 'Alt-Shift-Cmd-Down': () => { + this.tableEditor.moveRow(1, this.tableEditorOptions) + } }) if (this.props.enableTableEditor) { @@ -349,61 +475,12 @@ export default class CodeEditor extends React.Component { this.initialHighlighting() } - 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 - - let cursorIndex - for (let j = 0; j < snippetLines.length; j++) { - cursorIndex = snippetLines[j].indexOf(templateCursorString) - - if (cursorIndex !== -1) { - cursorLineNumber = j - cursorLinePosition = cursorIndex - - break - } - } - - cm.replaceRange( - snippets[i].content.replace(templateCursorString, ''), - wordBeforeCursor.range.from, - wordBeforeCursor.range.to - ) - cm.setCursor({ - line: cursor.line + cursorLineNumber, - ch: cursorLinePosition + cursor.ch - wordBeforeCursor.text.length - }) - } 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/ + const emptyChars = /\t|\s|\r|\n|\$/ - // to prevent the word to expand is long that will crash the whole app + // to prevent the word 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 @@ -413,7 +490,7 @@ export default class CodeEditor extends React.Component { if (!emptyChars.test(currentChar)) { wordBeforeCursor = currentChar + wordBeforeCursor } else if (wordBeforeCursor.length >= safeStop) { - throw new Error('Your snippet trigger is too long !') + throw new Error('Stopped after 20 loops for safety reason !') } else { break } @@ -423,8 +500,14 @@ export default class CodeEditor extends React.Component { return { text: wordBeforeCursor, range: { - from: { line: lineNumber, ch: originCursorPosition }, - to: { line: lineNumber, ch: cursorPosition } + from: { + line: lineNumber, + ch: originCursorPosition + }, + to: { + line: lineNumber, + ch: cursorPosition + } } } } @@ -450,7 +533,12 @@ export default class CodeEditor extends React.Component { componentDidUpdate (prevProps, prevState) { let needRefresh = false - const { rulers, enableRulers } = this.props + const { + rulers, + enableRulers, + enableMarkdownLint, + customMarkdownLintConfig + } = this.props if (prevProps.mode !== this.props.mode) { this.setMode(this.props.mode) } @@ -467,6 +555,16 @@ export default class CodeEditor extends React.Component { if (prevProps.keyMap !== this.props.keyMap) { needRefresh = true } + if (prevProps.enableMarkdownLint !== enableMarkdownLint || prevProps.customMarkdownLintConfig !== customMarkdownLintConfig) { + if (!enableMarkdownLint) { + this.editor.setOption('lint', {default: false}) + document.querySelector('.CodeMirror-lint-markers').style.display = 'none' + } else { + this.editor.setOption('lint', this.getCodeEditorLintConfig()) + document.querySelector('.CodeMirror-lint-markers').style.display = 'inline-block' + } + needRefresh = true + } if ( prevProps.enableRulers !== enableRulers || @@ -487,10 +585,26 @@ export default class CodeEditor extends React.Component { this.editor.setOption('lineNumbers', this.props.displayLineNumbers) } + if (prevProps.lineWrapping !== this.props.lineWrapping) { + this.editor.setOption('lineWrapping', this.props.lineWrapping) + } + if (prevProps.scrollPastEnd !== this.props.scrollPastEnd) { this.editor.setOption('scrollPastEnd', this.props.scrollPastEnd) } + if (prevProps.matchingPairs !== this.props.matchingPairs || + prevProps.matchingTriples !== this.props.matchingTriples || + prevProps.explodingPairs !== this.props.explodingPairs) { + const bracketObject = { + pairs: this.props.matchingPairs, + triples: this.props.matchingTriples, + explode: this.props.explodingPairs, + override: true + } + this.editor.setOption('autoCloseBrackets', bracketObject) + } + if (prevProps.enableTableEditor !== this.props.enableTableEditor) { if (this.props.enableTableEditor) { this.editor.on('cursorActivity', this.editorActivityHandler) @@ -523,20 +637,73 @@ export default class CodeEditor extends React.Component { if (prevProps.spellCheck !== this.props.spellCheck) { if (this.props.spellCheck === false) { spellcheck.setLanguage(this.editor, spellcheck.SPELLCHECK_DISABLED) - let elem = document.getElementById('editor-bottom-panel') + const elem = document.getElementById('editor-bottom-panel') elem.parentNode.removeChild(elem) } else { this.editor.addPanel(this.createSpellCheckPanel(), {position: 'bottom'}) } } + if (prevProps.deleteUnusedAttachments !== this.props.deleteUnusedAttachments) { + this.editor.setOption('deleteUnusedAttachments', this.props.deleteUnusedAttachments) + } if (needRefresh) { this.editor.refresh() } } + getCodeEditorLintConfig () { + const { mode } = this.props + const checkMarkdownNoteIsOpen = mode === 'Boost Flavored Markdown' + + return checkMarkdownNoteIsOpen ? { + getAnnotations: this.validatorOfMarkdown, + async: true + } : false + } + + validatorOfMarkdown (text, updateLinting) { + const { customMarkdownLintConfig } = this.props + let lintConfigJson + try { + Jsonlint.parse(customMarkdownLintConfig) + lintConfigJson = JSON.parse(customMarkdownLintConfig) + } catch (err) { + eventEmitter.emit('APP_SETTING_ERROR') + return + } + const lintOptions = { + strings: { + content: text + }, + config: lintConfigJson + } + + return markdownlint(lintOptions, (err, result) => { + if (!err) { + const foundIssues = [] + const splitText = text.split('\n') + result.content.map(item => { + let ruleNames = '' + item.ruleNames.map((ruleName, index) => { + ruleNames += ruleName + ruleNames += (index === item.ruleNames.length - 1) ? ': ' : '/' + }) + const lineNumber = item.lineNumber - 1 + foundIssues.push({ + from: CodeMirror.Pos(lineNumber, 0), + to: CodeMirror.Pos(lineNumber, splitText[lineNumber].length), + message: ruleNames + item.ruleDescription, + severity: 'warning' + }) + }) + updateLinting(foundIssues) + } + }) + } + setMode (mode) { - let syntax = CodeMirror.findModeByName(convertModeName(mode)) + let syntax = CodeMirror.findModeByName(convertModeName(mode || 'text')) if (syntax == null) syntax = CodeMirror.findModeByName('Plain Text') this.editor.setOption('mode', syntax.mime) @@ -546,6 +713,34 @@ export default class CodeEditor extends React.Component { handleChange (editor, changeObject) { spellcheck.handleChange(editor, changeObject) + // The current note contains an toc. We'll check for changes on headlines. + // origin is undefined when markdownTocGenerator replace the old tod + if (tocExistsInEditor(editor) && changeObject.origin !== undefined) { + let requireTocUpdate + + // Check if one of the changed lines contains a headline + for (let line = 0; line < changeObject.text.length; line++) { + if (this.linePossibleContainsHeadline(editor.getLine(changeObject.from.line + line))) { + requireTocUpdate = true + break + } + } + + if (!requireTocUpdate) { + // Check if one of the removed lines contains a headline + for (let line = 0; line < changeObject.removed.length; line++) { + if (this.linePossibleContainsHeadline(changeObject.removed[line])) { + requireTocUpdate = true + break + } + } + } + + if (requireTocUpdate) { + generateInEditor(editor) + } + } + this.updateHighlight(editor, changeObject) this.value = editor.getValue() @@ -554,15 +749,21 @@ export default class CodeEditor extends React.Component { } } + linePossibleContainsHeadline (currentLine) { + // We can't check if the line start with # because when some write text before + // the # we also need to update the toc + return currentLine.includes('# ') + } + incrementLines (start, linesAdded, linesRemoved, editor) { - let highlightedLines = editor.options.linesHighlighted + const highlightedLines = editor.options.linesHighlighted const totalHighlightedLines = highlightedLines.length - let offset = linesAdded - linesRemoved + const offset = linesAdded - linesRemoved // Store new items to be added as we're changing the lines - let newLines = [] + const newLines = [] let i = totalHighlightedLines @@ -643,6 +844,9 @@ export default class CodeEditor extends React.Component { ch: 1 } this.editor.setCursor(cursor) + const top = this.editor.charCoords({line: num, ch: 0}, 'local').top + const middleHeight = this.editor.getScrollerElement().offsetHeight / 2 + this.editor.scrollTo(null, top - middleHeight - 5) } focus () { @@ -670,9 +874,23 @@ export default class CodeEditor extends React.Component { this.editor.setCursor(cursor) } + /** + * Update content of one line + * @param {Number} lineNumber + * @param {String} content + */ + setLineContent (lineNumber, content) { + const prevContent = this.editor.getLine(lineNumber) + const prevContentLength = prevContent ? prevContent.length : 0 + this.editor.replaceRange(content, { line: lineNumber, ch: 0 }, { line: lineNumber, ch: prevContentLength }) + } + handleDropImage (dropEvent) { dropEvent.preventDefault() - const { storageKey, noteKey } = this.props + const { + storageKey, + noteKey + } = this.props attachmentManagement.handleAttachmentDrop( this, storageKey, @@ -685,25 +903,33 @@ export default class CodeEditor extends React.Component { this.editor.replaceSelection(imageMd) } + autoDetectLanguage (content) { + const res = hljs.highlightAuto(content, Object.keys(languageMaps)) + this.setMode(languageMaps[res.language]) + } + handlePaste (editor, forceSmartPaste) { const { storageKey, noteKey, fetchUrlTitle, enableSmartPaste } = this.props - const isURL = str => { - const matcher = /^(?:\w+:)?\/\/([^\s\.]+\.\S{2}|localhost[\:?\d]*)\S*$/ - return matcher.test(str) - } + const isURL = str => /(?:^\w+:|^)\/\/(?:[^\s\.]+\.\S{2}|localhost[\:?\d]*)/.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 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 } - ) + const nextChar = editor.getRange({ + line: endCursor.line, + ch: endCursor.ch + }, { + line: endCursor.line, + ch: endCursor.ch + 1 + }) return prevChar === '](' && nextChar === ')' } @@ -747,25 +973,10 @@ export default class CodeEditor extends React.Component { if (isInFencedCodeBlock(editor)) { this.handlePasteText(editor, pastedTxt) + } else if (fetchUrlTitle && isMarkdownTitleURL(pastedTxt) && !isInLinkTag(editor)) { + this.handlePasteUrl(editor, pastedTxt) } else if (fetchUrlTitle && isURL(pastedTxt) && !isInLinkTag(editor)) { this.handlePasteUrl(editor, pastedTxt) - } else if (enableSmartPaste || forceSmartPaste) { - const image = clipboard.readImage() - if (!image.isEmpty()) { - attachmentManagement.handlePastNativeImage( - this, - storageKey, - noteKey, - image - ) - } else { - const pastedHtml = clipboard.readHTML() - if (pastedHtml.length > 0) { - this.handlePasteHtml(editor, pastedHtml) - } else { - this.handlePasteText(editor, pastedTxt) - } - } } else if (attachmentManagement.isAttachmentLink(pastedTxt)) { attachmentManagement .handleAttachmentLinkPaste(storageKey, noteKey, pastedTxt) @@ -773,7 +984,28 @@ export default class CodeEditor extends React.Component { this.editor.replaceSelection(modifiedText) }) } else { - this.handlePasteText(editor, pastedTxt) + const image = clipboard.readImage() + if (!image.isEmpty()) { + attachmentManagement.handlePasteNativeImage( + this, + storageKey, + noteKey, + image + ) + } else if (enableSmartPaste || forceSmartPaste) { + const pastedHtml = clipboard.readHTML() + if (pastedHtml.length > 0) { + this.handlePasteHtml(editor, pastedHtml) + } else { + this.handlePasteText(editor, pastedTxt) + } + } else { + this.handlePasteText(editor, pastedTxt) + } + } + + if (!this.props.mode && this.props.autoDetect) { + this.autoDetectLanguage(editor.doc.getValue()) } } @@ -784,7 +1016,17 @@ export default class CodeEditor extends React.Component { } handlePasteUrl (editor, pastedTxt) { - const taggedUrl = `<${pastedTxt}>` + let taggedUrl = `<${pastedTxt}>` + let urlToFetch = pastedTxt + let titleMark = '' + + if (isMarkdownTitleURL(pastedTxt)) { + const pastedTxtSplitted = pastedTxt.split(' ') + titleMark = `${pastedTxtSplitted[0]} ` + urlToFetch = pastedTxtSplitted[1] + taggedUrl = `<${urlToFetch}>` + } + editor.replaceSelection(taggedUrl) const isImageReponse = response => { @@ -796,22 +1038,23 @@ export default class CodeEditor extends React.Component { const replaceTaggedUrl = replacement => { const value = editor.getValue() const cursor = editor.getCursor() - const newValue = value.replace(taggedUrl, replacement) + const newValue = value.replace(taggedUrl, titleMark + replacement) const newCursor = Object.assign({}, cursor, { - ch: cursor.ch + newValue.length - value.length + ch: cursor.ch + newValue.length - (value.length - titleMark.length) }) + editor.setValue(newValue) editor.setCursor(newCursor) } - fetch(pastedTxt, { + fetch(urlToFetch, { method: 'get' }) .then(response => { if (isImageReponse(response)) { - return this.mapImageResponse(response, pastedTxt) + return this.mapImageResponse(response, urlToFetch) } else { - return this.mapNormalResponse(response, pastedTxt) + return this.mapNormalResponse(response, urlToFetch) } }) .then(replacement => { @@ -899,7 +1142,7 @@ export default class CodeEditor extends React.Component { iconv.encodingExists(_charset) ? _charset : 'utf-8' - resolve(iconv.decode(new Buffer(buff), charset).toString()) + resolve(iconv.decode(Buffer.from(buff), charset).toString()) } catch (e) { reject(e) } @@ -919,20 +1162,26 @@ export default class CodeEditor extends React.Component { } render () { - const {className, fontSize} = this.props + const { + className, + fontSize + } = this.props const fontFamily = normalizeEditorFontFamily(this.props.fontFamily) const width = this.props.width - return ( -
this.handleDropImage(e)} + return (< + div className={ + className == null ? 'CodeEditor' : `CodeEditor ${className}` + } + ref='root' + tabIndex='-1' + style={{ + fontFamily, + fontSize: fontSize, + width: width + }} + onDrop={ + e => this.handleDropImage(e) + } /> ) } @@ -966,7 +1215,11 @@ CodeEditor.propTypes = { onBlur: PropTypes.func, onChange: PropTypes.func, readOnly: PropTypes.bool, - spellCheck: PropTypes.bool + autoDetect: PropTypes.bool, + spellCheck: PropTypes.bool, + enableMarkdownLint: PropTypes.bool, + customMarkdownLintConfig: PropTypes.string, + deleteUnusedAttachments: PropTypes.bool } CodeEditor.defaultProps = { @@ -977,5 +1230,10 @@ CodeEditor.defaultProps = { fontFamily: 'Monaco, Consolas', indentSize: 4, indentType: 'space', - spellCheck: false + autoDetect: false, + spellCheck: false, + enableMarkdownLint: DEFAULT_CONFIG.editor.enableMarkdownLint, + customMarkdownLintConfig: DEFAULT_CONFIG.editor.customMarkdownLintConfig, + prettierConfig: DEFAULT_CONFIG.editor.prettierConfig, + deleteUnusedAttachments: DEFAULT_CONFIG.editor.deleteUnusedAttachments } diff --git a/browser/components/CodeEditor.styl b/browser/components/CodeEditor.styl index 7a254935..1aa0e466 100644 --- a/browser/components/CodeEditor.styl +++ b/browser/components/CodeEditor.styl @@ -3,4 +3,3 @@ .spellcheck-select border: none - text-decoration underline wavy red diff --git a/browser/components/ColorPicker.js b/browser/components/ColorPicker.js new file mode 100644 index 00000000..9e0199c2 --- /dev/null +++ b/browser/components/ColorPicker.js @@ -0,0 +1,68 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { SketchPicker } from 'react-color' +import CSSModules from 'browser/lib/CSSModules' +import styles from './ColorPicker.styl' + +const componentHeight = 330 + +class ColorPicker extends React.Component { + constructor (props) { + super(props) + + this.state = { + color: this.props.color || '#939395' + } + + this.onColorChange = this.onColorChange.bind(this) + this.handleConfirm = this.handleConfirm.bind(this) + } + + componentWillReceiveProps (nextProps) { + this.onColorChange(nextProps.color) + } + + onColorChange (color) { + this.setState({ + color + }) + } + + handleConfirm () { + this.props.onConfirm(this.state.color) + } + + render () { + const { onReset, onCancel, targetRect } = this.props + const { color } = this.state + + const clientHeight = document.body.clientHeight + const alignX = targetRect.right + 4 + let alignY = targetRect.top + if (targetRect.top + componentHeight > clientHeight) { + alignY = targetRect.bottom - componentHeight + } + + return ( +
+
+ +
+ + + +
+
+ ) + } +} + +ColorPicker.propTypes = { + color: PropTypes.string, + targetRect: PropTypes.object, + onConfirm: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + onReset: PropTypes.func.isRequired +} + +export default CSSModules(ColorPicker, styles) diff --git a/browser/components/ColorPicker.styl b/browser/components/ColorPicker.styl new file mode 100644 index 00000000..fbc1212a --- /dev/null +++ b/browser/components/ColorPicker.styl @@ -0,0 +1,39 @@ +.colorPicker + position fixed + z-index 2 + display flex + flex-direction column + +.cover + position fixed + top 0 + right 0 + bottom 0 + left 0 + +.footer + display flex + justify-content center + z-index 2 + align-items center + & > button + button + margin-left 10px + +.btn-cancel, +.btn-confirm, +.btn-reset + vertical-align middle + height 25px + margin-top 2.5px + border-radius 2px + border none + padding 0 5px + background-color $default-button-background + &:hover + background-color $default-button-background--hover +.btn-confirm + background-color #1EC38B + &:hover + background-color darken(#1EC38B, 25%) + + diff --git a/browser/components/MarkdownEditor.js b/browser/components/MarkdownEditor.js index db0c4374..5c2ddbdb 100644 --- a/browser/components/MarkdownEditor.js +++ b/browser/components/MarkdownEditor.js @@ -20,10 +20,10 @@ class MarkdownEditor extends React.Component { this.supportMdSelectionBold = [16, 17, 186] this.state = { - status: props.config.editor.switchPreview === 'RIGHTCLICK' ? props.config.editor.delfaultStatus : 'PREVIEW', + status: props.config.editor.switchPreview === 'RIGHTCLICK' ? props.config.editor.delfaultStatus : 'CODE', renderValue: props.value, keyPressed: new Set(), - isLocked: false + isLocked: props.isLocked } this.lockEditorCode = () => this.handleLockEditor() @@ -32,6 +32,7 @@ class MarkdownEditor extends React.Component { componentDidMount () { this.value = this.refs.code.value eventEmitter.on('editor:lock', this.lockEditorCode) + eventEmitter.on('editor:focus', this.focusEditor.bind(this)) } componentDidUpdate () { @@ -47,6 +48,15 @@ class MarkdownEditor extends React.Component { componentWillUnmount () { this.cancelQueue() eventEmitter.off('editor:lock', this.lockEditorCode) + eventEmitter.off('editor:focus', this.focusEditor.bind(this)) + } + + focusEditor () { + this.setState({ + status: 'CODE' + }, () => { + this.refs.code.focus() + }) } queueRendering (value) { @@ -76,6 +86,7 @@ class MarkdownEditor extends React.Component { } handleContextMenu (e) { + if (this.state.isLocked) return const { config } = this.props if (config.editor.switchPreview === 'RIGHTCLICK') { const newStatus = this.state.status === 'PREVIEW' ? 'CODE' : 'PREVIEW' @@ -108,7 +119,7 @@ class MarkdownEditor extends React.Component { status: 'PREVIEW' }, () => { this.refs.preview.focus() - this.refs.preview.scrollTo(cursorPosition.line) + this.refs.preview.scrollToRow(cursorPosition.line) }) eventEmitter.emit('topbar:togglelockbutton', this.state.status) } @@ -148,24 +159,25 @@ class MarkdownEditor extends React.Component { e.preventDefault() e.stopPropagation() const idMatch = /checkbox-([0-9]+)/ - const checkedMatch = /^\s*[\+\-\*] \[x\]/i - const uncheckedMatch = /^\s*[\+\-\*] \[ \]/ - const checkReplace = /\[x\]/i - const uncheckReplace = /\[ \]/ + const checkedMatch = /^(\s*>?)*\s*[+\-*] \[x]/i + const uncheckedMatch = /^(\s*>?)*\s*[+\-*] \[ ]/ + const checkReplace = /\[x]/i + const uncheckReplace = /\[ ]/ if (idMatch.test(e.target.getAttribute('id'))) { const lineIndex = parseInt(e.target.getAttribute('id').match(idMatch)[1], 10) - 1 const lines = this.refs.code.value .split('\n') const targetLine = lines[lineIndex] + let newLine = targetLine if (targetLine.match(checkedMatch)) { - lines[lineIndex] = targetLine.replace(checkReplace, '[ ]') + newLine = targetLine.replace(checkReplace, '[ ]') } if (targetLine.match(uncheckedMatch)) { - lines[lineIndex] = targetLine.replace(uncheckReplace, '[x]') + newLine = targetLine.replace(uncheckReplace, '[x]') } - this.refs.code.setValue(lines.join('\n')) + this.refs.code.setLineContent(lineIndex, newLine) } } @@ -293,6 +305,10 @@ class MarkdownEditor extends React.Component { enableRulers={config.editor.enableRulers} rulers={config.editor.rulers} displayLineNumbers={config.editor.displayLineNumbers} + lineWrapping + matchingPairs={config.editor.matchingPairs} + matchingTriples={config.editor.matchingTriples} + explodingPairs={config.editor.explodingPairs} scrollPastEnd={config.editor.scrollPastEnd} storageKey={storageKey} noteKey={noteKey} @@ -305,6 +321,10 @@ class MarkdownEditor extends React.Component { enableSmartPaste={config.editor.enableSmartPaste} hotkey={config.hotkey} switchPreview={config.editor.switchPreview} + enableMarkdownLint={config.editor.enableMarkdownLint} + customMarkdownLintConfig={config.editor.customMarkdownLintConfig} + prettierConfig={config.editor.prettierConfig} + deleteUnusedAttachments={config.editor.deleteUnusedAttachments} /> this.handleContextMenu(e)} onDoubleClick={(e) => this.handleDoubleClick(e)} diff --git a/browser/components/MarkdownPreview.js b/browser/components/MarkdownPreview.js index 43fb5f93..960d83a6 100755 --- a/browser/components/MarkdownPreview.js +++ b/browser/components/MarkdownPreview.js @@ -8,7 +8,7 @@ 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 SequenceDiagram from '@rokt33r/js-sequence-diagrams' import Chart from 'chart.js' import eventEmitter from 'browser/main/lib/eventEmitter' import htmlTextHelper from 'browser/lib/htmlTextHelper' @@ -18,15 +18,13 @@ import mdurl from 'mdurl' import exportNote from 'browser/main/lib/dataApi/exportNote' import { escapeHtmlCharacters } from 'browser/lib/utils' import yaml from 'js-yaml' -import context from 'browser/lib/context' -import i18n from 'browser/lib/i18n' -import fs from 'fs' import { render } from 'react-dom' import Carousel from 'react-image-carousel' import ConfigManager from '../main/lib/ConfigManager' const { remote, shell } = require('electron') const attachmentManagement = require('../main/lib/dataApi/attachmentManagement') +const buildMarkdownPreviewContextMenu = require('browser/lib/contextMenuBuilder').buildMarkdownPreviewContextMenu const { app } = remote const path = require('path') @@ -34,8 +32,6 @@ const fileUrl = require('file-url') const dialog = remote.dialog -const uri2path = require('file-uri-to-path') - const markdownStyle = require('!!css!stylus?sourceMap!./markdown.styl')[0][1] const appPath = fileUrl( process.env.NODE_ENV === 'production' ? app.getAppPath() : path.resolve() @@ -46,16 +42,29 @@ const CSS_FILES = [ `${appPath}/node_modules/react-image-carousel/lib/css/main.min.css` ] -function buildStyle ( - fontFamily, - fontSize, - codeBlockFontFamily, - lineNumber, - scrollPastEnd, - theme, - allowCustomCSS, - customCSS -) { +/** + * @param {Object} opts + * @param {String} opts.fontFamily + * @param {Numberl} opts.fontSize + * @param {String} opts.codeBlockFontFamily + * @param {String} opts.theme + * @param {Boolean} [opts.lineNumber] Should show line number + * @param {Boolean} [opts.scrollPastEnd] + * @param {Boolean} [opts.allowCustomCSS] Should add custom css + * @param {String} [opts.customCSS] Will be added to bottom, only if `opts.allowCustomCSS` is truthy + * @returns {String} + */ +function buildStyle (opts) { + const { + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS + } = opts return ` @font-face { font-family: 'Lato'; @@ -85,12 +94,17 @@ function buildStyle ( url('${appPath}/resources/fonts/MaterialIcons-Regular.woff') format('woff'), url('${appPath}/resources/fonts/MaterialIcons-Regular.ttf') format('truetype'); } + ${markdownStyle} body { font-family: '${fontFamily.join("','")}'; font-size: ${fontSize}px; - ${scrollPastEnd && 'padding-bottom: 90vh;'} + ${scrollPastEnd ? ` + padding-bottom: 90vh; + box-sizing: border-box; + ` + : ''} } @media print { body { @@ -166,6 +180,10 @@ const scrollBarStyle = ` ::-webkit-scrollbar-thumb { background-color: rgba(0, 0, 0, 0.15); } + +::-webkit-scrollbar-track-piece { + background-color: inherit; +} ` const scrollBarDarkStyle = ` ::-webkit-scrollbar { @@ -175,6 +193,10 @@ const scrollBarDarkStyle = ` ::-webkit-scrollbar-thumb { background-color: rgba(0, 0, 0, 0.3); } + +::-webkit-scrollbar-track-piece { + background-color: inherit; +} ` const OSX = global.process.platform === 'darwin' @@ -192,6 +214,19 @@ const defaultCodeBlockFontFamily = [ 'source-code-pro', 'monospace' ] + +// return the line number of the line that used to generate the specified element +// return -1 if the line is not found +function getSourceLineNumberByElement (element) { + let isHasLineNumber = element.dataset.line !== undefined + let parent = element + while (!isHasLineNumber && parent.parentElement !== null) { + parent = parent.parentElement + isHasLineNumber = parent.dataset.line !== undefined + } + return parent.dataset.line !== undefined ? parseInt(parent.dataset.line) : -1 +} + export default class MarkdownPreview extends React.Component { constructor (props) { super(props) @@ -208,6 +243,7 @@ export default class MarkdownPreview extends React.Component { this.saveAsTextHandler = () => this.handleSaveAsText() this.saveAsMdHandler = () => this.handleSaveAsMd() this.saveAsHtmlHandler = () => this.handleSaveAsHtml() + this.saveAsPdfHandler = () => this.handleSaveAsPdf() this.printHandler = () => this.handlePrint() this.resizeHandler = _.throttle(this.handleResize.bind(this), 100) @@ -236,30 +272,12 @@ export default class MarkdownPreview extends React.Component { } handleContextMenu (event) { - // If a contextMenu handler was passed to us, use it instead of the self-defined one -> return - if (_.isFunction(this.props.onContextMenu)) { + const menu = buildMarkdownPreviewContextMenu(this, event) + const switchPreview = ConfigManager.get().editor.switchPreview + if (menu != null && switchPreview !== 'RIGHTCLICK') { + menu.popup(remote.getCurrentWindow()) + } else if (_.isFunction(this.props.onContextMenu)) { this.props.onContextMenu(event) - return - } - // No contextMenu was passed to us -> execute our own link-opener - if (event.target.tagName.toLowerCase() === 'a') { - const href = event.target.href - const isLocalFile = href.startsWith('file:') - if (isLocalFile) { - const absPath = uri2path(href) - try { - if (fs.lstatSync(absPath).isFile()) { - context.popup([ - { - label: i18n.__('Show in explorer'), - click: (e) => shell.showItemInFolder(absPath) - } - ]) - } - } catch (e) { - console.log('Error while evaluating if the file is locally available', e) - } - } } } @@ -269,17 +287,27 @@ export default class MarkdownPreview extends React.Component { handleMouseDown (e) { const config = ConfigManager.get() + const clickElement = e.target + const targetTag = clickElement.tagName // The direct parent HTML of where was clicked ie "BODY" or "DIV" + const lineNumber = getSourceLineNumberByElement(clickElement) // Line location of element clicked. + if (config.editor.switchPreview === 'RIGHTCLICK' && e.buttons === 2 && config.editor.type === 'SPLIT') { eventEmitter.emit('topbar:togglemodebutton', 'CODE') } - if (e.target != null) { - switch (e.target.tagName) { - case 'A': - case 'INPUT': - return null + if (e.ctrlKey) { + if (config.editor.type === 'SPLIT') { + if (lineNumber !== -1) { + eventEmitter.emit('line:jump', lineNumber) + } + } else { + if (lineNumber !== -1) { + eventEmitter.emit('editor:focus') + eventEmitter.emit('line:jump', lineNumber) + } } } - if (this.props.onMouseDown != null) this.props.onMouseDown(e) + + if (this.props.onMouseDown != null && targetTag === 'BODY') this.props.onMouseDown(e) } handleMouseUp (e) { @@ -298,58 +326,81 @@ export default class MarkdownPreview extends React.Component { this.exportAsDocument('md') } - handleSaveAsHtml () { - this.exportAsDocument('html', (noteContent, exportTasks) => { - const { - fontFamily, - fontSize, - codeBlockFontFamily, - lineNumber, - codeBlockTheme, - scrollPastEnd, - theme, - allowCustomCSS, - customCSS - } = this.getStyleParams() + htmlContentFormatter (noteContent, exportTasks, targetDir) { + 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(noteContent) - const files = [this.GetCodeThemeLink(codeBlockTheme), ...CSS_FILES] - files.forEach(file => { - if (global.process.platform === 'win32') { - file = file.replace('file:///', '') - } else { - file = file.replace('file://', '') - } - exportTasks.push({ - src: file, - dst: 'css' + const inlineStyles = buildStyle({ + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS + }) + let body = this.markdown.render(noteContent) + body = attachmentManagement.fixLocalURLS( + body, + this.props.storagePath + ) + const files = [this.getCodeThemeLink(codeBlockTheme), ...CSS_FILES] + files.forEach(file => { + if (global.process.platform === 'win32') { + file = file.replace('file:///', '') + } else { + file = file.replace('file://', '') + } + exportTasks.push({ + src: file, + dst: 'css' + }) + }) + + let styles = '' + files.forEach(file => { + styles += `` + }) + + return ` + + + + + + ${styles} + + ${body} + ` + } + + handleSaveAsHtml () { + this.exportAsDocument('html', (noteContent, exportTasks, targetDir) => Promise.resolve(this.htmlContentFormatter(noteContent, exportTasks, targetDir))) + } + + handleSaveAsPdf () { + this.exportAsDocument('pdf', (noteContent, exportTasks, targetDir) => { + const printout = new remote.BrowserWindow({show: false, webPreferences: {webSecurity: false, javascript: false}}) + printout.loadURL('data:text/html;charset=UTF-8,' + this.htmlContentFormatter(noteContent, exportTasks, targetDir)) + return new Promise((resolve, reject) => { + printout.webContents.on('did-finish-load', () => { + printout.webContents.printToPDF({}, (err, data) => { + if (err) reject(err) + else resolve(data) + printout.destroy() + }) }) }) - - let styles = '' - files.forEach(file => { - styles += `` - }) - - return ` - - - - - ${styles} - - ${body} - ` }) } @@ -399,6 +450,31 @@ export default class MarkdownPreview extends React.Component { } } + /** + * @description Convert special characters between three ``` + * @param {string[]} splitWithCodeTag Array of HTML strings separated by three ``` + * @returns {string} HTML in which special characters between three ``` have been converted + */ + escapeHtmlCharactersInCodeTag (splitWithCodeTag) { + for (let index = 0; index < splitWithCodeTag.length; index++) { + const codeTagRequired = (splitWithCodeTag[index] !== '\`\`\`' && index < splitWithCodeTag.length - 1) + if (codeTagRequired) { + splitWithCodeTag.splice((index + 1), 0, '\`\`\`') + } + } + let inCodeTag = false + let result = '' + for (let content of splitWithCodeTag) { + if (content === '\`\`\`') { + inCodeTag = !inCodeTag + } else if (inCodeTag) { + content = escapeHtmlCharacters(content) + } + result += content + } + return result + } + getScrollBarStyle () { const { theme } = this.props @@ -470,6 +546,7 @@ export default class MarkdownPreview extends React.Component { eventEmitter.on('export:save-text', this.saveAsTextHandler) eventEmitter.on('export:save-md', this.saveAsMdHandler) eventEmitter.on('export:save-html', this.saveAsHtmlHandler) + eventEmitter.on('export:save-pdf', this.saveAsPdfHandler) eventEmitter.on('print', this.printHandler) } @@ -511,20 +588,24 @@ export default class MarkdownPreview extends React.Component { eventEmitter.off('export:save-text', this.saveAsTextHandler) eventEmitter.off('export:save-md', this.saveAsMdHandler) eventEmitter.off('export:save-html', this.saveAsHtmlHandler) + eventEmitter.off('export:save-pdf', this.saveAsPdfHandler) eventEmitter.off('print', this.printHandler) } componentDidUpdate (prevProps) { - if (prevProps.value !== this.props.value) this.rewriteIframe() + // actual rewriteIframe function should be called only once + let needsRewriteIframe = false + if (prevProps.value !== this.props.value) needsRewriteIframe = true if ( prevProps.smartQuotes !== this.props.smartQuotes || prevProps.sanitize !== this.props.sanitize || + prevProps.mermaidHTMLLabel !== this.props.mermaidHTMLLabel || prevProps.smartArrows !== this.props.smartArrows || prevProps.breaks !== this.props.breaks || prevProps.lineThroughCheckbox !== this.props.lineThroughCheckbox ) { this.initMarkdown() - this.rewriteIframe() + needsRewriteIframe = true } if ( prevProps.fontFamily !== this.props.fontFamily || @@ -539,8 +620,17 @@ export default class MarkdownPreview extends React.Component { prevProps.customCSS !== this.props.customCSS ) { this.applyStyle() + needsRewriteIframe = true + } + + if (needsRewriteIframe) { this.rewriteIframe() } + + // Should scroll to top after selecting another note + if (prevProps.noteKey !== this.props.noteKey) { + this.scrollTo(0, 0) + } } getStyleParams () { @@ -596,8 +686,8 @@ export default class MarkdownPreview extends React.Component { this.getWindow().document.getElementById( 'codeTheme' - ).href = this.GetCodeThemeLink(codeBlockTheme) - this.getWindow().document.getElementById('style').innerHTML = buildStyle( + ).href = this.getCodeThemeLink(codeBlockTheme) + this.getWindow().document.getElementById('style').innerHTML = buildStyle({ fontFamily, fontSize, codeBlockFontFamily, @@ -606,17 +696,15 @@ export default class MarkdownPreview extends React.Component { theme, allowCustomCSS, customCSS - ) + }) } - GetCodeThemeLink (theme) { - theme = consts.THEMES.some(_theme => _theme === theme) && - theme !== 'default' - ? theme - : 'elegant' - return theme.startsWith('solarized') - ? `${appPath}/node_modules/codemirror/theme/solarized.css` - : `${appPath}/node_modules/codemirror/theme/${theme}.css` + getCodeThemeLink (name) { + const theme = consts.THEMES.find(theme => theme.name === name) + + return theme != null + ? theme.path + : `${appPath}/node_modules/codemirror/theme/elegant.css` } rewriteIframe () { @@ -641,11 +729,17 @@ export default class MarkdownPreview extends React.Component { indentSize, showCopyNotification, storagePath, - noteKey + noteKey, + sanitize, + mermaidHTMLLabel } = this.props let { value, codeBlockTheme } = this.props this.refs.root.contentWindow.document.body.setAttribute('data-theme', theme) + if (sanitize === 'NONE') { + const splitWithCodeTag = value.split('```') + value = this.escapeHtmlCharactersInCodeTag(splitWithCodeTag) + } const renderedHTML = this.markdown.render(value) attachmentManagement.migrateAttachments(value, storagePath, noteKey) this.refs.root.contentWindow.document.body.innerHTML = attachmentManagement.fixLocalURLS( @@ -669,9 +763,9 @@ export default class MarkdownPreview extends React.Component { } ) - codeBlockTheme = consts.THEMES.some(_theme => _theme === codeBlockTheme) - ? codeBlockTheme - : 'default' + codeBlockTheme = consts.THEMES.find(theme => theme.name === codeBlockTheme) + + const codeBlockThemeClassName = codeBlockTheme ? codeBlockTheme.className : 'cm-s-default' _.forEach( this.refs.root.contentWindow.document.querySelectorAll('.code code'), @@ -684,6 +778,8 @@ export default class MarkdownPreview extends React.Component { copyIcon.innerHTML = '' copyIcon.onclick = e => { + e.preventDefault() + e.stopPropagation() copy(content) if (showCopyNotification) { this.notify('Saved to Clipboard!', { @@ -692,14 +788,11 @@ export default class MarkdownPreview extends React.Component { }) } } + 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}` - } + el.parentNode.className += ` ${codeBlockThemeClassName}` + CodeMirror.runMode(content, syntax.mime, el, { tabSize: indentSize }) @@ -770,6 +863,7 @@ export default class MarkdownPreview extends React.Component { canvas.height = height.value + 'vh' } + // eslint-disable-next-line no-unused-vars const chart = new Chart(canvas, chartConfig) } catch (e) { el.className = 'chart-error' @@ -780,7 +874,7 @@ export default class MarkdownPreview extends React.Component { _.forEach( this.refs.root.contentWindow.document.querySelectorAll('.mermaid'), el => { - mermaidRender(el, htmlTextHelper.decodeEntities(el.innerHTML), theme) + mermaidRender(el, htmlTextHelper.decodeEntities(el.innerHTML), theme, mermaidHTMLLabel) } ) @@ -811,6 +905,103 @@ export default class MarkdownPreview extends React.Component { ) } ) + + const markdownPreviewIframe = document.querySelector('.MarkdownPreview') + const rect = markdownPreviewIframe.getBoundingClientRect() + const config = { attributes: true, subtree: true } + const imgObserver = new MutationObserver((mutationList) => { + for (const mu of mutationList) { + if (mu.target.className === 'carouselContent-enter-done') { + this.setImgOnClickEventHelper(mu.target, rect) + break + } + } + }) + + const imgList = markdownPreviewIframe.contentWindow.document.body.querySelectorAll('img') + for (const img of imgList) { + const parentEl = img.parentElement + this.setImgOnClickEventHelper(img, rect) + imgObserver.observe(parentEl, config) + } + + const aList = markdownPreviewIframe.contentWindow.document.body.querySelectorAll('a') + for (const a of aList) { + a.removeEventListener('click', this.linkClickHandler) + a.addEventListener('click', this.linkClickHandler) + } + } + + setImgOnClickEventHelper (img, rect) { + img.onclick = () => { + const widthMagnification = document.body.clientWidth / img.width + const heightMagnification = document.body.clientHeight / img.height + const baseOnWidth = widthMagnification < heightMagnification + const magnification = baseOnWidth ? widthMagnification : heightMagnification + + const zoomImgWidth = img.width * magnification + const zoomImgHeight = img.height * magnification + const zoomImgTop = (document.body.clientHeight - zoomImgHeight) / 2 + const zoomImgLeft = (document.body.clientWidth - zoomImgWidth) / 2 + const originalImgTop = img.y + rect.top + const originalImgLeft = img.x + rect.left + const originalImgRect = { + top: `${originalImgTop}px`, + left: `${originalImgLeft}px`, + width: `${img.width}px`, + height: `${img.height}px` + } + const zoomInImgRect = { + top: `${baseOnWidth ? zoomImgTop : 0}px`, + left: `${baseOnWidth ? 0 : zoomImgLeft}px`, + width: `${zoomImgWidth}px`, + height: `${zoomImgHeight}px` + } + const animationSpeed = 300 + + const zoomImg = document.createElement('img') + zoomImg.src = img.src + zoomImg.style = ` + position: absolute; + top: ${baseOnWidth ? zoomImgTop : 0}px; + left: ${baseOnWidth ? 0 : zoomImgLeft}px; + width: ${zoomImgWidth}; + height: ${zoomImgHeight}px; + ` + zoomImg.animate([ + originalImgRect, + zoomInImgRect + ], animationSpeed) + + const overlay = document.createElement('div') + overlay.style = ` + background-color: rgba(0,0,0,0.5); + cursor: zoom-out; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: ${document.body.clientHeight}px; + z-index: 100; + ` + overlay.onclick = () => { + zoomImg.style = ` + position: absolute; + top: ${originalImgTop}px; + left: ${originalImgLeft}px; + width: ${img.width}px; + height: ${img.height}px; + ` + const zoomOutImgAnimation = zoomImg.animate([ + zoomInImgRect, + originalImgRect + ], animationSpeed) + zoomOutImgAnimation.onfinish = () => overlay.remove() + } + + overlay.appendChild(zoomImg) + document.body.appendChild(overlay) + } } handleResize () { @@ -830,7 +1021,11 @@ export default class MarkdownPreview extends React.Component { return this.refs.root.contentWindow } - scrollTo (targetRow) { + /** + * @public + * @param {Number} targetRow + */ + scrollToRow (targetRow) { const blocks = this.getWindow().document.querySelectorAll( 'body>[data-line]' ) @@ -840,12 +1035,21 @@ export default class MarkdownPreview extends React.Component { const row = parseInt(block.getAttribute('data-line')) if (row > targetRow || index === blocks.length - 1) { block = blocks[index - 1] - block != null && this.getWindow().scrollTo(0, block.offsetTop) + block != null && this.scrollTo(0, block.offsetTop) break } } } + /** + * `document.body.scrollTo` + * @param {Number} x + * @param {Number} y + */ + scrollTo (x, y) { + this.getWindow().document.body.scrollTo(x, y) + } + preventImageDroppedHandler (e) { e.preventDefault() e.stopPropagation() @@ -866,20 +1070,32 @@ export default class MarkdownPreview extends React.Component { e.preventDefault() e.stopPropagation() - const href = e.target.href - const linkHash = href.split('/').pop() + const rawHref = e.target.getAttribute('href') + if (!rawHref) return // not checked href because parser will create file://... string for [empty link]() - 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 - ) + const parser = document.createElement('a') + parser.href = rawHref + const isStartWithHash = rawHref[0] === '#' + const { href, hash } = parser - if (targetElement != null) { - this.getWindow().scrollTo(0, targetElement.offsetTop) + const linkHash = hash === '' ? rawHref : hash // needed because we're having special link formats that are removed by parser e.g. :line:10 + + const extractIdRegex = /file:\/\/.*main.?\w*.html#/ // file://path/to/main(.development.)html + const regexNoteInternalLink = new RegExp(`${extractIdRegex.source}(.+)`) + if (isStartWithHash || regexNoteInternalLink.test(rawHref)) { + const posOfHash = linkHash.indexOf('#') + if (posOfHash > -1) { + const extractedId = linkHash.slice(posOfHash + 1) + const targetId = mdurl.encode(extractedId) + const targetElement = this.getWindow().document.getElementById( + targetId + ) + + if (targetElement != null) { + this.scrollTo(0, targetElement.offsetTop) + } + return } - return } // this will match the new uuid v4 hash and the old hash diff --git a/browser/components/MarkdownSplitEditor.js b/browser/components/MarkdownSplitEditor.js index f8f8b366..f5996c59 100644 --- a/browser/components/MarkdownSplitEditor.js +++ b/browser/components/MarkdownSplitEditor.js @@ -78,24 +78,25 @@ class MarkdownSplitEditor extends React.Component { e.preventDefault() e.stopPropagation() const idMatch = /checkbox-([0-9]+)/ - const checkedMatch = /^\s*[\+\-\*] \[x\]/i - const uncheckedMatch = /^\s*[\+\-\*] \[ \]/ - const checkReplace = /\[x\]/i - const uncheckReplace = /\[ \]/ + const checkedMatch = /^(\s*>?)*\s*[+\-*] \[x]/i + const uncheckedMatch = /^(\s*>?)*\s*[+\-*] \[ ]/ + const checkReplace = /\[x]/i + const uncheckReplace = /\[ ]/ if (idMatch.test(e.target.getAttribute('id'))) { const lineIndex = parseInt(e.target.getAttribute('id').match(idMatch)[1], 10) - 1 const lines = this.refs.code.value .split('\n') const targetLine = lines[lineIndex] + let newLine = targetLine if (targetLine.match(checkedMatch)) { - lines[lineIndex] = targetLine.replace(checkReplace, '[ ]') + newLine = targetLine.replace(checkReplace, '[ ]') } if (targetLine.match(uncheckedMatch)) { - lines[lineIndex] = targetLine.replace(uncheckReplace, '[x]') + newLine = targetLine.replace(uncheckReplace, '[x]') } - this.refs.code.setValue(lines.join('\n')) + this.refs.code.setLineContent(lineIndex, newLine) } } @@ -150,7 +151,6 @@ class MarkdownSplitEditor extends React.Component { onMouseMove={e => this.handleMouseMove(e)} onMouseUp={e => this.handleMouseUp(e)}>
this.handleMouseDown(e)} >
( ) diff --git a/browser/components/NavToggleButton.js b/browser/components/NavToggleButton.js index ad0ff54c..7dc75e90 100644 --- a/browser/components/NavToggleButton.js +++ b/browser/components/NavToggleButton.js @@ -16,8 +16,8 @@ const NavToggleButton = ({isFolded, handleToggleButtonClick}) => ( onClick={(e) => handleToggleButtonClick(e)} > {isFolded - ? - : + ? + : } ) diff --git a/browser/components/NavToggleButton.styl b/browser/components/NavToggleButton.styl index ae9dd6ca..422a7ca6 100644 --- a/browser/components/NavToggleButton.styl +++ b/browser/components/NavToggleButton.styl @@ -7,7 +7,7 @@ border-radius 16.5px height 34px width 34px - line-height 32px + line-height 100% padding 0 &:hover border: 1px solid #1EC38B; diff --git a/browser/components/NoteItem.js b/browser/components/NoteItem.js index 2fc70a39..168af1ff 100644 --- a/browser/components/NoteItem.js +++ b/browser/components/NoteItem.js @@ -3,7 +3,8 @@ */ import PropTypes from 'prop-types' import React from 'react' -import { isArray } from 'lodash' +import { isArray, sortBy } from 'lodash' +import invertColor from 'invert-color' import CSSModules from 'browser/lib/CSSModules' import { getTodoStatus } from 'browser/lib/getTodoStatus' import styles from './NoteItem.styl' @@ -13,29 +14,38 @@ import i18n from 'browser/lib/i18n' /** * @description Tag element component. * @param {string} tagName + * @param {string} color * @return {React.Component} */ -const TagElement = ({ tagName }) => ( - - #{tagName} - -) +const TagElement = ({ tagName, color }) => { + const style = {} + if (color) { + style.backgroundColor = color + style.color = invertColor(color, { black: '#222', white: '#f1f1f1', threshold: 0.3 }) + } + return ( + + #{tagName} + + ) +} /** * @description Tag element list component. * @param {Array|null} tags * @param {boolean} showTagsAlphabetically + * @param {Object} coloredTags * @return {React.Component} */ -const TagElementList = (tags, showTagsAlphabetically) => { +const TagElementList = (tags, showTagsAlphabetically, coloredTags) => { if (!isArray(tags)) { return [] } if (showTagsAlphabetically) { - return _.sortBy(tags).map(tag => TagElement({ tagName: tag })) + return sortBy(tags).map(tag => TagElement({ tagName: tag, color: coloredTags[tag] })) } else { - return tags.map(tag => TagElement({ tagName: tag })) + return tags.map(tag => TagElement({ tagName: tag, color: coloredTags[tag] })) } } @@ -46,6 +56,7 @@ const TagElementList = (tags, showTagsAlphabetically) => { * @param {Function} handleNoteClick * @param {Function} handleNoteContextMenu * @param {Function} handleDragStart + * @param {Object} coloredTags * @param {string} dateDisplay */ const NoteItem = ({ @@ -59,7 +70,8 @@ const NoteItem = ({ storageName, folderName, viewType, - showTagsAlphabetically + showTagsAlphabetically, + coloredTags }) => (
{note.tags.length > 0 - ? TagElementList(note.tags, showTagsAlphabetically) + ? TagElementList(note.tags, showTagsAlphabetically, coloredTags) : {snippet.name.trim().length > 0 ? snippet.name - : + : {i18n.__('Unnamed')} } diff --git a/browser/components/TagListItem.js b/browser/components/TagListItem.js index eec8ab14..9aa00a1d 100644 --- a/browser/components/TagListItem.js +++ b/browser/components/TagListItem.js @@ -10,11 +10,12 @@ import CSSModules from 'browser/lib/CSSModules' * @param {string} name * @param {Function} handleClickTagListItem * @param {Function} handleClickNarrowToTag -* @param {bool} isActive -* @param {bool} isRelated +* @param {boolean} isActive +* @param {boolean} isRelated +* @param {string} bgColor tab backgroundColor */ -const TagListItem = ({name, handleClickTagListItem, handleClickNarrowToTag, handleContextMenu, isActive, isRelated, count}) => ( +const TagListItem = ({name, handleClickTagListItem, handleClickNarrowToTag, handleContextMenu, isActive, isRelated, count, color}) => (
handleContextMenu(e, name)}> {isRelated ? + ) + } +} + +FromUrlButton.propTypes = { + isActive: PropTypes.bool, + onClick: PropTypes.func, + className: PropTypes.string +} + +export default CSSModules(FromUrlButton, styles) diff --git a/browser/main/Detail/FromUrlButton.styl b/browser/main/Detail/FromUrlButton.styl new file mode 100644 index 00000000..66c2d730 --- /dev/null +++ b/browser/main/Detail/FromUrlButton.styl @@ -0,0 +1,41 @@ +.root + top 45px + topBarButtonRight() + &:hover + transition 0.2s + color alpha($ui-favorite-star-button-color, 0.6) + &:hover .tooltip + opacity 1 + +.tooltip + tooltip() + position absolute + pointer-events none + top 50px + right 125px + width 90px + z-index 200 + padding 5px + line-height normal + border-radius 2px + opacity 0 + transition 0.1s + +.root--active + @extend .root + transition 0.15s + color $ui-favorite-star-button-color + &:hover + transition 0.2s + color alpha($ui-favorite-star-button-color, 0.6) + +.icon + transition transform 0.15s + height 13px + +body[data-theme="dark"] + .root + topBarButtonDark() + &:hover + transition 0.2s + color alpha($ui-favorite-star-button-color, 0.6) diff --git a/browser/main/Detail/FullscreenButton.js b/browser/main/Detail/FullscreenButton.js index ee212603..eb33165f 100644 --- a/browser/main/Detail/FullscreenButton.js +++ b/browser/main/Detail/FullscreenButton.js @@ -5,15 +5,17 @@ import styles from './FullscreenButton.styl' import i18n from 'browser/lib/i18n' const OSX = global.process.platform === 'darwin' -const hotkey = (OSX ? i18n.__('Command(⌘)') : i18n.__('Ctrl(^)')) + '+B' const FullscreenButton = ({ onClick -}) => ( - -) +}) => { + const hotkey = (OSX ? i18n.__('Command(⌘)') : i18n.__('Ctrl(^)')) + '+B' + return ( + + ) +} FullscreenButton.propTypes = { onClick: PropTypes.func.isRequired diff --git a/browser/main/Detail/FullscreenButton.styl b/browser/main/Detail/FullscreenButton.styl index cc1a8dff..133577f3 100644 --- a/browser/main/Detail/FullscreenButton.styl +++ b/browser/main/Detail/FullscreenButton.styl @@ -17,6 +17,10 @@ opacity 0 transition 0.1s +.tooltip:lang(ja) + @extend .tooltip + right 35px + body[data-theme="dark"] .control-fullScreenButton topBarButtonDark() \ No newline at end of file diff --git a/browser/main/Detail/InfoPanel.js b/browser/main/Detail/InfoPanel.js index 15535186..86b5ae86 100644 --- a/browser/main/Detail/InfoPanel.js +++ b/browser/main/Detail/InfoPanel.js @@ -14,7 +14,7 @@ class InfoPanel extends React.Component { render () { const { - storageName, folderName, noteLink, updatedAt, createdAt, exportAsMd, exportAsTxt, exportAsHtml, wordCount, letterCount, type, print + storageName, folderName, noteLink, updatedAt, createdAt, exportAsMd, exportAsTxt, exportAsHtml, exportAsPdf, wordCount, letterCount, type, print } = this.props return (
@@ -60,7 +60,7 @@ class InfoPanel extends React.Component {
- { e.target.select() }} /> + { e.target.select() }} /> @@ -85,6 +85,11 @@ class InfoPanel extends React.Component {

{i18n.__('.html')}

+ + - @@ -61,7 +61,8 @@ InfoPanelTrashed.propTypes = { createdAt: PropTypes.string.isRequired, exportAsMd: PropTypes.func.isRequired, exportAsTxt: PropTypes.func.isRequired, - exportAsHtml: PropTypes.func.isRequired + exportAsHtml: PropTypes.func.isRequired, + exportAsPdf: PropTypes.func.isRequired } export default CSSModules(InfoPanelTrashed, styles) diff --git a/browser/main/Detail/MarkdownNoteDetail.js b/browser/main/Detail/MarkdownNoteDetail.js index bc6cd499..207e1e2b 100755 --- a/browser/main/Detail/MarkdownNoteDetail.js +++ b/browser/main/Detail/MarkdownNoteDetail.js @@ -9,7 +9,6 @@ import StarButton from './StarButton' import TagSelect from './TagSelect' import FolderSelect from './FolderSelect' import dataApi from 'browser/main/lib/dataApi' -import { hashHistory } from 'react-router' import ee from 'browser/main/lib/eventEmitter' import markdown from 'browser/lib/markdownTextHelper' import StatusBar from '../StatusBar' @@ -30,6 +29,8 @@ import { getTodoPercentageOfCompleted } from 'browser/lib/getTodoStatus' import striptags from 'striptags' import { confirmDeleteNote } from 'browser/lib/confirmDeleteNote' import markdownToc from 'browser/lib/markdown-toc-generator' +import queryString from 'query-string' +import { replace } from 'connected-react-router' class MarkdownNoteDetail extends React.Component { constructor (props) { @@ -42,9 +43,10 @@ class MarkdownNoteDetail extends React.Component { content: '', linesHighlighted: [] }, props.note), - isLockButtonShown: false, + isLockButtonShown: props.config.editor.type !== 'SPLIT', isLocked: false, - editorType: props.config.editor.type + editorType: props.config.editor.type, + switchPreview: props.config.editor.switchPreview } this.dispatchTimer = null @@ -79,6 +81,20 @@ class MarkdownNoteDetail extends React.Component { if (this.refs.tags) this.refs.tags.reset() }) } + + // Focus content if using blur or double click + // --> Moved here from componentDidMount so a re-render during search won't set focus to the editor + const {switchPreview} = nextProps.config.editor + + if (this.state.switchPreview !== switchPreview) { + this.setState({ + switchPreview + }) + if (switchPreview === 'BLUR' || switchPreview === 'DBL_CLICK') { + console.log('setting focus', switchPreview) + this.focus() + } + } } componentWillUnmount () { @@ -96,7 +112,12 @@ class MarkdownNoteDetail extends React.Component { handleUpdateContent () { const { note } = this.state note.content = this.refs.content.value - note.title = markdown.strip(striptags(findNoteTitle(note.content, this.props.config.editor.enableFrontMatterTitle, this.props.config.editor.frontMatterTitleField))) + + let title = findNoteTitle(note.content, this.props.config.editor.enableFrontMatterTitle, this.props.config.editor.frontMatterTitleField) + title = striptags(title) + title = markdown.strip(title) + note.title = title + this.updateNote(note) } @@ -150,12 +171,12 @@ class MarkdownNoteDetail extends React.Component { originNote: note, note: newNote }) - hashHistory.replace({ + dispatch(replace({ pathname: location.pathname, - query: { + search: queryString.stringify({ key: newNote.key - } - }) + }) + })) this.setState({ isMovingNote: false }) @@ -192,6 +213,10 @@ class MarkdownNoteDetail extends React.Component { ee.emit('export:save-html') } + exportAsPdf () { + ee.emit('export:save-pdf') + } + handleKeyDown (e) { switch (e.keyCode) { // tab key @@ -285,7 +310,7 @@ class MarkdownNoteDetail extends React.Component { } getToggleLockButton () { - return this.state.isLocked ? '../resources/icon/icon-previewoff-on.svg' : '../resources/icon/icon-previewoff-off.svg' + return this.state.isLocked ? '../resources/icon/icon-lock.svg' : '../resources/icon/icon-unlock.svg' } handleDeleteKeyDown (e) { @@ -294,7 +319,7 @@ class MarkdownNoteDetail extends React.Component { handleToggleLockButton (event, noteStatus) { // first argument event is not used - if (this.props.config.editor.switchPreview === 'BLUR' && noteStatus === 'CODE') { + if (noteStatus === 'CODE') { this.setState({isLockButtonShown: true}) } else { this.setState({isLockButtonShown: false}) @@ -320,7 +345,8 @@ class MarkdownNoteDetail extends React.Component { } handleSwitchMode (type) { - this.setState({ editorType: type }, () => { + // If in split mode, hide the lock button + this.setState({ editorType: type, isLockButtonShown: !(type === 'SPLIT') }, () => { this.focus() const newConfig = Object.assign({}, this.props.config) newConfig.editor.type = type @@ -365,6 +391,7 @@ class MarkdownNoteDetail extends React.Component { noteKey={note.key} linesHighlighted={note.linesHighlighted} onChange={this.handleUpdateContent.bind(this)} + isLocked={this.state.isLocked} ignorePreviewPointerEvents={ignorePreviewPointerEvents} /> } else { @@ -382,7 +409,7 @@ class MarkdownNoteDetail extends React.Component { } render () { - const { data, location, config } = this.props + const { data, dispatch, location, config } = this.props const { note, editorType } = this.state const storageKey = note.storage const folderKey = note.folder @@ -415,13 +442,14 @@ class MarkdownNoteDetail extends React.Component { exportAsHtml={this.exportAsHtml} exportAsMd={this.exportAsMd} exportAsTxt={this.exportAsTxt} + exportAsPdf={this.exportAsPdf} />
const detailTopBar =
-
+
this.handleClearTodo(e)} percentageOfTodo={getTodoPercentageOfCompleted(note.content)} />
this.handleSwitchMode(e)} editorType={editorType} /> + this.handleStarButtonClick(e)} isActive={note.isStarred} @@ -454,7 +485,7 @@ class MarkdownNoteDetail extends React.Component { onFocus={(e) => this.handleFocus(e)} onMouseDown={(e) => this.handleLockButtonMouseDown(e)} > - + {this.state.isLocked ? Unlock : Lock} @@ -474,13 +505,14 @@ class MarkdownNoteDetail extends React.Component { div + > button + -webkit-user-drag none + user-select none + > img, span + -webkit-user-drag none + user-select none \ No newline at end of file diff --git a/browser/main/Detail/PermanentDeleteButton.js b/browser/main/Detail/PermanentDeleteButton.js index fa00ef17..7c27ede1 100644 --- a/browser/main/Detail/PermanentDeleteButton.js +++ b/browser/main/Detail/PermanentDeleteButton.js @@ -10,7 +10,7 @@ const PermanentDeleteButton = ({ ) diff --git a/browser/main/Detail/SnippetNoteDetail.js b/browser/main/Detail/SnippetNoteDetail.js index ebe61ba9..ec9a1d0b 100644 --- a/browser/main/Detail/SnippetNoteDetail.js +++ b/browser/main/Detail/SnippetNoteDetail.js @@ -8,7 +8,6 @@ import StarButton from './StarButton' import TagSelect from './TagSelect' import FolderSelect from './FolderSelect' import dataApi from 'browser/main/lib/dataApi' -import {hashHistory} from 'react-router' import ee from 'browser/main/lib/eventEmitter' import CodeMirror from 'codemirror' import 'codemirror-mode-elixir' @@ -18,8 +17,8 @@ import context from 'browser/lib/context' import ConfigManager from 'browser/main/lib/ConfigManager' import _ from 'lodash' import {findNoteTitle} from 'browser/lib/findNoteTitle' -import convertModeName from 'browser/lib/convertModeName' import AwsMobileAnalyticsConfig from 'browser/main/lib/AwsMobileAnalyticsConfig' +import FullscreenButton from './FullscreenButton' import TrashButton from './TrashButton' import RestoreButton from './RestoreButton' import PermanentDeleteButton from './PermanentDeleteButton' @@ -30,6 +29,8 @@ import { formatDate } from 'browser/lib/date-formatter' import i18n from 'browser/lib/i18n' import { confirmDeleteNote } from 'browser/lib/confirmDeleteNote' import markdownToc from 'browser/lib/markdown-toc-generator' +import queryString from 'query-string' +import { replace } from 'connected-react-router' const electron = require('electron') const { remote } = electron @@ -165,12 +166,12 @@ class SnippetNoteDetail extends React.Component { originNote: note, note: newNote }) - hashHistory.replace({ + dispatch(replace({ pathname: location.pathname, - query: { + search: queryString.stringify({ key: newNote.key - } - }) + }) + })) this.setState({ isMovingNote: false }) @@ -517,6 +518,19 @@ class SnippetNoteDetail extends React.Component { ]) } + handleWrapLineButtonClick (e) { + context.popup([ + { + label: 'on', + click: (e) => this.handleWrapLineItemClick(e, true) + }, + { + label: 'off', + click: (e) => this.handleWrapLineItemClick(e, false) + } + ]) + } + handleIndentSizeItemClick (e, indentSize) { const { config, dispatch } = this.props const editor = Object.assign({}, config.editor, { @@ -549,6 +563,22 @@ class SnippetNoteDetail extends React.Component { }) } + handleWrapLineItemClick (e, lineWrapping) { + const { config, dispatch } = this.props + const editor = Object.assign({}, config.editor, { + lineWrapping + }) + ConfigManager.set({ + editor + }) + dispatch({ + type: 'SET_CONFIG', + config: { + editor + } + }) + } + focus () { this.refs.description.focus() } @@ -599,12 +629,14 @@ class SnippetNoteDetail extends React.Component { } addSnippet () { - const { config } = this.props + const { config: { editor: { snippetDefaultLanguage } } } = this.props const { note } = this.state + const defaultLanguage = snippetDefaultLanguage === 'Auto Detect' ? null : snippetDefaultLanguage + note.snippets = note.snippets.concat([{ name: '', - mode: config.editor.snippetDefaultLanguage || 'text', + mode: defaultLanguage, content: '', linesHighlighted: [] }]) @@ -654,6 +686,7 @@ class SnippetNoteDetail extends React.Component { 'export-txt': 'Text export', 'export-md': 'Markdown export', 'export-html': 'HTML export', + 'export-pdf': 'PDF export', 'print': 'Print' })[msg] @@ -666,12 +699,14 @@ class SnippetNoteDetail extends React.Component { } render () { - const { data, config, location } = this.props + const { data, dispatch, config, location } = this.props const { note } = this.state const storageKey = note.storage const folderKey = note.folder + const autoDetect = config.editor.snippetDefaultLanguage === 'Auto Detect' + let editorFontSize = parseInt(config.editor.fontSize, 10) if (!(editorFontSize > 0 && editorFontSize < 101)) editorFontSize = 14 let editorIndentSize = parseInt(config.editor.indentSize, 10) @@ -696,8 +731,6 @@ class SnippetNoteDetail extends React.Component { const viewList = note.snippets.map((snippet, index) => { const isActive = this.state.snippetIndex === index - let syntax = CodeMirror.findModeByName(convertModeName(snippet.mode)) - if (syntax == null) syntax = CodeMirror.findModeByName('Plain Text') return
: }
@@ -763,13 +801,14 @@ class SnippetNoteDetail extends React.Component { exportAsMd={this.showWarning} exportAsTxt={this.showWarning} exportAsHtml={this.showWarning} + exportAsPdf={this.showWarning} />
const detailTopBar =
-
+
this.handleChange(e)} + coloredTags={config.coloredTags} />
@@ -793,11 +834,7 @@ class SnippetNoteDetail extends React.Component { isActive={note.isStarred} /> - + this.handleFullScreenButton(e)} /> this.handleTrashButtonClick(e)} /> @@ -808,12 +845,13 @@ class SnippetNoteDetail extends React.Component { @@ -892,6 +930,12 @@ class SnippetNoteDetail extends React.Component { size: {config.editor.indentSize}  +
- {i18n.__('Star')} + {i18n.__('Star')} ) } diff --git a/browser/main/Detail/StarButton.styl b/browser/main/Detail/StarButton.styl index d5fd755b..e06d1ac9 100644 --- a/browser/main/Detail/StarButton.styl +++ b/browser/main/Detail/StarButton.styl @@ -21,6 +21,11 @@ opacity 0 transition 0.1s +.tooltip:lang(ja) + @extend .tooltip + right 103px + width 70px + .root--active @extend .root transition 0.15s @@ -37,4 +42,4 @@ body[data-theme="dark"] topBarButtonDark() &:hover transition 0.2s - color alpha($ui-favorite-star-button-color, 0.6) \ No newline at end of file + color alpha($ui-favorite-star-button-color, 0.6) diff --git a/browser/main/Detail/TagSelect.js b/browser/main/Detail/TagSelect.js index 6ced475b..615cb5d2 100644 --- a/browser/main/Detail/TagSelect.js +++ b/browser/main/Detail/TagSelect.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types' import React from 'react' +import invertColor from 'invert-color' import CSSModules from 'browser/lib/CSSModules' import styles from './TagSelect.styl' import _ from 'lodash' @@ -7,6 +8,7 @@ import AwsMobileAnalyticsConfig from 'browser/main/lib/AwsMobileAnalyticsConfig' import i18n from 'browser/lib/i18n' import ee from 'browser/main/lib/eventEmitter' import Autosuggest from 'react-autosuggest' +import { push } from 'connected-react-router' class TagSelect extends React.Component { constructor (props) { @@ -45,8 +47,14 @@ class TagSelect extends React.Component { value = _.isArray(value) ? value.slice() : [] - value.push(newTag) - value = _.uniq(value) + + if (!_.includes(value, newTag)) { + value.push(newTag) + } + + if (this.props.saveTagsAlphabetically) { + value = _.sortBy(value) + } this.setState({ newTag: '' @@ -89,8 +97,11 @@ class TagSelect extends React.Component { } handleTagLabelClick (tag) { - const { router } = this.context - router.push(`/tags/${tag}`) + const { dispatch } = this.props + + // Note: `tag` requires encoding later. + // E.g. % in tag is a problem (see issue #3170) - encodeURIComponent(tag) is not working. + dispatch(push(`/tags/${tag}`)) } handleTagRemoveButtonClick (tag) { @@ -179,19 +190,34 @@ class TagSelect extends React.Component { } render () { - const { value, className, showTagsAlphabetically } = this.props + const { value, className, showTagsAlphabetically, coloredTags } = this.props const tagList = _.isArray(value) ? (showTagsAlphabetically ? _.sortBy(value) : value).map((tag) => { + const wrapperStyle = {} + const textStyle = {} + const BLACK = '#333333' + const WHITE = '#f1f1f1' + const color = coloredTags[tag] + const invertedColor = color && invertColor(color, { black: BLACK, white: WHITE }) + let iconRemove = '../resources/icon/icon-x.svg' + if (color) { + wrapperStyle.backgroundColor = color + textStyle.color = invertedColor + } + if (invertedColor === WHITE) { + iconRemove = '../resources/icon/icon-x-light.svg' + } return ( - this.handleTagLabelClick(tag)}>#{tag} + this.handleTagLabelClick(tag)}>#{tag} ) @@ -233,14 +259,12 @@ class TagSelect extends React.Component { } } -TagSelect.contextTypes = { - router: PropTypes.shape({}) -} - TagSelect.propTypes = { + dispatch: PropTypes.func, className: PropTypes.string, value: PropTypes.arrayOf(PropTypes.string), - onChange: PropTypes.func + onChange: PropTypes.func, + coloredTags: PropTypes.object } export default CSSModules(TagSelect, styles) diff --git a/browser/main/Detail/TagSelect.styl b/browser/main/Detail/TagSelect.styl index c6b13f3c..844561c6 100644 --- a/browser/main/Detail/TagSelect.styl +++ b/browser/main/Detail/TagSelect.styl @@ -3,19 +3,18 @@ align-items center user-select none vertical-align middle - width 100% - overflow-x scroll + width 96% + overflow-x auto white-space nowrap - margin-top 31px + top 50px position absolute - -.root::-webkit-scrollbar - display none + &::-webkit-scrollbar + height 8px .tag display flex align-items center - margin 0px 2px + margin 0px 2px 2px padding 2px 4px background-color alpha($ui-tag-backgroundColor, 3%) border-radius 4px diff --git a/browser/main/Detail/ToggleModeButton.js b/browser/main/Detail/ToggleModeButton.js index 7c026429..9d3d61f2 100644 --- a/browser/main/Detail/ToggleModeButton.js +++ b/browser/main/Detail/ToggleModeButton.js @@ -8,19 +8,19 @@ const ToggleModeButton = ({ onClick, editorType }) => (
-
onClick('SPLIT')}> - +
onClick('SPLIT')}> +
-
onClick('EDITOR_PREVIEW')}> - +
onClick('EDITOR_PREVIEW')}> +
- {i18n.__('Toggle Mode')} + {i18n.__('Toggle Mode')}
) ToggleModeButton.propTypes = { onClick: PropTypes.func.isRequired, - editorType: PropTypes.string.Required + editorType: PropTypes.string.isRequired } export default CSSModules(ToggleModeButton, styles) diff --git a/browser/main/Detail/ToggleModeButton.styl b/browser/main/Detail/ToggleModeButton.styl index 73f5acbd..39d30973 100644 --- a/browser/main/Detail/ToggleModeButton.styl +++ b/browser/main/Detail/ToggleModeButton.styl @@ -40,6 +40,11 @@ opacity 0 transition 0.1s +.tooltip:lang(ja) + @extend .tooltip + left -8px + width 70px + body[data-theme="dark"] .control-fullScreenButton topBarButtonDark() @@ -70,3 +75,10 @@ body[data-theme="dracula"] .active background-color #bd93f9 box-shadow 2px 0px 7px #222222 + +.control-toggleModeButton + -webkit-user-drag none + user-select none + > div img + -webkit-user-drag none + user-select none diff --git a/browser/main/Detail/TrashButton.js b/browser/main/Detail/TrashButton.js index 473c2d0b..8ca27ce9 100644 --- a/browser/main/Detail/TrashButton.js +++ b/browser/main/Detail/TrashButton.js @@ -10,8 +10,8 @@ const TrashButton = ({ ) diff --git a/browser/main/Detail/TrashButton.styl b/browser/main/Detail/TrashButton.styl index 7c7af878..a82cfa6b 100644 --- a/browser/main/Detail/TrashButton.styl +++ b/browser/main/Detail/TrashButton.styl @@ -17,6 +17,10 @@ opacity 0 transition 0.1s +.tooltip:lang(ja) + @extend .tooltip + right 46px + .control-trashButton--in-trash top 60px topBarButtonRight() diff --git a/browser/main/Detail/index.js b/browser/main/Detail/index.js index b6b6ef14..95b9d73d 100644 --- a/browser/main/Detail/index.js +++ b/browser/main/Detail/index.js @@ -10,6 +10,7 @@ import StatusBar from '../StatusBar' import i18n from 'browser/lib/i18n' import debounceRender from 'react-debounce-render' import searchFromNotes from 'browser/lib/search' +import queryString from 'query-string' const OSX = global.process.platform === 'darwin' @@ -36,11 +37,11 @@ class Detail extends React.Component { } render () { - const { location, data, params, config } = this.props + const { location, data, match: { params }, config } = this.props + const noteKey = location.search !== '' && queryString.parse(location.search).key let note = null - if (location.query.key != null) { - const noteKey = location.query.key + if (location.search !== '') { const allNotes = data.noteMap.map(note => note) const trashedNotes = data.trashedSet.toJS().map(uniqueKey => data.noteMap.get(uniqueKey)) let displayedNotes = allNotes @@ -49,16 +50,14 @@ class Detail extends React.Component { const searchStr = params.searchword displayedNotes = searchStr === undefined || searchStr === '' ? allNotes : searchFromNotes(allNotes, searchStr) - } - - if (location.pathname.match(/\/tags/)) { + } else if (location.pathname.match(/^\/tags/)) { const listOfTags = params.tagname.split(' ') displayedNotes = data.noteMap.map(note => note).filter(note => listOfTags.every(tag => note.tags.includes(tag)) ) } - if (location.pathname.match(/\/trashed/)) { + if (location.pathname.match(/^\/trashed/)) { displayedNotes = trashedNotes } else { displayedNotes = _.differenceWith(displayedNotes, trashedNotes, (note, trashed) => note.key === trashed.key) diff --git a/browser/main/DevTools/index.dev.js b/browser/main/DevTools/index.dev.js new file mode 100644 index 00000000..77d3eccd --- /dev/null +++ b/browser/main/DevTools/index.dev.js @@ -0,0 +1,16 @@ +import React from 'react' +import { createDevTools } from 'redux-devtools' +import LogMonitor from 'redux-devtools-log-monitor' +import DockMonitor from 'redux-devtools-dock-monitor' + +const DevTools = createDevTools( + + + +) + +export default DevTools diff --git a/browser/main/DevTools/index.js b/browser/main/DevTools/index.js new file mode 100644 index 00000000..d39d5fbb --- /dev/null +++ b/browser/main/DevTools/index.js @@ -0,0 +1,8 @@ +/* eslint-disable no-undef */ +if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line global-require + module.exports = require('./index.dev').default +} else { + // eslint-disable-next-line global-require + module.exports = require('./index.prod').default +} diff --git a/browser/main/DevTools/index.prod.js b/browser/main/DevTools/index.prod.js new file mode 100644 index 00000000..762cae2c --- /dev/null +++ b/browser/main/DevTools/index.prod.js @@ -0,0 +1,6 @@ +import React from 'react' + +const DevTools = () =>
+DevTools.instrument = () => {} + +export default DevTools diff --git a/browser/main/Main.js b/browser/main/Main.js index 556c5daf..e277c421 100644 --- a/browser/main/Main.js +++ b/browser/main/Main.js @@ -12,11 +12,11 @@ import _ from 'lodash' import ConfigManager from 'browser/main/lib/ConfigManager' import mobileAnalytics from 'browser/main/lib/AwsMobileAnalyticsConfig' import eventEmitter from 'browser/main/lib/eventEmitter' -import { hashHistory } from 'react-router' -import store from 'browser/main/store' +import { store } from 'browser/main/store' import i18n from 'browser/lib/i18n' import { getLocales } from 'browser/lib/Languages' import applyShortcuts from 'browser/main/lib/shortcutManager' +import { push } from 'connected-react-router' const path = require('path') const electron = require('electron') const { remote } = electron @@ -102,7 +102,7 @@ class Main extends React.Component { { name: 'example.js', mode: 'javascript', - content: "var boostnote = document.getElementById('enjoy').innerHTML\n\nconsole.log(boostnote)", + content: "var boostnote = document.getElementById('hello').innerHTML\n\nconsole.log(boostnote)", linesHighlighted: [] } ] @@ -132,7 +132,7 @@ class Main extends React.Component { .then(() => data.storage) }) .then(storage => { - hashHistory.push('/storages/' + storage.key) + store.dispatch(push('/storages/' + storage.key)) }) .catch(err => { throw err @@ -169,13 +169,25 @@ class Main extends React.Component { } }) + // eslint-disable-next-line no-undef delete CodeMirror.keyMap.emacs['Ctrl-V'] eventEmitter.on('editor:fullscreen', this.toggleFullScreen) + eventEmitter.on('menubar:togglemenubar', this.toggleMenuBarVisible.bind(this)) } componentWillUnmount () { eventEmitter.off('editor:fullscreen', this.toggleFullScreen) + eventEmitter.off('menubar:togglemenubar', this.toggleMenuBarVisible.bind(this)) + } + + toggleMenuBarVisible () { + const { config } = this.props + const { ui } = config + + const newUI = Object.assign(ui, {showMenuBar: !ui.showMenuBar}) + const newConfig = Object.assign(config, newUI) + ConfigManager.set(newConfig) } handleLeftSlideMouseDown (e) { @@ -300,7 +312,7 @@ class Main extends React.Component { onMouseUp={e => this.handleMouseUp(e)} > {!config.isSideNavFolded && @@ -330,7 +342,7 @@ class Main extends React.Component { 'dispatch', 'config', 'data', - 'params', + 'match', 'location' ])} /> @@ -340,7 +352,7 @@ class Main extends React.Component { 'dispatch', 'data', 'config', - 'params', + 'match', 'location' ])} /> @@ -362,7 +374,7 @@ class Main extends React.Component { 'dispatch', 'data', 'config', - 'params', + 'match', 'location' ])} ignorePreviewPointerEvents={this.state.isRightSliderFocused} diff --git a/browser/main/NewNoteButton/index.js b/browser/main/NewNoteButton/index.js index c34443be..27e2baa5 100644 --- a/browser/main/NewNoteButton/index.js +++ b/browser/main/NewNoteButton/index.js @@ -21,23 +21,20 @@ class NewNoteButton extends React.Component { this.state = { } - this.newNoteHandler = () => { - this.handleNewNoteButtonClick() - } + this.handleNewNoteButtonClick = this.handleNewNoteButtonClick.bind(this) } componentDidMount () { - eventEmitter.on('top:new-note', this.newNoteHandler) + eventEmitter.on('top:new-note', this.handleNewNoteButtonClick) } componentWillUnmount () { - eventEmitter.off('top:new-note', this.newNoteHandler) + eventEmitter.off('top:new-note', this.handleNewNoteButtonClick) } handleNewNoteButtonClick (e) { - const { location, params, dispatch, config } = this.props + const { location, dispatch, match: { params }, config } = this.props const { storage, folder } = this.resolveTargetFolder() - if (config.ui.defaultNote === 'MARKDOWN_NOTE') { createMarkdownNote(storage.key, folder.key, dispatch, location, params, config) } else if (config.ui.defaultNote === 'SNIPPET_NOTE') { @@ -55,9 +52,8 @@ class NewNoteButton extends React.Component { } resolveTargetFolder () { - const { data, params } = this.props + const { data, match: { params } } = this.props let storage = data.storageMap.get(params.storageKey) - // Find first storage if (storage == null) { for (const kv of data.storageMap) { @@ -93,8 +89,8 @@ class NewNoteButton extends React.Component { >
@@ -1110,6 +1163,7 @@ class NoteList extends React.Component { tabIndex='-1' onKeyDown={(e) => this.handleNoteListKeyDown(e)} onKeyUp={this.handleNoteListKeyUp} + onBlur={this.handleNoteListBlur} > {noteList}
diff --git a/browser/main/SideNav/PreferenceButton.js b/browser/main/SideNav/PreferenceButton.js index 187171f4..187bc41a 100644 --- a/browser/main/SideNav/PreferenceButton.js +++ b/browser/main/SideNav/PreferenceButton.js @@ -8,7 +8,7 @@ const PreferenceButton = ({ onClick }) => ( ) diff --git a/browser/main/SideNav/StorageItem.js b/browser/main/SideNav/StorageItem.js index e336f3ce..5cd4a491 100644 --- a/browser/main/SideNav/StorageItem.js +++ b/browser/main/SideNav/StorageItem.js @@ -2,7 +2,6 @@ import PropTypes from 'prop-types' import React from 'react' import CSSModules from 'browser/lib/CSSModules' import styles from './StorageItem.styl' -import { hashHistory } from 'react-router' import modal from 'browser/main/lib/modal' import CreateFolderModal from 'browser/main/modals/CreateFolderModal' import RenameFolderModal from 'browser/main/modals/RenameFolderModal' @@ -12,6 +11,7 @@ import _ from 'lodash' import { SortableElement } from 'react-sortable-hoc' import i18n from 'browser/lib/i18n' import context from 'browser/lib/context' +import { push } from 'connected-react-router' const { remote } = require('electron') const { dialog } = remote @@ -134,14 +134,14 @@ class StorageItem extends React.Component { } handleHeaderInfoClick (e) { - const { storage } = this.props - hashHistory.push('/storages/' + storage.key) + const { storage, dispatch } = this.props + dispatch(push('/storages/' + storage.key)) } handleFolderButtonClick (folderKey) { return (e) => { - const { storage } = this.props - hashHistory.push('/storages/' + storage.key + '/folders/' + folderKey) + const { storage, dispatch } = this.props + dispatch(push('/storages/' + storage.key + '/folders/' + folderKey)) } } @@ -362,14 +362,14 @@ class StorageItem extends React.Component { }
{this.state.isOpen && -
+
{folderList}
} diff --git a/browser/main/SideNav/index.js b/browser/main/SideNav/index.js index b98d859d..241d4151 100644 --- a/browser/main/SideNav/index.js +++ b/browser/main/SideNav/index.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types' import React from 'react' +import { push } from 'connected-react-router' import CSSModules from 'browser/lib/CSSModules' import dataApi from 'browser/main/lib/dataApi' import styles from './SideNav.styl' @@ -20,13 +21,31 @@ import i18n from 'browser/lib/i18n' import context from 'browser/lib/context' import { remote } from 'electron' import { confirmDeleteNote } from 'browser/lib/confirmDeleteNote' +import ColorPicker from 'browser/components/ColorPicker' +import { every, sortBy } from 'lodash' function matchActiveTags (tags, activeTags) { - return _.every(activeTags, v => tags.indexOf(v) >= 0) + return every(activeTags, v => tags.indexOf(v) >= 0) } class SideNav extends React.Component { // TODO: should not use electron stuff v0.7 + constructor (props) { + super(props) + + this.state = { + colorPicker: { + show: false, + color: null, + tagName: null, + targetRect: null + } + } + + this.dismissColorPicker = this.dismissColorPicker.bind(this) + this.handleColorPickerConfirm = this.handleColorPickerConfirm.bind(this) + this.handleColorPickerReset = this.handleColorPickerReset.bind(this) + } componentDidMount () { EventEmitter.on('side:preferences', this.handleMenuButtonClick) @@ -38,14 +57,14 @@ class SideNav extends React.Component { deleteTag (tag) { const selectedButton = remote.dialog.showMessageBox(remote.getCurrentWindow(), { - ype: 'warning', + type: 'warning', message: i18n.__('Confirm tag deletion'), detail: i18n.__('This will permanently remove this tag.'), buttons: [i18n.__('Confirm'), i18n.__('Cancel')] }) if (selectedButton === 0) { - const { data, dispatch, location, params } = this.props + const { data, dispatch, location, match: { params } } = this.props const notes = data.noteMap .map(note => note) @@ -75,7 +94,7 @@ class SideNav extends React.Component { if (index !== -1) { tags.splice(index, 1) - this.context.router.push(`/tags/${tags.map(tag => encodeURIComponent(tag)).join(' ')}`) + dispatch(push(`/tags/${tags.map(tag => encodeURIComponent(tag)).join(' ')}`)) } } }) @@ -87,13 +106,13 @@ class SideNav extends React.Component { } handleHomeButtonClick (e) { - const { router } = this.context - router.push('/home') + const { dispatch } = this.props + dispatch(push('/home')) } handleStarredButtonClick (e) { - const { router } = this.context - router.push('/starred') + const { dispatch } = this.props + dispatch(push('/starred')) } handleTagContextMenu (e, tag) { @@ -104,9 +123,64 @@ class SideNav extends React.Component { click: this.deleteTag.bind(this, tag) }) + menu.push({ + label: i18n.__('Customize Color'), + click: this.displayColorPicker.bind(this, tag, e.target.getBoundingClientRect()) + }) + context.popup(menu) } + dismissColorPicker () { + this.setState({ + colorPicker: { + show: false + } + }) + } + + displayColorPicker (tagName, rect) { + const { config } = this.props + this.setState({ + colorPicker: { + show: true, + color: config.coloredTags[tagName], + tagName, + targetRect: rect + } + }) + } + + handleColorPickerConfirm (color) { + const { dispatch, config: {coloredTags} } = this.props + const { colorPicker: { tagName } } = this.state + const newColoredTags = Object.assign({}, coloredTags, {[tagName]: color.hex}) + + const config = { coloredTags: newColoredTags } + ConfigManager.set(config) + dispatch({ + type: 'SET_CONFIG', + config + }) + this.dismissColorPicker() + } + + handleColorPickerReset () { + const { dispatch, config: {coloredTags} } = this.props + const { colorPicker: { tagName } } = this.state + const newColoredTags = Object.assign({}, coloredTags) + + delete newColoredTags[tagName] + + const config = { coloredTags: newColoredTags } + ConfigManager.set(config) + dispatch({ + type: 'SET_CONFIG', + config + }) + this.dismissColorPicker() + } + handleToggleButtonClick (e) { const { dispatch, config } = this.props @@ -118,18 +192,18 @@ class SideNav extends React.Component { } handleTrashedButtonClick (e) { - const { router } = this.context - router.push('/trashed') + const { dispatch } = this.props + dispatch(push('/trashed')) } handleSwitchFoldersButtonClick () { - const { router } = this.context - router.push('/home') + const { dispatch } = this.props + dispatch(push('/home')) } handleSwitchTagsButtonClick () { - const { router } = this.context - router.push('/alltags') + const { dispatch } = this.props + dispatch(push('/alltags')) } onSortEnd (storage) { @@ -198,6 +272,7 @@ class SideNav extends React.Component {
{this.tagListComponent(data)}
+
) } @@ -207,9 +282,10 @@ class SideNav extends React.Component { tagListComponent () { const { data, location, config } = this.props + const { colorPicker } = this.state const activeTags = this.getActiveTags(location.pathname) const relatedTags = this.getRelatedTags(activeTags, data.noteMap) - let tagList = _.sortBy(data.tagNoteMap.map( + let tagList = sortBy(data.tagNoteMap.map( (tag, name) => ({ name, size: tag.size, related: relatedTags.has(name) }) ).filter( tag => tag.size > 0 @@ -222,7 +298,7 @@ class SideNav extends React.Component { }) } if (config.sortTagsBy === 'COUNTER') { - tagList = _.sortBy(tagList, item => (0 - item.size)) + tagList = sortBy(tagList, item => (0 - item.size)) } if (config.ui.showOnlyRelatedTags && (relatedTags.size > 0)) { tagList = tagList.filter( @@ -237,10 +313,11 @@ class SideNav extends React.Component { handleClickTagListItem={this.handleClickTagListItem.bind(this)} handleClickNarrowToTag={this.handleClickNarrowToTag.bind(this)} handleContextMenu={this.handleTagContextMenu.bind(this)} - isActive={this.getTagActive(location.pathname, tag.name)} + isActive={this.getTagActive(location.pathname, tag.name) || (colorPicker.tagName === tag.name)} isRelated={tag.related} key={tag.name} count={tag.size} + color={config.coloredTags[tag.name]} /> ) }) @@ -274,8 +351,8 @@ class SideNav extends React.Component { } handleClickTagListItem (name) { - const { router } = this.context - router.push(`/tags/${encodeURIComponent(name)}`) + const { dispatch } = this.props + dispatch(push(`/tags/${encodeURIComponent(name)}`)) } handleSortTagsByChange (e) { @@ -293,8 +370,7 @@ class SideNav extends React.Component { } handleClickNarrowToTag (tag) { - const { router } = this.context - const { location } = this.props + const { dispatch, location } = this.props const listOfTags = this.getActiveTags(location.pathname) const indexOfTag = listOfTags.indexOf(tag) if (indexOfTag > -1) { @@ -302,7 +378,7 @@ class SideNav extends React.Component { } else { listOfTags.push(tag) } - router.push(`/tags/${encodeURIComponent(listOfTags.join(' '))}`) + dispatch(push(`/tags/${encodeURIComponent(listOfTags.join(' '))}`)) } emptyTrash (entries) { @@ -333,6 +409,7 @@ class SideNav extends React.Component { render () { const { data, location, config, dispatch } = this.props + const { colorPicker: colorPickerState } = this.state const isFolded = config.isSideNavFolded @@ -349,9 +426,23 @@ class SideNav extends React.Component { useDragHandle /> }) + + let colorPicker + if (colorPickerState.show) { + colorPicker = ( + + ) + } + const style = {} if (!isFolded) style.width = this.props.width - const isTagActive = location.pathname.match(/tag/) + const isTagActive = /tag/.test(location.pathname) return (
{this.SideNavComponent(isFolded, storageList)} + {colorPicker}
) } diff --git a/browser/main/TopBar/index.js b/browser/main/TopBar/index.js index 91256daf..09fd56b2 100644 --- a/browser/main/TopBar/index.js +++ b/browser/main/TopBar/index.js @@ -7,6 +7,8 @@ import ee from 'browser/main/lib/eventEmitter' import NewNoteButton from 'browser/main/NewNoteButton' import i18n from 'browser/lib/i18n' import debounce from 'lodash/debounce' +import CInput from 'react-composition-input' +import { push } from 'connected-react-router' class TopBar extends React.Component { constructor (props) { @@ -15,26 +17,36 @@ class TopBar extends React.Component { this.state = { search: '', searchOptions: [], - isSearching: false, - isAlphabet: false, - isIME: false, - isConfirmTranslation: false + isSearching: false } + const { dispatch } = this.props + this.focusSearchHandler = () => { this.handleOnSearchFocus() } this.codeInitHandler = this.handleCodeInit.bind(this) + this.handleKeyDown = this.handleKeyDown.bind(this) + this.handleSearchFocus = this.handleSearchFocus.bind(this) + this.handleSearchBlur = this.handleSearchBlur.bind(this) + this.handleSearchChange = this.handleSearchChange.bind(this) + this.handleSearchClearButton = this.handleSearchClearButton.bind(this) - this.updateKeyword = debounce(this.updateKeyword, 1000 / 60, { + this.debouncedUpdateKeyword = debounce((keyword) => { + dispatch(push(`/searched/${encodeURIComponent(keyword)}`)) + this.setState({ + search: keyword + }) + ee.emit('top:search', keyword) + }, 1000 / 60, { maxWait: 1000 / 8 }) } componentDidMount () { - const { params } = this.props - const searchWord = params.searchword + const { match: { params } } = this.props + const searchWord = params && params.searchword if (searchWord !== undefined) { this.setState({ search: searchWord, @@ -51,22 +63,22 @@ class TopBar extends React.Component { } handleSearchClearButton (e) { - const { router } = this.context + const { dispatch } = this.props this.setState({ search: '', isSearching: false }) this.refs.search.childNodes[0].blur - router.push('/searched') + dispatch(push('/searched')) e.preventDefault() + this.debouncedUpdateKeyword('') } handleKeyDown (e) { - // reset states - this.setState({ - isAlphabet: false, - isIME: false - }) + // Re-apply search field on ENTER key + if (e.keyCode === 13) { + this.debouncedUpdateKeyword(e.target.value) + } // Clear search on ESC if (e.keyCode === 27) { @@ -84,51 +96,11 @@ class TopBar extends React.Component { 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({ - isAlphabet: true - }) - // When the key is an IME input (Japanese, Chinese) - } else if (e.keyCode === 229) { - this.setState({ - isIME: true - }) - } - } - - handleKeyUp (e) { - // reset states - this.setState({ - isConfirmTranslation: false - }) - - // When the key is translation confirmation (Enter, Space) - if (this.state.isIME && (e.keyCode === 32 || e.keyCode === 13)) { - this.setState({ - isConfirmTranslation: true - }) - const keyword = this.refs.searchInput.value - this.updateKeyword(keyword) - } } handleSearchChange (e) { - if (this.state.isAlphabet || this.state.isConfirmTranslation) { - const keyword = this.refs.searchInput.value - this.updateKeyword(keyword) - } else { - e.preventDefault() - } - } - - updateKeyword (keyword) { - this.context.router.push(`/searched/${encodeURIComponent(keyword)}`) - this.setState({ - search: keyword - }) - ee.emit('top:search', keyword) + const keyword = e.target.value + this.debouncedUpdateKeyword(keyword) } handleSearchFocus (e) { @@ -136,6 +108,7 @@ class TopBar extends React.Component { isSearching: true }) } + handleSearchBlur (e) { e.stopPropagation() @@ -165,7 +138,7 @@ class TopBar extends React.Component { } handleCodeInit () { - ee.emit('top:search', this.refs.searchInput.value) + ee.emit('top:search', this.refs.searchInput.value || '') } render () { @@ -178,24 +151,23 @@ class TopBar extends React.Component {
this.handleSearchFocus(e)} - onBlur={(e) => this.handleSearchBlur(e)} + onFocus={this.handleSearchFocus} + onBlur={this.handleSearchBlur} tabIndex='-1' ref='search' > - this.handleSearchChange(e)} - onKeyDown={(e) => this.handleKeyDown(e)} - onKeyUp={(e) => this.handleKeyUp(e)} + onInputChange={this.handleSearchChange} + onKeyDown={this.handleKeyDown} placeholder={i18n.__('Search')} type='text' className='searchInput' /> {this.state.search !== '' &&
diff --git a/browser/main/global.styl b/browser/main/global.styl index e04060c2..d864993d 100644 --- a/browser/main/global.styl +++ b/browser/main/global.styl @@ -97,6 +97,7 @@ modalBackColor = white body[data-theme="dark"] + background-color $ui-dark-backgroundColor ::-webkit-scrollbar-thumb background-color rgba(0, 0, 0, 0.3) .ModalBase @@ -148,6 +149,7 @@ body[data-theme="dark"] z-index modalZIndex + 5 body[data-theme="solarized-dark"] + background-color $ui-solarized-dark-backgroundColor ::-webkit-scrollbar-thumb background-color rgba(0, 0, 0, 0.3) .ModalBase @@ -157,6 +159,7 @@ body[data-theme="solarized-dark"] color: $ui-solarized-dark-text-color body[data-theme="monokai"] + background-color $ui-monokai-backgroundColor ::-webkit-scrollbar-thumb background-color rgba(0, 0, 0, 0.3) .ModalBase @@ -166,6 +169,7 @@ body[data-theme="monokai"] color: $ui-monokai-text-color body[data-theme="dracula"] + background-color $ui-dracula-backgroundColor ::-webkit-scrollbar-thumb background-color rgba(0, 0, 0, 0.3) .ModalBase diff --git a/browser/main/index.js b/browser/main/index.js index 6e8bdcc5..b3a909e5 100644 --- a/browser/main/index.js +++ b/browser/main/index.js @@ -1,11 +1,13 @@ import { Provider } from 'react-redux' import Main from './Main' -import store from './store' -import React from 'react' +import { store, history } from './store' +import React, { Fragment } from 'react' import ReactDOM from 'react-dom' require('!!style!css!stylus?sourceMap!./global.styl') -import { Router, Route, IndexRoute, IndexRedirect, hashHistory } from 'react-router' -import { syncHistoryWithStore } from 'react-router-redux' +import { Route, Switch, Redirect } from 'react-router-dom' +import { ConnectedRouter } from 'connected-react-router' +import DevTools from './DevTools' + require('./lib/ipcClient') require('../lib/customMeta') import i18n from 'browser/lib/i18n' @@ -77,7 +79,6 @@ document.addEventListener('click', function (e) { }) const el = document.getElementById('content') -const history = syncHistoryWithStore(hashHistory, store) function notify (...args) { return new window.Notification(...args) @@ -98,29 +99,24 @@ function updateApp () { ReactDOM.render(( - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + {/* storages */} + + + + + + + ), el, function () { const loadingCover = document.getElementById('loadingCover') diff --git a/browser/main/lib/ConfigManager.js b/browser/main/lib/ConfigManager.js index c2ff9f7a..ce641b9a 100644 --- a/browser/main/lib/ConfigManager.js +++ b/browser/main/lib/ConfigManager.js @@ -8,9 +8,14 @@ const win = global.process.platform === 'win32' const electron = require('electron') const { ipcRenderer } = electron const consts = require('browser/lib/consts') +const electronConfig = new (require('electron-config'))() let isInitialized = false +const DEFAULT_MARKDOWN_LINT_CONFIG = `{ + "default": true +}` + export const DEFAULT_CONFIG = { zoom: 1, isSideNavFolded: false, @@ -22,29 +27,40 @@ export const DEFAULT_CONFIG = { sortTagsBy: 'ALPHABETICAL', // 'ALPHABETICAL', 'COUNTER' listStyle: 'DEFAULT', // 'DEFAULT', 'SMALL' amaEnabled: true, + autoUpdateEnabled: true, hotkey: { toggleMain: OSX ? 'Command + Alt + L' : 'Super + Alt + E', toggleMode: OSX ? 'Command + Alt + M' : 'Ctrl + M', deleteNote: OSX ? 'Command + Shift + Backspace' : 'Ctrl + Shift + Backspace', - pasteSmartly: OSX ? 'Command + Shift + V' : 'Ctrl + Shift + V' + pasteSmartly: OSX ? 'Command + Shift + V' : 'Ctrl + Shift + V', + prettifyMarkdown: OSX ? 'Command + Shift + F' : 'Ctrl + Shift + F', + sortLines: OSX ? 'Command + Shift + S' : 'Ctrl + Shift + S', + insertDate: OSX ? 'Command + /' : 'Ctrl + /', + insertDateTime: OSX ? 'Command + Alt + /' : 'Ctrl + Shift + /', + toggleMenuBar: 'Alt' }, ui: { language: 'en', theme: 'default', showCopyNotification: true, disableDirectWrite: false, - defaultNote: 'ALWAYS_ASK' // 'ALWAYS_ASK', 'SNIPPET_NOTE', 'MARKDOWN_NOTE' + defaultNote: 'ALWAYS_ASK', // 'ALWAYS_ASK', 'SNIPPET_NOTE', 'MARKDOWN_NOTE' + showMenuBar: false }, editor: { theme: 'base16-light', keyMap: 'sublime', fontSize: '14', - fontFamily: win ? 'Segoe UI' : 'Monaco, Consolas', + fontFamily: win ? 'Consolas' : 'Monaco', indentType: 'space', indentSize: '2', + lineWrapping: true, enableRulers: false, rulers: [80, 120], displayLineNumbers: true, + matchingPairs: '()[]{}\'\'""$$**``~~__', + matchingTriples: '```"""\'\'\'', + explodingPairs: '[]{}``$$', switchPreview: 'BLUR', // 'BLUR', 'DBL_CLICK', 'RIGHTCLICK' delfaultStatus: 'PREVIEW', // 'PREVIEW', 'CODE' scrollPastEnd: false, @@ -54,7 +70,16 @@ export const DEFAULT_CONFIG = { enableFrontMatterTitle: true, frontMatterTitleField: 'title', spellcheck: false, - enableSmartPaste: false + enableSmartPaste: false, + enableMarkdownLint: false, + customMarkdownLintConfig: DEFAULT_MARKDOWN_LINT_CONFIG, + prettierConfig: ` { + "trailingComma": "es5", + "tabWidth": 4, + "semi": false, + "singleQuote": true + }`, + deleteUnusedAttachments: true }, preview: { fontSize: '14', @@ -72,8 +97,10 @@ export const DEFAULT_CONFIG = { breaks: true, smartArrows: false, allowCustomCSS: false, - customCSS: '', + + customCSS: '/* Drop Your Custom CSS Code Here */', sanitize: 'STRICT', // 'STRICT', 'ALLOW_STYLES', 'NONE' + mermaidHTMLLabel: false, lineThroughCheckbox: true }, blog: { @@ -83,7 +110,8 @@ export const DEFAULT_CONFIG = { token: '', username: '', password: '' - } + }, + coloredTags: {} } function validate (config) { @@ -96,7 +124,6 @@ function validate (config) { } function _save (config) { - console.log(config) window.localStorage.setItem('config', JSON.stringify(config)) } @@ -116,6 +143,8 @@ function get () { _save(config) } + config.autoUpdateEnabled = electronConfig.get('autoUpdateEnabled', config.autoUpdateEnabled) + if (!isInitialized) { isInitialized = true let editorTheme = document.getElementById('editorTheme') @@ -126,16 +155,12 @@ function get () { document.head.appendChild(editorTheme) } - config.editor.theme = consts.THEMES.some((theme) => theme === config.editor.theme) - ? config.editor.theme - : 'default' + const theme = consts.THEMES.find(theme => theme.name === config.editor.theme) - if (config.editor.theme !== 'default') { - if (config.editor.theme.startsWith('solarized')) { - editorTheme.setAttribute('href', '../node_modules/codemirror/theme/solarized.css') - } else { - editorTheme.setAttribute('href', '../node_modules/codemirror/theme/' + config.editor.theme + '.css') - } + if (theme) { + editorTheme.setAttribute('href', theme.path) + } else { + config.editor.theme = 'default' } } @@ -144,7 +169,13 @@ function get () { function set (updates) { const currentConfig = get() - const newConfig = Object.assign({}, DEFAULT_CONFIG, currentConfig, updates) + + const arrangedUpdates = updates + if (updates.preview !== undefined && updates.preview.customCSS === '') { + arrangedUpdates.preview.customCSS = DEFAULT_CONFIG.preview.customCSS + } + + const newConfig = Object.assign({}, DEFAULT_CONFIG, currentConfig, arrangedUpdates) if (!validate(newConfig)) throw new Error('INVALID CONFIG') _save(newConfig) @@ -171,18 +202,15 @@ function set (updates) { editorTheme.setAttribute('rel', 'stylesheet') document.head.appendChild(editorTheme) } - const newTheme = consts.THEMES.some((theme) => theme === newConfig.editor.theme) - ? newConfig.editor.theme - : 'default' - if (newTheme !== 'default') { - if (newTheme.startsWith('solarized')) { - editorTheme.setAttribute('href', '../node_modules/codemirror/theme/solarized.css') - } else { - editorTheme.setAttribute('href', '../node_modules/codemirror/theme/' + newTheme + '.css') - } + const newTheme = consts.THEMES.find(theme => theme.name === newConfig.editor.theme) + + if (newTheme) { + editorTheme.setAttribute('href', newTheme.path) } + electronConfig.set('autoUpdateEnabled', newConfig.autoUpdateEnabled) + ipcRenderer.send('config-renew', { config: get() }) @@ -205,7 +233,7 @@ function assignConfigValues (originalConfig, rcConfig) { function rewriteHotkey (config) { const keys = [...Object.keys(config.hotkey)] keys.forEach(key => { - config.hotkey[key] = config.hotkey[key].replace(/Cmd/g, 'Command') + config.hotkey[key] = config.hotkey[key].replace(/Cmd\s/g, 'Command ') config.hotkey[key] = config.hotkey[key].replace(/Opt\s/g, 'Option ') }) return config diff --git a/browser/main/lib/dataApi/attachmentManagement.js b/browser/main/lib/dataApi/attachmentManagement.js index 6a0315f7..971ae812 100644 --- a/browser/main/lib/dataApi/attachmentManagement.js +++ b/browser/main/lib/dataApi/attachmentManagement.js @@ -6,7 +6,9 @@ const mdurl = require('mdurl') const fse = require('fs-extra') const escapeStringRegexp = require('escape-string-regexp') const sander = require('sander') +const url = require('url') import i18n from 'browser/lib/i18n' +import { isString } from 'lodash' const STORAGE_FOLDER_PLACEHOLDER = ':storage' const DESTINATION_FOLDER = 'attachments' @@ -18,15 +20,23 @@ const PATH_SEPARATORS = escapeStringRegexp(path.posix.sep) + escapeStringRegexp( * @returns {Promise} Image element created */ function getImage (file) { - return new Promise((resolve) => { - const reader = new FileReader() - const img = new Image() - img.onload = () => resolve(img) - reader.onload = e => { - img.src = e.target.result - } - reader.readAsDataURL(file) - }) + if (isString(file)) { + return new Promise(resolve => { + const img = new Image() + img.onload = () => resolve(img) + img.src = file + }) + } else { + return new Promise(resolve => { + const reader = new FileReader() + const img = new Image() + img.onload = () => resolve(img) + reader.onload = e => { + img.src = e.target.result + } + reader.readAsDataURL(file) + }) + } } /** @@ -76,7 +86,7 @@ function getOrientation (file) { return view.getUint16(offset + (i * 12) + 8, little) } } - } else if ((marker & 0xFF00) !== 0xFF00) { // If not start with 0xFF, not a Marker + } else if ((marker & 0xFF00) !== 0xFF00) { // If not start with 0xFF, not a Marker. break } else { offset += view.getUint16(offset, false) @@ -151,23 +161,28 @@ function copyAttachment (sourceFilePath, storageKey, noteKey, useRandomName = tr try { const isBase64 = typeof sourceFilePath === 'object' && sourceFilePath.type === 'base64' - if (!fs.existsSync(sourceFilePath) && !isBase64) { + if (!isBase64 && !fs.existsSync(sourceFilePath)) { return reject('source file does not exist') } - const targetStorage = findStorage.findStorage(storageKey) + + const sourcePath = sourceFilePath.sourceFilePath || sourceFilePath + const sourceURL = url.parse(/^\w+:\/\//.test(sourcePath) ? sourcePath : 'file:///' + sourcePath) + let destinationName if (useRandomName) { - destinationName = `${uniqueSlug()}${path.extname(sourceFilePath.sourceFilePath || sourceFilePath)}` + destinationName = `${uniqueSlug()}${path.extname(sourceURL.pathname) || '.png'}` } else { - destinationName = path.basename(sourceFilePath.sourceFilePath || sourceFilePath) + destinationName = path.basename(sourceURL.pathname) } + + const targetStorage = findStorage.findStorage(storageKey) const destinationDir = path.join(targetStorage.path, DESTINATION_FOLDER, noteKey) createAttachmentDestinationFolder(targetStorage.path, noteKey) const outputFile = fs.createWriteStream(path.join(destinationDir, destinationName)) if (isBase64) { const base64Data = sourceFilePath.data.replace(/^data:image\/\w+;base64,/, '') - const dataBuffer = new Buffer(base64Data, 'base64') + const dataBuffer = Buffer.from(base64Data, 'base64') outputFile.write(dataBuffer, () => { resolve(destinationName) }) @@ -227,17 +242,20 @@ function migrateAttachments (markdownContent, storagePath, noteKey) { * @returns {String} postprocessed HTML in which all :storage references are mapped to the actual paths. */ function fixLocalURLS (renderedHTML, storagePath) { + const encodedWin32SeparatorRegex = /%5C/g + const storageRegex = new RegExp('/?' + STORAGE_FOLDER_PLACEHOLDER, 'g') + const storageUrl = 'file:///' + path.join(storagePath, DESTINATION_FOLDER).replace(/\\/g, '/') + /* A :storage reference is like `:storage/3b6f8bd6-4edd-4b15-96e0-eadc4475b564/f939b2c3.jpg`. - `STORAGE_FOLDER_PLACEHOLDER` will match `:storage` - - `(?:(?:\\\/|%5C)[\\w.]+)+` will match `/3b6f8bd6-4edd-4b15-96e0-eadc4475b564/f939b2c3.jpg` - - `(?:\\\/|%5C)[\\w.]+` will either match `/3b6f8bd6-4edd-4b15-96e0-eadc4475b564` or `/f939b2c3.jpg` + - `(?:(?:\\\/|%5C)[-.\\w]+)+` will match `/3b6f8bd6-4edd-4b15-96e0-eadc4475b564/f939b2c3.jpg` + - `(?:\\\/|%5C)[-.\\w]+` will either match `/3b6f8bd6-4edd-4b15-96e0-eadc4475b564` or `/f939b2c3.jpg` - `(?:\\\/|%5C)` match the path seperator. `\\\/` for posix systems and `%5C` for windows. */ - return renderedHTML.replace(new RegExp('/?' + STORAGE_FOLDER_PLACEHOLDER + '(?:(?:\\\/|%5C)[\\w.]+)+', '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)) + return renderedHTML.replace(new RegExp('/?' + STORAGE_FOLDER_PLACEHOLDER + '(?:(?:\\\/|%5C)[-.\\w]+)+', 'g'), function (match) { + return match.replace(encodedWin32SeparatorRegex, '/').replace(storageRegex, storageUrl) }) } @@ -261,22 +279,87 @@ function generateAttachmentMarkdown (fileName, path, showPreview) { * @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'] - const isImage = fileType.startsWith('image') let promise - if (isImage) { - promise = fixRotate(file).then(base64data => { - return copyAttachment({type: 'base64', data: base64data, sourceFilePath: filePath}, storageKey, noteKey) - }) + if (dropEvent.dataTransfer.files.length > 0) { + promise = Promise.all(Array.from(dropEvent.dataTransfer.files).map(file => { + const filePath = file.path + const fileType = file.type // EX) 'image/gif' or 'text/html' + if (fileType.startsWith('image')) { + if (fileType === 'image/gif' || fileType === 'image/svg+xml') { + return copyAttachment(filePath, storageKey, noteKey).then(fileName => ({ + fileName, + title: path.basename(filePath), + isImage: true + })) + } else { + return getOrientation(file) + .then((orientation) => { + if (orientation === -1) { // The image rotation is correct and does not need adjustment + return copyAttachment(filePath, storageKey, noteKey) + } else { + return fixRotate(file).then(data => copyAttachment({ + type: 'base64', + data: data, + sourceFilePath: filePath + }, storageKey, noteKey)) + } + }) + .then(fileName => + ({ + fileName, + title: path.basename(filePath), + isImage: true + }) + ) + } + } else { + return copyAttachment(filePath, storageKey, noteKey).then(fileName => ({ + fileName, + title: path.basename(filePath), + isImage: false + })) + } + })) } else { - promise = copyAttachment(filePath, storageKey, noteKey) + let imageURL = dropEvent.dataTransfer.getData('text/plain') + + if (!imageURL) { + const match = /]*[\s"']src="([^"]+)"/.exec(dropEvent.dataTransfer.getData('text/html')) + if (match) { + imageURL = match[1] + } + } + + if (!imageURL) { + return + } + + promise = Promise.all([getImage(imageURL) + .then(image => { + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + canvas.width = image.width + canvas.height = image.height + context.drawImage(image, 0, 0) + + return copyAttachment({ + type: 'base64', + data: canvas.toDataURL(), + sourceFilePath: imageURL + }, storageKey, noteKey) + }) + .then(fileName => ({ + fileName, + title: imageURL, + isImage: true + })) + ]) } - promise.then((fileName) => { - const imageMd = generateAttachmentMarkdown(originalFileName, path.join(STORAGE_FOLDER_PLACEHOLDER, noteKey, fileName), isImage) - codeEditor.insertAttachmentMd(imageMd) + + promise.then(files => { + const attachments = files.filter(file => !!file).map(file => generateAttachmentMarkdown(file.title, path.join(STORAGE_FOLDER_PLACEHOLDER, noteKey, file.fileName), file.isImage)) + + codeEditor.insertAttachmentMd(attachments.join('\n')) }) } @@ -287,7 +370,7 @@ function handleAttachmentDrop (codeEditor, storageKey, noteKey, dropEvent) { * @param {String} noteKey Key of the current note * @param {DataTransferItem} dataTransferItem Part of the past-event */ -function handlePastImageEvent (codeEditor, storageKey, noteKey, dataTransferItem) { +function handlePasteImageEvent (codeEditor, storageKey, noteKey, dataTransferItem) { if (!codeEditor) { throw new Error('codeEditor has to be given') } @@ -331,7 +414,7 @@ function handlePastImageEvent (codeEditor, storageKey, noteKey, dataTransferItem * @param {String} noteKey Key of the current note * @param {NativeImage} image The native image */ -function handlePastNativeImage (codeEditor, storageKey, noteKey, image) { +function handlePasteNativeImage (codeEditor, storageKey, noteKey, image) { if (!codeEditor) { throw new Error('codeEditor has to be given') } @@ -388,6 +471,54 @@ function getAbsolutePathsOfAttachmentsInContent (markdownContent, storagePath) { return result } +/** + * @description Copies the attachments to the storage folder and returns the mardown content it should be replaced with + * @param {String} markDownContent content in which the attachment paths should be found + * @param {String} filepath The path of the file with attachments to import + * @param {String} storageKey Storage key of the destination storage + * @param {String} noteKey Key of the current note. Will be used as subfolder in :storage + */ +function importAttachments (markDownContent, filepath, storageKey, noteKey) { + return new Promise((resolve, reject) => { + const nameRegex = /(!\[.*?]\()(.+?\..+?)(\))/g + let attachPath = nameRegex.exec(markDownContent) + const promiseArray = [] + const attachmentPaths = [] + const groupIndex = 2 + + while (attachPath) { + let attachmentPath = attachPath[groupIndex] + attachmentPaths.push(attachmentPath) + attachmentPath = path.isAbsolute(attachmentPath) ? attachmentPath : path.join(path.dirname(filepath), attachmentPath) + promiseArray.push(this.copyAttachment(attachmentPath, storageKey, noteKey)) + attachPath = nameRegex.exec(markDownContent) + } + + let numResolvedPromises = 0 + + if (promiseArray.length === 0) { + resolve(markDownContent) + } + + for (let j = 0; j < promiseArray.length; j++) { + promiseArray[j] + .then((fileName) => { + const newPath = path.join(STORAGE_FOLDER_PLACEHOLDER, noteKey, fileName) + markDownContent = markDownContent.replace(attachmentPaths[j], newPath) + }) + .catch((e) => { + console.error('File does not exist in path: ' + attachmentPaths[j]) + }) + .finally(() => { + numResolvedPromises++ + if (numResolvedPromises === promiseArray.length) { + resolve(markDownContent) + } + }) + } + }) +} + /** * @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. @@ -429,7 +560,14 @@ function replaceNoteKeyWithNewNoteKey (noteContent, oldNoteKey, newNoteKey) { * @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) + return input.replace(new RegExp('/?' + STORAGE_FOLDER_PLACEHOLDER + '.*?("|])', 'g'), function (match) { + const temp = match + .replace(new RegExp(mdurl.encode(path.win32.sep), 'g'), path.sep) + .replace(new RegExp(mdurl.encode(path.posix.sep), 'g'), path.sep) + .replace(new RegExp(escapeStringRegexp(path.win32.sep), 'g'), path.sep) + .replace(new RegExp(escapeStringRegexp(path.posix.sep), 'g'), path.sep) + return temp.replace(new RegExp(STORAGE_FOLDER_PLACEHOLDER + '(' + escapeStringRegexp(path.sep) + noteKey + ')?', 'g'), DESTINATION_FOLDER) + }) } /** @@ -483,11 +621,79 @@ function deleteAttachmentsNotPresentInNote (markdownContent, storageKey, noteKey } }) }) - } else { - console.info('Attachment folder ("' + attachmentFolder + '") did not exist..') } } +/** + * @description Get all existing attachments related to a specific note + including their status (in use or not) and their path. Return null if there're no attachment related to note or specified parametters are invalid + * @param markdownContent markdownContent of the current note + * @param storageKey StorageKey of the current note + * @param noteKey NoteKey of the currentNote + * @return {Promise>} Promise returning the + list of attachments with their properties */ +function getAttachmentsPathAndStatus (markdownContent, storageKey, noteKey) { + if (storageKey == null || noteKey == null || markdownContent == null) { + return null + } + 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)) { + return new Promise((resolve, reject) => { + fs.readdir(attachmentFolder, (err, files) => { + if (err) { + console.error('Error reading directory "' + attachmentFolder + '". Error:') + console.error(err) + reject(err) + return + } + const attachments = [] + for (const file of files) { + const absolutePathOfFile = path.join(targetStorage.path, DESTINATION_FOLDER, noteKey, file) + if (!attachmentsInNoteOnlyFileNames.includes(file)) { + attachments.push({ path: absolutePathOfFile, isInUse: false }) + } else { + attachments.push({ path: absolutePathOfFile, isInUse: true }) + } + } + resolve(attachments) + }) + }) + } else { + return null + } +} + +/** + * @description Remove all specified attachment paths + * @param attachments attachment paths + * @return {Promise} Promise after all attachments are removed */ +function removeAttachmentsByPaths (attachments) { + const promises = [] + for (const attachment of attachments) { + const promise = new Promise((resolve, reject) => { + fs.unlink(attachment, (err) => { + if (err) { + console.error('Could not delete "%s"', attachment) + console.error(err) + reject(err) + return + } + resolve() + }) + }) + promises.push(promise) + } + return Promise.all(promises) +} + /** * 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. @@ -584,13 +790,16 @@ module.exports = { fixLocalURLS, generateAttachmentMarkdown, handleAttachmentDrop, - handlePastImageEvent, - handlePastNativeImage, + handlePasteImageEvent, + handlePasteNativeImage, getAttachmentsInMarkdownContent, getAbsolutePathsOfAttachmentsInContent, + importAttachments, removeStorageAndNoteReferences, + removeAttachmentsByPaths, deleteAttachmentFolder, deleteAttachmentsNotPresentInNote, + getAttachmentsPathAndStatus, moveAttachments, cloneAttachments, isAttachmentLink, diff --git a/browser/main/lib/dataApi/createNoteFromUrl.js b/browser/main/lib/dataApi/createNoteFromUrl.js new file mode 100644 index 00000000..ead93f9e --- /dev/null +++ b/browser/main/lib/dataApi/createNoteFromUrl.js @@ -0,0 +1,86 @@ +const http = require('http') +const https = require('https') +const { createTurndownService } = require('../../../lib/turndown') +const createNote = require('./createNote') + +import { push } from 'connected-react-router' +import ee from 'browser/main/lib/eventEmitter' + +function validateUrl (str) { + if (/^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})).?)(?::\d{2,5})?(?:[/?#]\S*)?$/i.test(str)) { + return true + } else { + return false + } +} + +const ERROR_MESSAGES = { + ENOTFOUND: 'URL not found. Please check the URL, or your internet connection and try again.', + VALIDATION_ERROR: 'Please check if the URL follows this format: https://www.google.com', + UNEXPECTED: 'Unexpected error! Please check console for details!' +} + +function createNoteFromUrl (url, storage, folder, dispatch = null, location = null) { + return new Promise((resolve, reject) => { + const td = createTurndownService() + + if (!validateUrl(url)) { + reject({result: false, error: ERROR_MESSAGES.VALIDATION_ERROR}) + } + + const request = url.startsWith('https') ? https : http + + const req = request.request(url, (res) => { + let data = '' + + res.on('data', (chunk) => { + data += chunk + }) + + res.on('end', () => { + const markdownHTML = td.turndown(data) + + if (dispatch !== null) { + createNote(storage, { + type: 'MARKDOWN_NOTE', + folder: folder, + title: '', + content: markdownHTML + }) + .then((note) => { + const noteHash = note.key + dispatch({ + type: 'UPDATE_NOTE', + note: note + }) + dispatch(push({ + pathname: location.pathname, + query: {key: noteHash} + })) + ee.emit('list:jump', noteHash) + ee.emit('detail:focus') + resolve({result: true, error: null}) + }) + } else { + createNote(storage, { + type: 'MARKDOWN_NOTE', + folder: folder, + title: '', + content: markdownHTML + }).then((note) => { + resolve({result: true, note, error: null}) + }) + } + }) + }) + + req.on('error', (e) => { + console.error('error in parsing URL', e) + reject({result: false, error: ERROR_MESSAGES[e.code] || ERROR_MESSAGES.UNEXPECTED}) + }) + + req.end() + }) +} + +module.exports = createNoteFromUrl diff --git a/browser/main/lib/dataApi/deleteFolder.js b/browser/main/lib/dataApi/deleteFolder.js index 0c7486f5..5ccc1414 100644 --- a/browser/main/lib/dataApi/deleteFolder.js +++ b/browser/main/lib/dataApi/deleteFolder.js @@ -3,7 +3,6 @@ const path = require('path') const resolveStorageData = require('./resolveStorageData') const resolveStorageNotes = require('./resolveStorageNotes') const CSON = require('@rokt33r/season') -const sander = require('sander') const { findStorage } = require('browser/lib/findStorage') const deleteSingleNote = require('./deleteNote') diff --git a/browser/main/lib/dataApi/exportFolder.js b/browser/main/lib/dataApi/exportFolder.js index 771f77dc..8f15b147 100644 --- a/browser/main/lib/dataApi/exportFolder.js +++ b/browser/main/lib/dataApi/exportFolder.js @@ -43,19 +43,18 @@ function exportFolder (storageKey, folderKey, fileType, exportDir) { .then(function exportNotes (data) { const { storage, notes } = data - notes + return Promise.all(notes .filter(note => note.folder === folderKey && note.isTrashed === false && note.type === 'MARKDOWN_NOTE') - .forEach(note => { + .map(note => { const notePath = path.join(exportDir, `${filenamify(note.title, {replacement: '_'})}.${fileType}`) - exportNote(note.key, storage.path, note.content, notePath, null) + return exportNote(note.key, storage.path, note.content, notePath, null) }) - - return { + ).then(() => ({ storage, folderKey, fileType, exportDir - } + })) }) } diff --git a/browser/main/lib/dataApi/exportNote.js b/browser/main/lib/dataApi/exportNote.js index b358e548..42e1fa56 100755 --- a/browser/main/lib/dataApi/exportNote.js +++ b/browser/main/lib/dataApi/exportNote.js @@ -43,14 +43,17 @@ function exportNote (nodeKey, storageKey, noteContent, targetPath, outputFormatt ) if (outputFormatter) { - exportedData = outputFormatter(exportedData, exportTasks) + exportedData = outputFormatter(exportedData, exportTasks, targetPath) + } else { + exportedData = Promise.resolve(exportedData) } const tasks = prepareTasks(exportTasks, storagePath, path.dirname(targetPath)) return Promise.all(tasks.map((task) => copyFile(task.src, task.dst))) - .then(() => { - return saveToFile(exportedData, targetPath) + .then(() => exportedData) + .then(data => { + return saveToFile(data, targetPath) }).catch((err) => { rollbackExport(tasks) throw err diff --git a/browser/main/lib/dataApi/index.js b/browser/main/lib/dataApi/index.js index 92be6b93..6e88bbf9 100644 --- a/browser/main/lib/dataApi/index.js +++ b/browser/main/lib/dataApi/index.js @@ -11,6 +11,7 @@ const dataApi = { exportFolder: require('./exportFolder'), exportStorage: require('./exportStorage'), createNote: require('./createNote'), + createNoteFromUrl: require('./createNoteFromUrl'), updateNote: require('./updateNote'), deleteNote: require('./deleteNote'), moveNote: require('./moveNote'), diff --git a/browser/main/lib/dataApi/init.js b/browser/main/lib/dataApi/init.js index 7f81e90b..0dbcc182 100644 --- a/browser/main/lib/dataApi/init.js +++ b/browser/main/lib/dataApi/init.js @@ -4,6 +4,7 @@ const resolveStorageData = require('./resolveStorageData') const resolveStorageNotes = require('./resolveStorageNotes') const consts = require('browser/lib/consts') const path = require('path') +const fs = require('fs') const CSON = require('@rokt33r/season') /** * @return {Object} all storages and notes @@ -19,11 +20,14 @@ const CSON = require('@rokt33r/season') * 2. legacy * 3. empty directory */ + function init () { const fetchStorages = function () { let rawStorages try { rawStorages = JSON.parse(window.localStorage.getItem('storages')) + // Remove storages who's location is inaccesible. + rawStorages = rawStorages.filter(storage => fs.existsSync(storage.path)) if (!_.isArray(rawStorages)) throw new Error('Cached data is not valid.') } catch (e) { console.warn('Failed to parse cached data from localStorage', e) @@ -36,6 +40,7 @@ function init () { const fetchNotes = function (storages) { const findNotesFromEachStorage = storages + .filter(storage => fs.existsSync(storage.path)) .map((storage) => { return resolveStorageNotes(storage) .then((notes) => { @@ -51,7 +56,11 @@ function init () { } }) if (unknownCount > 0) { - CSON.writeFileSync(path.join(storage.path, 'boostnote.json'), _.pick(storage, ['folders', 'version'])) + try { + CSON.writeFileSync(path.join(storage.path, 'boostnote.json'), _.pick(storage, ['folders', 'version'])) + } catch (e) { + console.log('Error writting boostnote.json: ' + e + ' from init.js') + } } return notes }) diff --git a/browser/main/lib/dataApi/moveNote.js b/browser/main/lib/dataApi/moveNote.js index 2d306cdf..c38968cb 100644 --- a/browser/main/lib/dataApi/moveNote.js +++ b/browser/main/lib/dataApi/moveNote.js @@ -1,7 +1,6 @@ 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') diff --git a/browser/main/lib/modal.js b/browser/main/lib/modal.js index 7a7a9c8c..955cb5c8 100644 --- a/browser/main/lib/modal.js +++ b/browser/main/lib/modal.js @@ -1,7 +1,7 @@ import React from 'react' import { Provider } from 'react-redux' import ReactDOM from 'react-dom' -import store from '../store' +import { store } from '../store' class ModalBase extends React.Component { constructor (props) { diff --git a/browser/main/lib/shortcut.js b/browser/main/lib/shortcut.js index 93e33c9b..3165606a 100644 --- a/browser/main/lib/shortcut.js +++ b/browser/main/lib/shortcut.js @@ -6,5 +6,8 @@ module.exports = { }, 'deleteNote': () => { ee.emit('hotkey:deletenote') + }, + 'toggleMenuBar': () => { + ee.emit('menubar:togglemenubar') } } diff --git a/browser/main/modals/CreateFolderModal.js b/browser/main/modals/CreateFolderModal.js index b061b0f3..b48d6e42 100644 --- a/browser/main/modals/CreateFolderModal.js +++ b/browser/main/modals/CreateFolderModal.js @@ -3,7 +3,7 @@ import React from 'react' import CSSModules from 'browser/lib/CSSModules' import styles from './CreateFolderModal.styl' import dataApi from 'browser/main/lib/dataApi' -import store from 'browser/main/store' +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' diff --git a/browser/main/modals/CreateMarkdownFromURLModal.js b/browser/main/modals/CreateMarkdownFromURLModal.js new file mode 100644 index 00000000..31988059 --- /dev/null +++ b/browser/main/modals/CreateMarkdownFromURLModal.js @@ -0,0 +1,118 @@ +import PropTypes from 'prop-types' +import React from 'react' +import CSSModules from 'browser/lib/CSSModules' +import styles from './CreateMarkdownFromURLModal.styl' +import dataApi from 'browser/main/lib/dataApi' +import ModalEscButton from 'browser/components/ModalEscButton' +import i18n from 'browser/lib/i18n' + +class CreateMarkdownFromURLModal extends React.Component { + constructor (props) { + super(props) + + this.state = { + name: '', + showerror: false, + errormessage: '' + } + } + + componentDidMount () { + this.refs.name.focus() + this.refs.name.select() + } + + handleCloseButtonClick (e) { + this.props.close() + } + + handleChange (e) { + this.setState({ + name: this.refs.name.value + }) + } + + handleKeyDown (e) { + if (e.keyCode === 27) { + this.props.close() + } + } + + handleInputKeyDown (e) { + switch (e.keyCode) { + case 13: + this.confirm() + } + } + + handleConfirmButtonClick (e) { + this.confirm() + } + + showError (message) { + this.setState({ + showerror: true, + errormessage: message + }) + } + + hideError () { + this.setState({ + showerror: false, + errormessage: '' + }) + } + + confirm () { + this.hideError() + const { storage, folder, dispatch, location } = this.props + + dataApi.createNoteFromUrl(this.state.name, storage, folder, dispatch, location).then((result) => { + this.props.close() + }).catch((result) => { + this.showError(result.error) + }) + } + + render () { + return ( +
this.handleKeyDown(e)} + > +
+
{i18n.__('Import Markdown From URL')}
+
+ this.handleCloseButtonClick(e)} /> +
+
+
{i18n.__('Insert URL Here')}
+ this.handleChange(e)} + onKeyDown={(e) => this.handleInputKeyDown(e)} + /> +
+ +
{this.state.errormessage}
+
+
+ ) + } +} + +CreateMarkdownFromURLModal.propTypes = { + storage: PropTypes.string, + folder: PropTypes.string, + dispatch: PropTypes.func, + location: PropTypes.shape({ + pathname: PropTypes.string + }) +} + +export default CSSModules(CreateMarkdownFromURLModal, styles) diff --git a/browser/main/modals/CreateMarkdownFromURLModal.styl b/browser/main/modals/CreateMarkdownFromURLModal.styl new file mode 100644 index 00000000..5e59e465 --- /dev/null +++ b/browser/main/modals/CreateMarkdownFromURLModal.styl @@ -0,0 +1,160 @@ +.root + modal() + width 500px + height 270px + overflow hidden + position relative + +.header + height 80px + margin-bottom 10px + margin-top 20px + font-size 18px + line-height 50px + background-color $ui-backgroundColor + color $ui-text-color + +.title + font-size 36px + font-weight 600 + +.control-folder-label + text-align left + font-size 14px + color $ui-text-color + +.control-folder-input + display block + height 40px + width 490px + padding 0 5px + margin 10px 0 + border 1px solid $ui-input--create-folder-modal + border-radius 2px + background-color transparent + outline none + vertical-align middle + font-size 16px + &:disabled + background-color $ui-input--disabled-backgroundColor + &:focus, &:active + border-color $ui-active-color + +.control-confirmButton + display block + height 35px + width 140px + border none + border-radius 2px + padding 0 25px + margin 20px auto + font-size 14px + colorPrimaryButton() + +body[data-theme="dark"] + .root + modalDark() + width 500px + height 270px + overflow hidden + position relative + + .header + background-color transparent + border-color $ui-dark-borderColor + color $ui-dark-text-color + + .control-folder-label + color $ui-dark-text-color + + .control-folder-input + border 1px solid $ui-input--create-folder-modal + color white + + .description + color $ui-inactive-text-color + + .control-confirmButton + colorDarkPrimaryButton() + +body[data-theme="solarized-dark"] + .root + modalSolarizedDark() + width 500px + height 270px + overflow hidden + position relative + + .header + background-color transparent + border-color $ui-dark-borderColor + color $ui-solarized-dark-text-color + + .control-folder-label + color $ui-solarized-dark-text-color + + .control-folder-input + border 1px solid $ui-input--create-folder-modal + color white + + .description + color $ui-inactive-text-color + + .control-confirmButton + colorSolarizedDarkPrimaryButton() + +.error + text-align center + color #F44336 + +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() + +body[data-theme="dracula"] + .root + modalDracula() + width 500px + height 270px + overflow hidden + position relative + + .header + background-color transparent + border-color $ui-dracula-borderColor + color $ui-dracula-text-color + + .control-folder-label + color $ui-dracula-text-color + + .control-folder-input + border 1px solid $ui-input--create-folder-modal + color white + + .description + color $ui-inactive-text-color + + .control-confirmButton + colorDraculaPrimaryButton() diff --git a/browser/main/modals/NewNoteModal.js b/browser/main/modals/NewNoteModal.js index a190602c..476fa252 100644 --- a/browser/main/modals/NewNoteModal.js +++ b/browser/main/modals/NewNoteModal.js @@ -3,12 +3,15 @@ import CSSModules from 'browser/lib/CSSModules' import styles from './NewNoteModal.styl' import ModalEscButton from 'browser/components/ModalEscButton' import i18n from 'browser/lib/i18n' +import { openModal } from 'browser/main/lib/modal' +import CreateMarkdownFromURLModal from '../modals/CreateMarkdownFromURLModal' import { createMarkdownNote, createSnippetNote } from 'browser/lib/newNote' +import queryString from 'query-string' class NewNoteModal extends React.Component { constructor (props) { super(props) - + this.lock = false this.state = {} } @@ -20,13 +23,29 @@ class NewNoteModal extends React.Component { this.props.close() } - handleMarkdownNoteButtonClick (e) { - const { storage, folder, dispatch, location, params, config } = this.props - createMarkdownNote(storage, folder, dispatch, location, params, config).then(() => { - setTimeout(this.props.close, 200) + handleCreateMarkdownFromUrlClick (e) { + this.props.close() + + const { storage, folder, dispatch, location } = this.props + openModal(CreateMarkdownFromURLModal, { + storage: storage, + folder: folder, + dispatch, + location }) } + handleMarkdownNoteButtonClick (e) { + const { storage, folder, dispatch, location, config } = this.props + const params = location.search !== '' && queryString.parse(location.search) + if (!this.lock) { + this.lock = true + createMarkdownNote(storage, folder, dispatch, location, params, config).then(() => { + setTimeout(this.props.close, 200) + }) + } + } + handleMarkdownNoteButtonKeyDown (e) { if (e.keyCode === 9) { e.preventDefault() @@ -35,10 +54,14 @@ class NewNoteModal extends React.Component { } handleSnippetNoteButtonClick (e) { - const { storage, folder, dispatch, location, params, config } = this.props - createSnippetNote(storage, folder, dispatch, location, params, config).then(() => { - setTimeout(this.props.close, 200) - }) + const { storage, folder, dispatch, location, config } = this.props + const params = location.search !== '' && queryString.parse(location.search) + if (!this.lock) { + this.lock = true + createSnippetNote(storage, folder, dispatch, location, params, config).then(() => { + setTimeout(this.props.close, 200) + }) + } } handleSnippetNoteButtonKeyDown (e) { @@ -106,10 +129,8 @@ class NewNoteModal extends React.Component {
-
- {i18n.__('Tab to switch format')} -
- +
{i18n.__('Tab to switch format')}
+
this.handleCreateMarkdownFromUrlClick(e)}>Or, create a new markdown note from a URL
) } diff --git a/browser/main/modals/NewNoteModal.styl b/browser/main/modals/NewNoteModal.styl index c82b9376..ff0052bd 100644 --- a/browser/main/modals/NewNoteModal.styl +++ b/browser/main/modals/NewNoteModal.styl @@ -19,6 +19,7 @@ .control padding 25px 0px text-align center + display: flex .control-button width 240px @@ -47,6 +48,12 @@ text-align center margin-bottom 25px +.from-url + color $ui-inactive-text-color + text-align center + margin-bottom 25px + cursor pointer + body[data-theme="dark"] .root modalDark() @@ -61,7 +68,7 @@ body[data-theme="dark"] &:focus colorDarkPrimaryButton() - .description + .description, .from-url color $ui-inactive-text-color body[data-theme="solarized-dark"] @@ -78,7 +85,7 @@ body[data-theme="solarized-dark"] &:focus colorDarkPrimaryButton() - .description + .description, .from-url color $ui-solarized-dark-text-color body[data-theme="monokai"] @@ -95,7 +102,7 @@ body[data-theme="monokai"] &:focus colorDarkPrimaryButton() - .description + .description, .from-url color $ui-monokai-text-color body[data-theme="dracula"] diff --git a/browser/main/modals/PreferencesModal/Blog.js b/browser/main/modals/PreferencesModal/Blog.js index 2c93fb29..4d59bea1 100644 --- a/browser/main/modals/PreferencesModal/Blog.js +++ b/browser/main/modals/PreferencesModal/Blog.js @@ -2,7 +2,7 @@ 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 { store } from 'browser/main/store' import PropTypes from 'prop-types' import _ from 'lodash' import i18n from 'browser/lib/i18n' diff --git a/browser/main/modals/PreferencesModal/ConfigTab.styl b/browser/main/modals/PreferencesModal/ConfigTab.styl index 255165ce..0e22833d 100644 --- a/browser/main/modals/PreferencesModal/ConfigTab.styl +++ b/browser/main/modals/PreferencesModal/ConfigTab.styl @@ -18,6 +18,14 @@ margin-bottom 15px margin-top 30px +.group-header--sub + @extend .group-header + margin-bottom 10px + +.group-header2--sub + @extend .group-header2 + margin-bottom 10px + .group-section margin-bottom 20px display flex @@ -148,10 +156,12 @@ body[data-theme="dark"] color $ui-dark-text-color .group-header + .group-header--sub color $ui-dark-text-color border-color $ui-dark-borderColor .group-header2 + .group-header2--sub color $ui-dark-text-color .group-section-control-input @@ -176,10 +186,12 @@ body[data-theme="solarized-dark"] color $ui-solarized-dark-text-color .group-header + .group-header--sub color $ui-solarized-dark-text-color border-color $ui-solarized-dark-borderColor .group-header2 + .group-header2--sub color $ui-solarized-dark-text-color .group-section-control-input @@ -203,10 +215,12 @@ body[data-theme="monokai"] color $ui-monokai-text-color .group-header + .group-header--sub color $ui-monokai-text-color border-color $ui-monokai-borderColor .group-header2 + .group-header2--sub color $ui-monokai-text-color .group-section-control-input @@ -230,10 +244,12 @@ body[data-theme="dracula"] color $ui-dracula-text-color .group-header + .group-header--sub color $ui-dracula-text-color border-color $ui-dracula-borderColor .group-header2 + .group-header2--sub color $ui-dracula-text-color .group-section-control-input diff --git a/browser/main/modals/PreferencesModal/Crowdfunding.js b/browser/main/modals/PreferencesModal/Crowdfunding.js index f6389cd8..56bb6e34 100644 --- a/browser/main/modals/PreferencesModal/Crowdfunding.js +++ b/browser/main/modals/PreferencesModal/Crowdfunding.js @@ -22,19 +22,17 @@ class Crowdfunding extends React.Component { render () { return (
-
{i18n.__('Crowdfunding')}
+
{i18n.__('Crowdfunding')}

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


{i18n.__('We launched IssueHunt which is an issue-based crowdfunding / sourcing platform for open source projects.')}

{i18n.__('Anyone can put a bounty on not only a bug but also on OSS feature requests listed on IssueHunt. Collected funds will be distributed to project owners and contributors.')}

-
-

{i18n.__('### Sustainable Open Source Ecosystem')}

+
{i18n.__('Sustainable Open Source Ecosystem')}

{i18n.__('We discussed about open-source ecosystem and IssueHunt concept with the Boostnote team repeatedly. We actually also discussed with Matz who father of Ruby.')}

{i18n.__('The original reason why we made IssueHunt was to reward our contributors of Boostnote project. We’ve got tons of Github stars and hundred of contributors in two years.')}

{i18n.__('We thought that it will be nice if we can pay reward for our contributors.')}

-
-

{i18n.__('### We believe Meritocracy')}

-

{i18n.__('We think developers who has skill and did great things must be rewarded properly.')}

+
{i18n.__('We believe Meritocracy')}
+

{i18n.__('We think developers who have skills and do great things must be rewarded properly.')}

{i18n.__('OSS projects are used in everywhere on the internet, but no matter how they great, most of owners of those projects need to have another job to sustain their living.')}

{i18n.__('It sometimes looks like exploitation.')}

{i18n.__('We’ve realized IssueHunt could enhance sustainability of open-source ecosystem.')}

diff --git a/browser/main/modals/PreferencesModal/Crowdfunding.styl b/browser/main/modals/PreferencesModal/Crowdfunding.styl index 6d72290b..d1d6fc9f 100644 --- a/browser/main/modals/PreferencesModal/Crowdfunding.styl +++ b/browser/main/modals/PreferencesModal/Crowdfunding.styl @@ -1,14 +1,8 @@ -@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 p font-size 16px + line-height 1.4 .cf-link height 35px diff --git a/browser/main/modals/PreferencesModal/FolderItem.js b/browser/main/modals/PreferencesModal/FolderItem.js index dc9082b9..648db4e6 100644 --- a/browser/main/modals/PreferencesModal/FolderItem.js +++ b/browser/main/modals/PreferencesModal/FolderItem.js @@ -4,7 +4,7 @@ import CSSModules from 'browser/lib/CSSModules' import ReactDOM from 'react-dom' import styles from './FolderItem.styl' import dataApi from 'browser/main/lib/dataApi' -import store from 'browser/main/store' +import { store } from 'browser/main/store' import { SketchPicker } from 'react-color' import { SortableElement, SortableHandle } from 'react-sortable-hoc' import i18n from 'browser/lib/i18n' @@ -225,7 +225,7 @@ class FolderItem extends React.Component {
- {folder.name} + {folder.name} ({folder.key})
@@ -288,10 +288,10 @@ class Handle extends React.Component { class SortableFolderItemComponent extends React.Component { render () { - const StyledHandle = CSSModules(Handle, this.props.styles) + const StyledHandle = CSSModules(Handle, styles) const DragHandle = SortableHandle(StyledHandle) - const StyledFolderItem = CSSModules(FolderItem, this.props.styles) + const StyledFolderItem = CSSModules(FolderItem, styles) return (
diff --git a/browser/main/modals/PreferencesModal/FolderItem.styl b/browser/main/modals/PreferencesModal/FolderItem.styl index 2ded3ada..618e9bc4 100644 --- a/browser/main/modals/PreferencesModal/FolderItem.styl +++ b/browser/main/modals/PreferencesModal/FolderItem.styl @@ -62,7 +62,7 @@ .folderItem-right-button vertical-align middle height 25px - margin-top 2.5px + margin-top 2px colorDefaultButton() border-radius 2px border $ui-border diff --git a/browser/main/modals/PreferencesModal/FolderList.js b/browser/main/modals/PreferencesModal/FolderList.js index e7cc6f94..674026c5 100644 --- a/browser/main/modals/PreferencesModal/FolderList.js +++ b/browser/main/modals/PreferencesModal/FolderList.js @@ -3,7 +3,7 @@ import React from 'react' import CSSModules from 'browser/lib/CSSModules' import dataApi from 'browser/main/lib/dataApi' import styles from './FolderList.styl' -import store from 'browser/main/store' +import { store } from 'browser/main/store' import FolderItem from './FolderItem' import { SortableContainer } from 'react-sortable-hoc' import i18n from 'browser/lib/i18n' @@ -22,7 +22,7 @@ class FolderList extends React.Component { }) return ( -
+
{folderList.length > 0 ? folderList :
{i18n.__('No Folders')}
diff --git a/browser/main/modals/PreferencesModal/HotkeyTab.js b/browser/main/modals/PreferencesModal/HotkeyTab.js index a0f6a739..9c4f5655 100644 --- a/browser/main/modals/PreferencesModal/HotkeyTab.js +++ b/browser/main/modals/PreferencesModal/HotkeyTab.js @@ -3,7 +3,7 @@ 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 { store } from 'browser/main/store' import _ from 'lodash' import i18n from 'browser/lib/i18n' @@ -76,12 +76,16 @@ class HotkeyTab extends React.Component { handleHotkeyChange (e) { const { config } = this.state - config.hotkey = { + config.hotkey = Object.assign({}, config.hotkey, { toggleMain: this.refs.toggleMain.value, toggleMode: this.refs.toggleMode.value, deleteNote: this.refs.deleteNote.value, - pasteSmartly: this.refs.pasteSmartly.value - } + pasteSmartly: this.refs.pasteSmartly.value, + prettifyMarkdown: this.refs.prettifyMarkdown.value, + toggleMenuBar: this.refs.toggleMenuBar.value, + insertDate: this.refs.insertDate.value, + insertDateTime: this.refs.insertDateTime.value + }) this.setState({ config }) @@ -128,6 +132,17 @@ class HotkeyTab extends React.Component { />
+
+
{i18n.__('Show/Hide Menu Bar')}
+
+ this.handleHotkeyChange(e)} + ref='toggleMenuBar' + value={config.hotkey.toggleMenuBar} + type='text' + /> +
+
{i18n.__('Toggle Editor Mode')}
@@ -151,7 +166,7 @@ class HotkeyTab extends React.Component {
-
{i18n.__('Paste Smartly')}
+
{i18n.__('Paste HTML')}
this.handleHotkeyChange(e)} @@ -161,6 +176,38 @@ class HotkeyTab extends React.Component { />
+
+
{i18n.__('Prettify Markdown')}
+
+ this.handleHotkeyChange(e)} + ref='prettifyMarkdown' + value={config.hotkey.prettifyMarkdown} + type='text' /> +
+
+
+
{i18n.__('Insert Current Date')}
+
+ +
+
+
+
{i18n.__('Insert Current Date and Time')}
+
+ +
+
+
{i18n.__('Attachment storage')}
+

+ Unused attachments size: {humanFileSize(totalUnusedAttachmentsSize)} ({totalUnusedAttachments} items) +

+

+ In use attachments size: {humanFileSize(totalInuseAttachmentsSize)} ({totalInuseAttachments} items) +

+

+ Total attachments size: {humanFileSize(totalAttachmentsSize)} ({totalAttachments} items) +

+
) } diff --git a/browser/main/modals/PreferencesModal/StoragesTab.styl b/browser/main/modals/PreferencesModal/StoragesTab.styl index 9a1a0ef8..fbfa89e6 100644 --- a/browser/main/modals/PreferencesModal/StoragesTab.styl +++ b/browser/main/modals/PreferencesModal/StoragesTab.styl @@ -1,8 +1,4 @@ -@import('./Tab') - -.root - padding 15px - color $ui-text-color +@import('./ConfigTab') .list margin-bottom 15px @@ -37,6 +33,17 @@ colorDefaultButton() font-size $tab--button-font-size border-radius 2px +.list-attachment-label + margin-bottom 10px + color $ui-text-color +.list-attachement-clear-button + height 30px + border none + border-top-right-radius 2px + border-bottom-right-radius 2px + colorPrimaryButton() + vertical-align middle + padding 0 20px .addStorage margin-bottom 15px @@ -158,8 +165,8 @@ body[data-theme="dark"] .addStorage-body-control-cancelButton colorDarkDefaultButton() border-color $ui-dark-borderColor - - + .list-attachement-clear-button + colorDarkPrimaryButton() body[data-theme="solarized-dark"] .root @@ -198,6 +205,8 @@ body[data-theme="solarized-dark"] .addStorage-body-control-cancelButton colorDarkDefaultButton() border-color $ui-solarized-dark-borderColor + .list-attachement-clear-button + colorSolarizedDarkPrimaryButton() body[data-theme="monokai"] .root @@ -236,6 +245,8 @@ body[data-theme="monokai"] .addStorage-body-control-cancelButton colorDarkDefaultButton() border-color $ui-monokai-borderColor + .list-attachement-clear-button + colorMonokaiPrimaryButton() body[data-theme="dracula"] .root @@ -273,4 +284,6 @@ body[data-theme="dracula"] colorDarkPrimaryButton() .addStorage-body-control-cancelButton colorDarkDefaultButton() - border-color $ui-dracula-borderColor \ No newline at end of file + border-color $ui-dracula-borderColor + .list-attachement-clear-button + colorDraculaPrimaryButton() \ No newline at end of file diff --git a/browser/main/modals/PreferencesModal/UiTab.js b/browser/main/modals/PreferencesModal/UiTab.js index becd4f54..329dbfa4 100644 --- a/browser/main/modals/PreferencesModal/UiTab.js +++ b/browser/main/modals/PreferencesModal/UiTab.js @@ -3,7 +3,7 @@ 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 { store } from 'browser/main/store' import consts from 'browser/lib/consts' import ReactCodeMirror from 'react-codemirror' import CodeMirror from 'codemirror' @@ -30,7 +30,13 @@ class UiTab extends React.Component { componentDidMount () { CodeMirror.autoLoadMode(this.codeMirrorInstance.getCodeMirror(), 'javascript') CodeMirror.autoLoadMode(this.customCSSCM.getCodeMirror(), 'css') + CodeMirror.autoLoadMode(this.customMarkdownLintConfigCM.getCodeMirror(), 'javascript') + CodeMirror.autoLoadMode(this.prettierConfigCM.getCodeMirror(), 'javascript') + // Set CM editor Sizes this.customCSSCM.getCodeMirror().setSize('400px', '400px') + this.prettierConfigCM.getCodeMirror().setSize('400px', '400px') + this.customMarkdownLintConfigCM.getCodeMirror().setSize('400px', '200px') + this.handleSettingDone = () => { this.setState({UiAlert: { type: 'success', @@ -75,6 +81,7 @@ class UiTab extends React.Component { showTagsAlphabetically: this.refs.showTagsAlphabetically.checked, saveTagsAlphabetically: this.refs.saveTagsAlphabetically.checked, enableLiveNoteCounts: this.refs.enableLiveNoteCounts.checked, + showMenuBar: this.refs.showMenuBar.checked, disableDirectWrite: this.refs.uiD2w != null ? this.refs.uiD2w.checked : false @@ -88,6 +95,7 @@ class UiTab extends React.Component { enableRulers: this.refs.enableEditorRulers.value === 'true', rulers: this.refs.editorRulers.value.replace(/[^0-9,]/g, '').split(','), displayLineNumbers: this.refs.editorDisplayLineNumbers.checked, + lineWrapping: this.refs.editorLineWrapping.checked, switchPreview: this.refs.editorSwitchPreview.value, keyMap: this.refs.editorKeyMap.value, snippetDefaultLanguage: this.refs.editorSnippetDefaultLanguage.value, @@ -96,8 +104,15 @@ class UiTab extends React.Component { enableTableEditor: this.refs.enableTableEditor.checked, enableFrontMatterTitle: this.refs.enableFrontMatterTitle.checked, frontMatterTitleField: this.refs.frontMatterTitleField.value, + matchingPairs: this.refs.matchingPairs.value, + matchingTriples: this.refs.matchingTriples.value, + explodingPairs: this.refs.explodingPairs.value, spellcheck: this.refs.spellcheck.checked, - enableSmartPaste: this.refs.enableSmartPaste.checked + enableSmartPaste: this.refs.enableSmartPaste.checked, + enableMarkdownLint: this.refs.enableMarkdownLint.checked, + customMarkdownLintConfig: this.customMarkdownLintConfigCM.getCodeMirror().getValue(), + prettierConfig: this.prettierConfigCM.getCodeMirror().getValue(), + deleteUnusedAttachments: this.refs.deleteUnusedAttachments.checked }, preview: { fontSize: this.refs.previewFontSize.value, @@ -115,6 +130,7 @@ class UiTab extends React.Component { breaks: this.refs.previewBreaks.checked, smartArrows: this.refs.previewSmartArrows.checked, sanitize: this.refs.previewSanitize.value, + mermaidHTMLLabel: this.refs.previewMermaidHTMLLabel.checked, allowCustomCSS: this.refs.previewAllowCustomCSS.checked, lineThroughCheckbox: this.refs.lineThroughCheckbox.checked, customCSS: this.customCSSCM.getCodeMirror().getValue() @@ -124,8 +140,13 @@ class UiTab extends React.Component { const newCodemirrorTheme = this.refs.editorTheme.value if (newCodemirrorTheme !== codemirrorTheme) { - checkHighLight.setAttribute('href', `../node_modules/codemirror/theme/${newCodemirrorTheme.split(' ')[0]}.css`) + const theme = consts.THEMES.find(theme => theme.name === newCodemirrorTheme) + + if (theme) { + checkHighLight.setAttribute('href', theme.path) + } } + this.setState({ config: newConfig, codemirrorTheme: newCodemirrorTheme }, () => { const {ui, editor, preview} = this.props.config this.currentConfig = {ui, editor, preview} @@ -235,6 +256,16 @@ class UiTab extends React.Component {
+
+ +
+
+ +
+
@@ -575,6 +618,86 @@ class UiTab extends React.Component { {i18n.__('Enable spellcheck - Experimental feature!! :)')}
+
+ +
+ +
+
+ {i18n.__('Matching character pairs')} +
+
+ this.handleUIChange(e)} + type='text' + /> +
+
+ +
+
+ {i18n.__('Matching character triples')} +
+
+ this.handleUIChange(e)} + type='text' + /> +
+
+ +
+
+ {i18n.__('Exploding character pairs')} +
+
+ this.handleUIChange(e)} + type='text' + /> +
+
+
+
+ {i18n.__('Custom MarkdownLint Rules')} +
+
+ this.handleUIChange(e)} + checked={this.state.config.editor.enableMarkdownLint} + ref='enableMarkdownLint' + type='checkbox' + />  + {i18n.__('Enable MarkdownLint')} +
+ this.handleUIChange(e)} + ref={e => (this.customMarkdownLintConfigCM = e)} + value={config.editor.customMarkdownLintConfig} + options={{ + lineNumbers: true, + mode: 'application/json', + theme: codemirrorTheme, + lint: true, + gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'] + }} /> +
+
+
{i18n.__('Preview')}
@@ -603,6 +726,7 @@ class UiTab extends React.Component { />
+
{i18n.__('Code Block Theme')}
@@ -612,7 +736,7 @@ class UiTab extends React.Component { > { themes.map((theme) => { - return () + return () }) } @@ -705,6 +829,16 @@ class UiTab extends React.Component {
+
+ +
{i18n.__('LaTeX Inline Open Delimiter')} @@ -796,7 +930,27 @@ class UiTab extends React.Component {
- +
+
+ {i18n.__('Prettier Config')} +
+
+
+ this.handleUIChange(e)} + ref={e => (this.prettierConfigCM = e)} + value={config.editor.prettierConfig} + options={{ + lineNumbers: true, + mode: 'application/json', + lint: true, + theme: codemirrorTheme + }} /> +
+
+