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/.boostnoterc.sample b/.boostnoterc.sample index a7981f7f..2d581a48 100644 --- a/.boostnoterc.sample +++ b/.boostnoterc.sample @@ -22,7 +22,9 @@ "fontSize": "14", "lineNumber": true }, - "sortBy": "UPDATED_AT", + "sortBy": { + "default": "UPDATED_AT" + }, "sortTagsBy": "ALPHABETICAL", "ui": { "defaultNote": "ALWAYS_ASK", diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..8c5bd614 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Space indentation +[*] +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +# The indent size used in the `package.json` file cannot be changed +# https://github.com/npm/npm/pull/3180#issuecomment-16336516 +[{*.yml,*.yaml,package.json}] +indent_style = space +indent_size = 2 diff --git a/.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/.travis.yml b/.travis.yml index d9267f77..90548ee9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,10 @@ 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/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..9d1cc4ec --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,41 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "type": "node", + "request": "launch", + "name": "BoostNote Main", + "protocol": "inspector", + "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron", + "runtimeArgs": [ + "--remote-debugging-port=9223", + "--hot", + "${workspaceFolder}/index.js" + ], + "windows": { + "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd" + } + }, + { + "type": "chrome", + "request": "attach", + "name": "BoostNote Renderer", + "port": 9223, + "webRoot": "${workspaceFolder}", + "sourceMapPathOverrides": { + "webpack:///./~/*": "${webRoot}/node_modules/*", + "webpack:///*": "${webRoot}/*" + } + } + ], + "compounds": [ + { + "name": "BostNote All", + "configurations": ["BoostNote Main", "BoostNote Renderer"] + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..c6664225 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,27 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "Build Boostnote", + "group": "build", + "type": "npm", + "script": "watch", + "isBackground": true, + "presentation": { + "reveal": "always", + }, + "problemMatcher": { + "pattern":[ + { + "regexp": "^([^\\\\s].*)\\\\((\\\\d+,\\\\d+)\\\\):\\\\s*(.*)$", + "file": 1, + "location": 2, + "message": 3 + } + ] + } + } + ] +} \ No newline at end of file diff --git a/Backers.md b/Backers.md deleted file mode 100644 index 18d221bf..00000000 --- a/Backers.md +++ /dev/null @@ -1,72 +0,0 @@ -

Sponsors & Backers

- -Boostnote is an open source project. It's an independent project with its ongoing development made possible entirely thanks to the support by these awesome backers. If you'd like to join them, please consider: - -- [Become a backer or sponsor on Open Collective.](https://opencollective.com/boostnoteio) - ---- - -## Backers via OpenCollective - -### [Gold Sponsors / $1,000 per month](https://opencollective.com/boostnoteio/order/2259) -- Get your logo on our Readme.md on GitHub and the frontpage of https://boostnote.io/. - -### [Silver Sponsors / $250 per month](https://opencollective.com/boostnoteio/order/2257) -- Get your logo on our Readme.md on GitHub and the frontpage of https://boostnote.io/. - -### [Bronze Sponsors / $50 per month](https://opencollective.com/boostnoteio/order/2258) -- Get your name and Url (or E-mail) on Readme.md on GitHub. - -### [Backers3 / $10 per month](https://opencollective.com/boostnoteio/order/2176) -- [Ralph03](https://opencollective.com/ralph03) - -- [Nikolas Dan](https://opencollective.com/nikolas-dan) - -### [Backers2 / $5 per month](https://opencollective.com/boostnoteio/order/2175) -- [Yeojong Kim](https://twitter.com/yeojoy) - -- [Scotia Draven](https://opencollective.com/scotia-draven) - -- [A. J. Vargas](https://opencollective.com/aj-vargas) - -### [Backers1](https://opencollective.com/boostnoteio/order/2563) and One-time sponsors -- Ryosuke Tamura - $30 - -- tatoosh11 - $10 - -- Alexander Borovkov - $10 - -- spoonhoop - $5 - -- Drew Williams - $2 - -- Andy Shaw - $2 - -- mysafesky -$2 - ---- - -## Backers via Bountysource -https://salt.bountysource.com/teams/boostnote - -- Kuzz - $65 - -- Intense Raiden - $45 - -- ravy22 - $25 - -- trentpolack - $20 - -- hikariru - $10 - -- kolchan11 - $10 - -- RonWalker22 - $10 - -- hocchuc - $5 - -- Adam - $5 - -- Steve - $5 - -- evmin - $5 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/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md index f185492a..5554c4b8 100644 --- a/ISSUE_TEMPLATE.md +++ b/ISSUE_TEMPLATE.md @@ -1,15 +1,25 @@ # Current behavior # Expected behavior + + # Steps to reproduce + + 1. 2. 3. @@ -20,6 +30,6 @@ If your issue is regarding boostnote mobile, move to https://github.com/BoostIO/ - OS Version and name : \ No newline at end of file +Love Boostnote? Please consider supporting us on IssueHunt: +👉 https://issuehunt.io/repos/53266139 +--> 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/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..58df576a --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,36 @@ + +## Description + + +## Issue fixed + + + +## Type of changes + +- :white_circle: Bug fix (Change that fixed an issue) +- :white_circle: Breaking change (Change that can cause existing functionality to change) +- :white_circle: Improvement (Change that improves the code. Maybe performance or development improvement) +- :white_circle: Feature (Change that adds new functionality) +- :white_circle: Documentation change (Change that modifies documentation. Maybe typo fixes) + +## Checklist: + +- :white_circle: My code follows [the project code style](docs/code_style.md) +- :white_circle: I have written test for my code and it has been tested +- :white_circle: All existing tests have been passed +- :white_circle: I have attached a screenshot/video to visualize my change if possible diff --git a/browser/components/CodeEditor.js b/browser/components/CodeEditor.js index 7dfb6125..1abd15a9 100644 --- a/browser/components/CodeEditor.js +++ b/browser/components/CodeEditor.js @@ -2,26 +2,54 @@ 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 TextEditorInterface from 'browser/lib/TextEditorInterface' import eventEmitter from 'browser/main/lib/eventEmitter' import iconv from 'iconv-lite' -const { ipcRenderer } = require('electron') +import { isMarkdownTitleURL } from 'browser/lib/utils' +import styles from '../components/CodeEditor.styl' +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 {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' CodeMirror.modeURL = '../node_modules/codemirror/mode/%N/%N.js' -const defaultEditorFontFamily = ['Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', 'monospace'] 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') +} export default class CodeEditor extends React.Component { constructor (props) { super(props) - this.scrollHandler = _.debounce(this.handleScroll.bind(this), 100, {leading: false, trailing: true}) - this.changeHandler = (e) => this.handleChange(e) + this.scrollHandler = _.debounce(this.handleScroll.bind(this), 100, { + leading: false, + trailing: true + }) + this.changeHandler = (editor, changeObject) => this.handleChange(editor, changeObject) + this.highlightHandler = (editor, changeObject) => this.handleHighlight(editor, changeObject) this.focusHandler = () => { ipcRenderer.send('editor:focused', true) } @@ -37,15 +65,44 @@ export default class CodeEditor extends React.Component { } this.props.onBlur != null && this.props.onBlur(e) - const {storageKey, noteKey} = this.props - attachmentManagement.deleteAttachmentsNotPresentInNote(this.editor.getValue(), storageKey, noteKey) + const { + storageKey, + noteKey + } = this.props + attachmentManagement.deleteAttachmentsNotPresentInNote( + this.editor.getValue(), + storageKey, + noteKey + ) } - this.pasteHandler = (editor, e) => this.handlePaste(editor, e) - this.loadStyleHandler = (e) => { + this.pasteHandler = (editor, e) => { + e.preventDefault() + + this.handlePaste(editor, false) + } + this.loadStyleHandler = e => { this.editor.refresh() } 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() + + if (props.switchPreview !== 'RIGHTCLICK') { + this.contextMenuHandler = function (editor, event) { + const menu = buildEditorContextMenu(editor, event) + if (menu != null) { + setTimeout(() => menu.popup(remote.getCurrentWindow()), 30) + } + } + } + + this.editorActivityHandler = () => this.handleEditorActivity() + + this.turndownService = new TurndownService() } handleSearch (msg) { @@ -60,7 +117,10 @@ export default class CodeEditor extends React.Component { cm.addOverlay(component.searchState) function makeOverlay (query, style) { - query = new RegExp(query.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'), 'gi') + query = new RegExp( + query.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'), + 'gi' + ) return { token: function (stream) { query.lastIndex = stream.pos @@ -79,13 +139,133 @@ export default class CodeEditor extends React.Component { }) } - componentDidMount () { - const { rulers, enableRulers } = this.props - this.value = this.props.value + handleFormatTable () { + this.tableEditor.formatAll(options({ + textWidthOptions: {} + })) + } + handleEditorActivity () { + if (!this.textEditorInterface.transaction) { + this.updateTableEditorState() + } + } + + updateDefaultKeyMap () { + const { hotkey } = this.props + const self = this + const expandSnippet = snippetManager.expandSnippet + + this.defaultKeyMap = CodeMirror.normalizeKeyMap({ + Tab: function (cm) { + const cursor = cm.getCursor() + const line = cm.getLine(cursor.line) + const cursorPosition = cursor.ch + const charBeforeCursor = line.substr(cursorPosition - 1, 1) + if (cm.somethingSelected()) cm.indentSelection('add') + else { + const tabs = cm.getOption('indentWithTabs') + if (line.trimLeft().match(/^(-|\*|\+) (\[( |x)] )?$/)) { + cm.execCommand('goLineStart') + if (tabs) { + cm.execCommand('insertTab') + } else { + cm.execCommand('insertSoftTab') + } + cm.execCommand('goLineEnd') + } else if ( + !charBeforeCursor.match(/\t|\s|\r|\n|\$/) && + cursor.ch > 1 + ) { + // text expansion on tab key if the char before is alphabet + const wordBeforeCursor = self.getWordBeforeCursor( + line, + cursor.line, + cursor.ch + ) + if (expandSnippet(wordBeforeCursor, cursor, cm) === false) { + if (tabs) { + cm.execCommand('insertTab') + } else { + cm.execCommand('insertSoftTab') + } + } + } else { + if (tabs) { + cm.execCommand('insertTab') + } else { + cm.execCommand('insertSoftTab') + } + } + } + }, + 'Cmd-Left': function (cm) { + cm.execCommand('goLineLeft') + }, + 'Cmd-T': function (cm) { + // Do nothing + }, + 'Ctrl-/': function (cm) { + if (global.process.platform === 'darwin') { return } + const dateNow = new Date() + cm.replaceSelection(dateNow.toLocaleDateString()) + }, + 'Cmd-/': function (cm) { + if (global.process.platform !== 'darwin') { return } + const dateNow = new Date() + cm.replaceSelection(dateNow.toLocaleDateString()) + }, + 'Shift-Ctrl-/': function (cm) { + if (global.process.platform === 'darwin') { return } + const dateNow = new Date() + cm.replaceSelection(dateNow.toLocaleString()) + }, + 'Shift-Cmd-/': function (cm) { + if (global.process.platform !== 'darwin') { return } + const dateNow = new Date() + cm.replaceSelection(dateNow.toLocaleString()) + }, + Enter: 'boostNewLineAndIndentContinueMarkdownList', + 'Ctrl-C': cm => { + if (cm.getOption('keyMap').substr(0, 3) === 'vim') { + document.execCommand('copy') + } + return CodeMirror.Pass + }, + [translateHotkey(hotkey.pasteSmartly)]: cm => { + this.handlePaste(cm, true) + } + }) + } + + updateTableEditorState () { + const active = this.tableEditor.cursorIsInTable(this.tableEditorOptions) + if (active) { + if (this.extraKeysMode !== 'editor') { + this.extraKeysMode = 'editor' + this.editor.setOption('extraKeys', this.editorKeyMap) + } + } else { + if (this.extraKeysMode !== 'default') { + this.extraKeysMode = 'default' + this.editor.setOption('extraKeys', this.defaultKeyMap) + this.tableEditor.resetSmartCursor() + } + } + } + + componentDidMount () { + const { rulers, enableRulers, enableMarkdownLint } = this.props + eventEmitter.on('line:jump', this.scrollToLineHandeler) + + snippetManager.init() + this.updateDefaultKeyMap() + + this.value = this.props.value this.editor = CodeMirror(this.refs.root, { rulers: buildCMRulers(rulers, enableRulers), value: this.props.value, + linesHighlighted: this.props.linesHighlighted, lineNumbers: this.props.displayLineNumbers, lineWrapping: true, theme: this.props.theme, @@ -97,51 +277,33 @@ export default class CodeEditor extends React.Component { inputStyle: 'textarea', dragDrop: false, foldGutter: true, - gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'], - autoCloseBrackets: true, - extraKeys: { - Tab: function (cm) { - const cursor = cm.getCursor() - const line = cm.getLine(cursor.line) - if (cm.somethingSelected()) cm.indentSelection('add') - else { - const tabs = cm.getOption('indentWithTabs') - if (line.trimLeft().match(/^(-|\*|\+) (\[( |x)] )?$/)) { - cm.execCommand('goLineStart') - if (tabs) { - cm.execCommand('insertTab') - } else { - cm.execCommand('insertSoftTab') - } - cm.execCommand('goLineEnd') - } else { - if (tabs) { - cm.execCommand('insertTab') - } else { - cm.execCommand('insertSoftTab') - } - } - } - }, - 'Cmd-T': function (cm) { - // Do nothing - }, - Enter: 'boostNewLineAndIndentContinueMarkdownList', - 'Ctrl-C': (cm) => { - if (cm.getOption('keyMap').substr(0, 3) === 'vim') { - document.execCommand('copy') - } - return CodeMirror.Pass - } - } + lint: enableMarkdownLint ? this.getCodeEditorLintConfig() : false, + gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'], + autoCloseBrackets: { + pairs: this.props.matchingPairs, + triples: this.props.matchingTriples, + explode: this.props.explodingPairs, + override: true + }, + extraKeys: this.defaultKeyMap }) - 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) this.editor.on('change', this.changeHandler) + this.editor.on('gutterClick', this.highlightHandler) this.editor.on('paste', this.pasteHandler) + if (this.props.switchPreview !== 'RIGHTCLICK') { + this.editor.on('contextmenu', this.contextMenuHandler) + } eventEmitter.on('top:search', this.searchHandler) eventEmitter.emit('code:init') @@ -155,6 +317,180 @@ export default class CodeEditor extends React.Component { CodeMirror.Vim.defineEx('wq', 'wq', this.quitEditor) CodeMirror.Vim.defineEx('qw', 'qw', this.quitEditor) CodeMirror.Vim.map('ZZ', ':q', 'normal') + + this.textEditorInterface = new TextEditorInterface(this.editor) + this.tableEditor = new TableEditor(this.textEditorInterface) + if (this.props.spellCheck) { + this.editor.addPanel(this.createSpellCheckPanel(), {position: 'bottom'}) + } + + eventEmitter.on('code:format-table', this.formatTable) + + this.tableEditorOptions = options({ + smartCursor: true + }) + + 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) + } + }) + + if (this.props.enableTableEditor) { + this.editor.on('cursorActivity', this.editorActivityHandler) + this.editor.on('changes', this.editorActivityHandler) + } + + this.setState({ + clientWidth: this.refs.root.clientWidth + }) + + this.initialHighlighting() + } + + getWordBeforeCursor (line, lineNumber, cursorPosition) { + let wordBeforeCursor = '' + const originCursorPosition = cursorPosition + const emptyChars = /\t|\s|\r|\n|\$/ + + // 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 + + while (cursorPosition > 0) { + const currentChar = line.substr(cursorPosition - 1, 1) + // if char is not an empty char + if (!emptyChars.test(currentChar)) { + wordBeforeCursor = currentChar + wordBeforeCursor + } else if (wordBeforeCursor.length >= safeStop) { + throw new Error('Stopped after 20 loops for safety reason !') + } else { + break + } + cursorPosition-- + } + + return { + text: wordBeforeCursor, + range: { + from: { + line: lineNumber, + ch: originCursorPosition + }, + to: { + line: lineNumber, + ch: cursorPosition + } + } + } } quitEditor () { @@ -168,13 +504,22 @@ export default class CodeEditor extends React.Component { this.editor.off('paste', this.pasteHandler) eventEmitter.off('top:search', this.searchHandler) this.editor.off('scroll', this.scrollHandler) + this.editor.off('contextmenu', this.contextMenuHandler) const editorTheme = document.getElementById('editorTheme') editorTheme.removeEventListener('load', this.loadStyleHandler) + + spellcheck.setLanguage(null, spellcheck.SPELLCHECK_DISABLED) + eventEmitter.off('code:format-table', this.formatTable) } 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) } @@ -191,8 +536,21 @@ 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 || prevProps.rulers !== rulers) { + if ( + prevProps.enableRulers !== enableRulers || + prevProps.rulers !== rulers + ) { this.editor.setOption('rulers', buildCMRulers(rulers, enableRulers)) } @@ -212,30 +570,257 @@ export default class CodeEditor extends React.Component { 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) + this.editor.on('changes', this.editorActivityHandler) + } else { + this.editor.off('cursorActivity', this.editorActivityHandler) + this.editor.off('changes', this.editorActivityHandler) + } + + this.extraKeysMode = 'default' + this.editor.setOption('extraKeys', this.defaultKeyMap) + } + + if (prevProps.hotkey !== this.props.hotkey) { + this.updateDefaultKeyMap() + + if (this.extraKeysMode === 'default') { + this.editor.setOption('extraKeys', this.defaultKeyMap) + } + } + + if (this.state.clientWidth !== this.refs.root.clientWidth) { + this.setState({ + clientWidth: this.refs.root.clientWidth + }) + + needRefresh = true + } + + if (prevProps.spellCheck !== this.props.spellCheck) { + if (this.props.spellCheck === false) { + spellcheck.setLanguage(this.editor, spellcheck.SPELLCHECK_DISABLED) + const elem = document.getElementById('editor-bottom-panel') + elem.parentNode.removeChild(elem) + } else { + this.editor.addPanel(this.createSpellCheckPanel(), {position: 'bottom'}) + } + } + 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) CodeMirror.autoLoadMode(this.editor, syntax.mode) } - handleChange (e) { - this.value = this.editor.getValue() + 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() if (this.props.onChange) { - this.props.onChange(e) + this.props.onChange(editor) } } - moveCursorTo (row, col) { + 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('# ') } - scrollToLine (num) { + incrementLines (start, linesAdded, linesRemoved, editor) { + const highlightedLines = editor.options.linesHighlighted + + const totalHighlightedLines = highlightedLines.length + + const offset = linesAdded - linesRemoved + + // Store new items to be added as we're changing the lines + const newLines = [] + + let i = totalHighlightedLines + + while (i--) { + const lineNumber = highlightedLines[i] + + // Interval that will need to be updated + // Between start and (start + offset) remove highlight + if (lineNumber >= start) { + highlightedLines.splice(highlightedLines.indexOf(lineNumber), 1) + + // Lines that need to be relocated + if (lineNumber >= (start + linesRemoved)) { + newLines.push(lineNumber + offset) + } + } + } + + // Adding relocated lines + highlightedLines.push(...newLines) + + if (this.props.onChange) { + this.props.onChange(editor) + } + } + + handleHighlight (editor, changeObject) { + const lines = editor.options.linesHighlighted + + if (!lines.includes(changeObject)) { + lines.push(changeObject) + editor.addLineClass(changeObject, 'text', 'CodeMirror-activeline-background') + } else { + lines.splice(lines.indexOf(changeObject), 1) + editor.removeLineClass(changeObject, 'text', 'CodeMirror-activeline-background') + } + if (this.props.onChange) { + this.props.onChange(editor) + } + } + + updateHighlight (editor, changeObject) { + const linesAdded = changeObject.text.length - 1 + const linesRemoved = changeObject.removed.length - 1 + + // If no lines added or removed return + if (linesAdded === 0 && linesRemoved === 0) { + return + } + + let start = changeObject.from.line + + switch (changeObject.origin) { + case '+insert", "undo': + start += 1 + break + + case 'paste': + case '+delete': + case '+input': + if (changeObject.to.ch !== 0 || changeObject.from.ch !== 0) { + start += 1 + } + break + + default: + return + } + + this.incrementLines(start, linesAdded, linesRemoved, editor) + } + + moveCursorTo (row, col) {} + + scrollToLine (event, num) { + const cursor = { + line: num, + 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 () { @@ -252,6 +837,7 @@ export default class CodeEditor extends React.Component { this.value = this.props.value this.editor.setValue(this.props.value) this.editor.clearHistory() + this.restartHighlighting() this.editor.on('change', this.changeHandler) this.editor.refresh() } @@ -264,40 +850,125 @@ export default class CodeEditor extends React.Component { handleDropImage (dropEvent) { dropEvent.preventDefault() - const {storageKey, noteKey} = this.props - attachmentManagement.handleAttachmentDrop(this, storageKey, noteKey, dropEvent) + const { + storageKey, + noteKey + } = this.props + attachmentManagement.handleAttachmentDrop( + this, + storageKey, + noteKey, + dropEvent + ) } insertAttachmentMd (imageMd) { this.editor.replaceSelection(imageMd) } - handlePaste (editor, e) { - const clipboardData = e.clipboardData - const dataTransferItem = clipboardData.items[0] - const pastedTxt = clipboardData.getData('text') - const isURL = (str) => { - const matcher = /^(?:\w+:)?\/\/([^\s\.]+\.\S{2}|localhost[\:?\d]*)\S*$/ - return matcher.test(str) - } - const isInLinkTag = (editor) => { + 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 => /(?:^\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 === ')' } - if (dataTransferItem.type.match('image')) { - const {storageKey, noteKey} = this.props - attachmentManagement.handlePastImageEvent(this, storageKey, noteKey, dataTransferItem) - } else if (this.props.fetchUrlTitle && isURL(pastedTxt) && !isInLinkTag(editor)) { - this.handlePasteUrl(e, editor, pastedTxt) + + const isInFencedCodeBlock = editor => { + const cursor = editor.getCursor() + + let token = editor.getTokenAt(cursor) + if (token.state.fencedState) { + return true + } + + let line = line = cursor.line - 1 + while (line >= 0) { + token = editor.getTokenAt({ + ch: 3, + line + }) + + if (token.start === token.end) { + --line + } else if (token.type === 'comment') { + if (line > 0) { + token = editor.getTokenAt({ + ch: 3, + line: line - 1 + }) + + return token.type !== 'comment' + } else { + return true + } + } else { + return false + } + } + + return false + } + + const pastedTxt = clipboard.readText() + + 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 (attachmentManagement.isAttachmentLink(pastedTxt)) { + attachmentManagement + .handleAttachmentLinkPaste(storageKey, noteKey, pastedTxt) + .then(modifiedText => { + this.editor.replaceSelection(modifiedText) + }) + } else { + 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()) } } @@ -307,29 +978,118 @@ export default class CodeEditor extends React.Component { } } - handlePasteUrl (e, editor, pastedTxt) { - e.preventDefault() - const taggedUrl = `<${pastedTxt}>` + handlePasteUrl (editor, 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) - fetch(pastedTxt, { + const isImageReponse = response => { + return ( + response.headers.has('content-type') && + response.headers.get('content-type').match(/^image\/.+$/) + ) + } + const replaceTaggedUrl = replacement => { + const value = editor.getValue() + const cursor = editor.getCursor() + const newValue = value.replace(taggedUrl, titleMark + replacement) + const newCursor = Object.assign({}, cursor, { + ch: cursor.ch + newValue.length - (value.length - titleMark.length) + }) + + editor.setValue(newValue) + editor.setCursor(newCursor) + } + + fetch(urlToFetch, { method: 'get' - }).then((response) => { - return this.decodeResponse(response) - }).then((response) => { - const parsedResponse = (new window.DOMParser()).parseFromString(response, 'text/html') - const value = editor.getValue() - const cursor = editor.getCursor() - const LinkWithTitle = `[${parsedResponse.title}](${pastedTxt})` - const newValue = value.replace(taggedUrl, LinkWithTitle) - editor.setValue(newValue) - editor.setCursor(cursor) - }).catch((e) => { - const value = editor.getValue() - const newValue = value.replace(taggedUrl, pastedTxt) - const cursor = editor.getCursor() - editor.setValue(newValue) - editor.setCursor(cursor) + }) + .then(response => { + if (isImageReponse(response)) { + return this.mapImageResponse(response, urlToFetch) + } else { + return this.mapNormalResponse(response, urlToFetch) + } + }) + .then(replacement => { + replaceTaggedUrl(replacement) + }) + .catch(e => { + replaceTaggedUrl(pastedTxt) + }) + } + + handlePasteHtml (editor, pastedHtml) { + const markdown = this.turndownService.turndown(pastedHtml) + editor.replaceSelection(markdown) + } + + handlePasteText (editor, pastedTxt) { + editor.replaceSelection(pastedTxt) + } + + mapNormalResponse (response, pastedTxt) { + return this.decodeResponse(response).then(body => { + return new Promise((resolve, reject) => { + try { + const parsedBody = new window.DOMParser().parseFromString( + body, + 'text/html' + ) + const escapePipe = (str) => { + return str.replace('|', '\\|') + } + const linkWithTitle = `[${escapePipe(parsedBody.title)}](${pastedTxt})` + resolve(linkWithTitle) + } catch (e) { + reject(e) + } + }) + }) + } + + initialHighlighting () { + if (this.editor.options.linesHighlighted == null) { + return + } + + const totalHighlightedLines = this.editor.options.linesHighlighted.length + const totalAvailableLines = this.editor.lineCount() + + for (let i = 0; i < totalHighlightedLines; i++) { + const lineNumber = this.editor.options.linesHighlighted[i] + if (lineNumber > totalAvailableLines) { + // make sure that we skip the invalid lines althrough this case should not be happened. + continue + } + this.editor.addLineClass(lineNumber, 'text', 'CodeMirror-activeline-background') + } + } + + restartHighlighting () { + this.editor.options.linesHighlighted = this.props.linesHighlighted + this.initialHighlighting() + } + + mapImageResponse (response, pastedTxt) { + return new Promise((resolve, reject) => { + try { + const url = response.url + const name = url.substring(url.lastIndexOf('/') + 1) + const imageLinkWithName = `![${name}](${pastedTxt})` + resolve(imageLinkWithName) + } catch (e) { + reject(e) + } }) } @@ -338,11 +1098,14 @@ export default class CodeEditor extends React.Component { const _charset = headers.has('content-type') ? this.extractContentTypeCharset(headers.get('content-type')) : undefined - return response.arrayBuffer().then((buff) => { + return response.arrayBuffer().then(buff => { return new Promise((resolve, reject) => { try { - const charset = _charset !== undefined && iconv.encodingExists(_charset) ? _charset : 'utf-8' - resolve(iconv.decode(new Buffer(buff), charset).toString()) + const charset = _charset !== undefined && + iconv.encodingExists(_charset) + ? _charset + : 'utf-8' + resolve(iconv.decode(Buffer.from(buff), charset).toString()) } catch (e) { reject(e) } @@ -351,35 +1114,59 @@ export default class CodeEditor extends React.Component { } extractContentTypeCharset (contentType) { - return contentType.split(';').filter((str) => { - return str.trim().toLowerCase().startsWith('charset') - }).map((str) => { - return str.replace(/['"]/g, '').split('=')[1] - })[0] + return contentType + .split(';') + .filter(str => { + return str.trim().toLowerCase().startsWith('charset') + }) + .map(str => { + return str.replace(/['"]/g, '').split('=')[1] + })[0] } render () { - const { className, fontSize } = this.props - let fontFamily = this.props.fontFamily - fontFamily = _.isString(fontFamily) && fontFamily.length > 0 - ? [fontFamily].concat(defaultEditorFontFamily) - : defaultEditorFontFamily - return ( -
this.handleDropImage(e)} + const { + className, + fontSize + } = this.props + const fontFamily = normalizeEditorFontFamily(this.props.fontFamily) + const width = this.props.width + return (< + div className={ + className == null ? 'CodeEditor' : `CodeEditor ${className}` + } + ref='root' + tabIndex='-1' + style={{ + fontFamily, + fontSize: fontSize, + width: width + }} + onDrop={ + e => this.handleDropImage(e) + } /> ) } + + createSpellCheckPanel () { + const panel = document.createElement('div') + panel.className = 'panel bottom' + panel.id = 'editor-bottom-panel' + const dropdown = document.createElement('select') + dropdown.title = 'Spellcheck' + dropdown.className = styles['spellcheck-select'] + dropdown.addEventListener('change', (e) => spellcheck.setLanguage(this.editor, dropdown.value)) + const options = spellcheck.getAvailableDictionaries() + for (const op of options) { + const option = document.createElement('option') + option.value = op.value + option.innerHTML = op.label + dropdown.appendChild(option) + } + panel.appendChild(dropdown) + return panel + } } CodeEditor.propTypes = { @@ -390,7 +1177,11 @@ CodeEditor.propTypes = { className: PropTypes.string, onBlur: PropTypes.func, onChange: PropTypes.func, - readOnly: PropTypes.bool + readOnly: PropTypes.bool, + autoDetect: PropTypes.bool, + spellCheck: PropTypes.bool, + enableMarkdownLint: PropTypes.bool, + customMarkdownLintConfig: PropTypes.string } CodeEditor.defaultProps = { @@ -400,5 +1191,9 @@ CodeEditor.defaultProps = { fontSize: 14, fontFamily: 'Monaco, Consolas', indentSize: 4, - indentType: 'space' + indentType: 'space', + autoDetect: false, + spellCheck: false, + enableMarkdownLint: DEFAULT_CONFIG.editor.enableMarkdownLint, + customMarkdownLintConfig: DEFAULT_CONFIG.editor.customMarkdownLintConfig } diff --git a/browser/components/CodeEditor.styl b/browser/components/CodeEditor.styl new file mode 100644 index 00000000..1aa0e466 --- /dev/null +++ b/browser/components/CodeEditor.styl @@ -0,0 +1,5 @@ +.codeEditor-typo + text-decoration underline wavy red + +.spellcheck-select + border: none 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 2bd5d951..e956655c 100644 --- a/browser/components/MarkdownEditor.js +++ b/browser/components/MarkdownEditor.js @@ -6,6 +6,8 @@ import CodeEditor from 'browser/components/CodeEditor' import MarkdownPreview from 'browser/components/MarkdownPreview' import eventEmitter from 'browser/main/lib/eventEmitter' import { findStorage } from 'browser/lib/findStorage' +import ConfigManager from 'browser/main/lib/ConfigManager' +import attachmentManagement from 'browser/main/lib/dataApi/attachmentManagement' class MarkdownEditor extends React.Component { constructor (props) { @@ -18,10 +20,10 @@ class MarkdownEditor extends React.Component { this.supportMdSelectionBold = [16, 17, 186] this.state = { - status: '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() @@ -30,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 () { @@ -45,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) { @@ -64,17 +76,20 @@ class MarkdownEditor extends React.Component { }) } + setValue (value) { + this.refs.code.setValue(value) + } + handleChange (e) { this.value = this.refs.code.value this.props.onChange(e) } handleContextMenu (e) { + if (this.state.isLocked) return const { config } = this.props if (config.editor.switchPreview === 'RIGHTCLICK') { - const newStatus = this.state.status === 'PREVIEW' - ? 'CODE' - : 'PREVIEW' + const newStatus = this.state.status === 'PREVIEW' ? 'CODE' : 'PREVIEW' this.setState({ status: newStatus }, () => { @@ -84,6 +99,10 @@ class MarkdownEditor extends React.Component { this.refs.preview.focus() } eventEmitter.emit('topbar:togglelockbutton', this.state.status) + + const newConfig = Object.assign({}, config) + newConfig.editor.delfaultStatus = newStatus + ConfigManager.set(newConfig) }) } } @@ -140,8 +159,10 @@ class MarkdownEditor extends React.Component { e.preventDefault() e.stopPropagation() const idMatch = /checkbox-([0-9]+)/ - const checkedMatch = /\[x\]/i - const uncheckedMatch = /\[ \]/ + 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 @@ -150,10 +171,10 @@ class MarkdownEditor extends React.Component { const targetLine = lines[lineIndex] if (targetLine.match(checkedMatch)) { - lines[lineIndex] = targetLine.replace(checkedMatch, '[ ]') + lines[lineIndex] = targetLine.replace(checkReplace, '[ ]') } if (targetLine.match(uncheckedMatch)) { - lines[lineIndex] = targetLine.replace(uncheckedMatch, '[x]') + lines[lineIndex] = targetLine.replace(uncheckReplace, '[x]') } this.refs.code.setValue(lines.join('\n')) } @@ -212,6 +233,28 @@ class MarkdownEditor extends React.Component { this.refs.code.editor.replaceSelection(`${mdElement}${this.refs.code.editor.getSelection()}${mdElement}`) } + handleDropImage (dropEvent) { + dropEvent.preventDefault() + const { storageKey, noteKey } = this.props + + this.setState({ + status: 'CODE' + }, () => { + this.refs.code.focus() + + this.refs.code.editor.execCommand('goDocEnd') + this.refs.code.editor.execCommand('goLineEnd') + this.refs.code.editor.execCommand('newlineAndIndent') + + attachmentManagement.handleAttachmentDrop( + this.refs.code, + storageKey, + noteKey, + dropEvent + ) + }) + } + handleKeyUp (e) { const keyPressed = this.state.keyPressed keyPressed.delete(e.keyCode) @@ -223,7 +266,7 @@ class MarkdownEditor extends React.Component { } render () { - const {className, value, config, storageKey, noteKey} = this.props + const {className, value, config, storageKey, noteKey, linesHighlighted} = this.props let editorFontSize = parseInt(config.editor.fontSize, 10) if (!(editorFontSize > 0 && editorFontSize < 101)) editorFontSize = 14 @@ -250,7 +293,7 @@ class MarkdownEditor extends React.Component { : 'codeEditor--hide' } ref='code' - mode='GitHub Flavored Markdown' + mode='Boost Flavored Markdown' value={value} theme={config.editor.theme} keyMap={config.editor.keyMap} @@ -261,12 +304,23 @@ class MarkdownEditor extends React.Component { enableRulers={config.editor.enableRulers} rulers={config.editor.rulers} displayLineNumbers={config.editor.displayLineNumbers} + matchingPairs={config.editor.matchingPairs} + matchingTriples={config.editor.matchingTriples} + explodingPairs={config.editor.explodingPairs} scrollPastEnd={config.editor.scrollPastEnd} storageKey={storageKey} noteKey={noteKey} fetchUrlTitle={config.editor.fetchUrlTitle} + enableTableEditor={config.editor.enableTableEditor} + linesHighlighted={linesHighlighted} onChange={(e) => this.handleChange(e)} onBlur={(e) => this.handleBlur(e)} + spellCheck={config.editor.spellcheck} + enableSmartPaste={config.editor.enableSmartPaste} + hotkey={config.hotkey} + switchPreview={config.editor.switchPreview} + enableMarkdownLint={config.editor.enableMarkdownLint} + customMarkdownLintConfig={config.editor.customMarkdownLintConfig} /> this.handleDropImage(e)} />
) diff --git a/browser/components/MarkdownEditor.styl b/browser/components/MarkdownEditor.styl index 13455e5d..c8fe2e49 100644 --- a/browser/components/MarkdownEditor.styl +++ b/browser/components/MarkdownEditor.styl @@ -16,7 +16,6 @@ .preview display block absolute top bottom left right - z-index 100 background-color white height 100% width 100% diff --git a/browser/components/MarkdownPreview.js b/browser/components/MarkdownPreview.js index ea5f11c0..9d391125 100755 --- a/browser/components/MarkdownPreview.js +++ b/browser/components/MarkdownPreview.js @@ -7,7 +7,9 @@ import 'codemirror-mode-elixir' import consts from 'browser/lib/consts' import Raphael from 'raphael' import flowchart from 'flowchart' -import SequenceDiagram from 'js-sequence-diagrams' +import mermaidRender from './render/MermaidRender' +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' import convertModeName from 'browser/lib/convertModeName' @@ -15,24 +17,45 @@ import copy from 'copy-to-clipboard' 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 } = require('electron') +const { remote, shell } = require('electron') const attachmentManagement = require('../main/lib/dataApi/attachmentManagement') const { app } = remote const path = require('path') +const fileUrl = require('file-url') + const dialog = remote.dialog +const uri2path = require('file-uri-to-path') + const markdownStyle = require('!!css!stylus?sourceMap!./markdown.styl')[0][1] -const appPath = 'file://' + (process.env.NODE_ENV === 'production' - ? app.getAppPath() - : path.resolve()) +const appPath = fileUrl( + process.env.NODE_ENV === 'production' ? app.getAppPath() : path.resolve() +) const CSS_FILES = [ `${appPath}/node_modules/katex/dist/katex.min.css`, - `${appPath}/node_modules/codemirror/lib/codemirror.css` + `${appPath}/node_modules/codemirror/lib/codemirror.css`, + `${appPath}/node_modules/react-image-carousel/lib/css/main.min.css` ] -function buildStyle (fontFamily, fontSize, codeBlockFontFamily, lineNumber, scrollPastEnd, theme) { +function buildStyle ( + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS +) { return ` @font-face { font-family: 'Lato'; @@ -52,12 +75,28 @@ function buildStyle (fontFamily, fontSize, codeBlockFontFamily, lineNumber, scro font-weight: 700; text-rendering: optimizeLegibility; } +@font-face { + font-family: 'Material Icons'; + font-style: normal; + font-weight: 400; + src: local('Material Icons'), + local('MaterialIcons-Regular'), + url('${appPath}/resources/fonts/MaterialIcons-Regular.woff2') format('woff2'), + url('${appPath}/resources/fonts/MaterialIcons-Regular.woff') format('woff'), + url('${appPath}/resources/fonts/MaterialIcons-Regular.ttf') format('truetype'); +} ${markdownStyle} + body { font-family: '${fontFamily.join("','")}'; font-size: ${fontSize}px; ${scrollPastEnd && 'padding-bottom: 90vh;'} } +@media print { + body { + padding-bottom: initial; + } +} code { font-family: '${codeBlockFontFamily.join("','")}'; background-color: rgba(0,0,0,0.04); @@ -110,11 +149,34 @@ body p { color: #000; background-color: #fff; } + .clipboardButton { + display: none + } } + +${allowCustomCSS ? customCSS : ''} ` } -const { shell } = require('electron') +const scrollBarStyle = ` +::-webkit-scrollbar { + width: 12px; +} + +::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.15); +} +` +const scrollBarDarkStyle = ` +::-webkit-scrollbar { + width: 12px; +} + +::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.3); +} +` + const OSX = global.process.platform === 'darwin' const defaultFontFamily = ['helvetica', 'arial', 'sans-serif'] @@ -122,24 +184,47 @@ if (!OSX) { defaultFontFamily.unshift('Microsoft YaHei') defaultFontFamily.unshift('meiryo') } -const defaultCodeBlockFontFamily = ['Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', 'monospace'] +const defaultCodeBlockFontFamily = [ + 'Monaco', + 'Menlo', + 'Ubuntu Mono', + 'Consolas', + 'source-code-pro', + 'monospace' +] + +// 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) - this.contextMenuHandler = (e) => this.handleContextMenu(e) - this.mouseDownHandler = (e) => this.handleMouseDown(e) - this.mouseUpHandler = (e) => this.handleMouseUp(e) - this.DoubleClickHandler = (e) => this.handleDoubleClick(e) - this.scrollHandler = _.debounce(this.handleScroll.bind(this), 100, {leading: false, trailing: true}) - this.anchorClickHandler = (e) => this.handlePreviewAnchorClick(e) - this.checkboxClickHandler = (e) => this.handleCheckboxClick(e) + this.contextMenuHandler = e => this.handleContextMenu(e) + this.mouseDownHandler = e => this.handleMouseDown(e) + this.mouseUpHandler = e => this.handleMouseUp(e) + this.DoubleClickHandler = e => this.handleDoubleClick(e) + this.scrollHandler = _.debounce(this.handleScroll.bind(this), 100, { + leading: false, + trailing: true + }) + this.checkboxClickHandler = e => this.handleCheckboxClick(e) this.saveAsTextHandler = () => this.handleSaveAsText() this.saveAsMdHandler = () => this.handleSaveAsMd() this.saveAsHtmlHandler = () => this.handleSaveAsHtml() + this.saveAsPdfHandler = () => this.handleSaveAsPdf() this.printHandler = () => this.handlePrint() - this.linkClickHandler = this.handlelinkClick.bind(this) + this.linkClickHandler = this.handleLinkClick.bind(this) this.initMarkdown = this.initMarkdown.bind(this) this.initMarkdown() } @@ -153,22 +238,6 @@ export default class MarkdownPreview extends React.Component { }) } - handlePreviewAnchorClick (e) { - e.preventDefault() - e.stopPropagation() - - const anchor = e.target.closest('a') - const href = anchor.getAttribute('href') - if (_.isString(href) && href.match(/^#/)) { - const targetElement = this.refs.root.contentWindow.document.getElementById(href.substring(1, href.length)) - if (targetElement != null) { - this.getWindow().scrollTo(0, targetElement.offsetTop) - } - } else { - shell.openExternal(href) - } - } - handleCheckboxClick (e) { this.props.onCheckboxClick(e) } @@ -179,8 +248,32 @@ export default class MarkdownPreview extends React.Component { } } - handleContextMenu (e) { - this.props.onContextMenu(e) + handleContextMenu (event) { + // If a contextMenu handler was passed to us, use it instead of the self-defined one -> return + 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' && event.target.getAttribute('href')) { + 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) + } + } + } } handleDoubleClick (e) { @@ -188,14 +281,28 @@ export default class MarkdownPreview extends React.Component { } handleMouseDown (e) { - if (e.target != null) { - switch (e.target.tagName) { - case 'A': - case 'INPUT': - return null + 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.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) { @@ -214,45 +321,77 @@ export default class MarkdownPreview extends React.Component { this.exportAsDocument('md') } - handleSaveAsHtml () { - this.exportAsDocument('html', (noteContent, exportTasks) => { - const {fontFamily, fontSize, codeBlockFontFamily, lineNumber, codeBlockTheme, scrollPastEnd, theme} = 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) - let body = this.markdown.render(escapeHtmlCharacters(noteContent)) - - const files = [this.GetCodeThemeLink(codeBlockTheme), ...CSS_FILES] - const attachmentsAbsolutePaths = attachmentManagement.getAbsolutePathsOfAttachmentsInContent(noteContent, this.props.storagePath) - - files.forEach((file) => { + 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' + } + 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}}) + 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() + }) }) }) - attachmentsAbsolutePaths.forEach((attachment) => { - exportTasks.push({ - src: attachment, - dst: attachmentManagement.DESTINATION_FOLDER - }) - }) - body = attachmentManagement.removeStorageAndNoteReferences(body, this.props.noteKey) - - let styles = '' - files.forEach((file) => { - styles += `` - }) - - return ` - - - - - ${styles} - - ${body} - ` }) } @@ -262,50 +401,104 @@ export default class MarkdownPreview extends React.Component { exportAsDocument (fileType, contentFormatter) { const options = { - filters: [ - {name: 'Documents', extensions: [fileType]} - ], + filters: [{ name: 'Documents', extensions: [fileType] }], properties: ['openFile', 'createDirectory'] } - dialog.showSaveDialog(remote.getCurrentWindow(), options, - (filename) => { - if (filename) { - const content = this.props.value - const storage = this.props.storagePath + dialog.showSaveDialog(remote.getCurrentWindow(), options, filename => { + if (filename) { + const content = this.props.value + const storage = this.props.storagePath + const nodeKey = this.props.noteKey - exportNote(storage, content, filename, contentFormatter) - .then((res) => { - dialog.showMessageBox(remote.getCurrentWindow(), {type: 'info', message: `Exported to ${filename}`}) - }).catch((err) => { - dialog.showErrorBox('Export error', err ? err.message || err : 'Unexpected error during export') - throw err - }) - } - }) + exportNote(nodeKey, storage, content, filename, contentFormatter) + .then(res => { + dialog.showMessageBox(remote.getCurrentWindow(), { + type: 'info', + message: `Exported to ${filename}` + }) + }) + .catch(err => { + dialog.showErrorBox( + 'Export error', + err ? err.message || err : 'Unexpected error during export' + ) + throw err + }) + } + }) } fixDecodedURI (node) { - if (node && node.children.length === 1 && typeof node.children[0] === 'string') { + if ( + node && + node.children.length === 1 && + typeof node.children[0] === 'string' + ) { const { innerText, href } = node - node.innerText = mdurl.decode(href) === innerText - ? href - : innerText + node.innerText = mdurl.decode(href) === innerText ? href : innerText + } + } + + /** + * @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 + + switch (theme) { + case 'dark': + case 'solarized-dark': + case 'monokai': + case 'dracula': + return scrollBarDarkStyle + default: + return scrollBarStyle } } componentDidMount () { + const { onDrop } = this.props + this.refs.root.setAttribute('sandbox', 'allow-scripts') - this.refs.root.contentWindow.document.body.addEventListener('contextmenu', this.contextMenuHandler) + this.refs.root.contentWindow.document.body.addEventListener( + 'contextmenu', + this.contextMenuHandler + ) let styles = ` + ` - CSS_FILES.forEach((file) => { + CSS_FILES.forEach(file => { styles += `` }) @@ -313,154 +506,271 @@ export default class MarkdownPreview extends React.Component { this.rewriteIframe() this.applyStyle() - this.refs.root.contentWindow.document.addEventListener('mousedown', this.mouseDownHandler) - this.refs.root.contentWindow.document.addEventListener('mouseup', this.mouseUpHandler) - this.refs.root.contentWindow.document.addEventListener('dblclick', this.DoubleClickHandler) - this.refs.root.contentWindow.document.addEventListener('drop', this.preventImageDroppedHandler) - this.refs.root.contentWindow.document.addEventListener('dragover', this.preventImageDroppedHandler) - this.refs.root.contentWindow.document.addEventListener('scroll', this.scrollHandler) + this.refs.root.contentWindow.document.addEventListener( + 'mousedown', + this.mouseDownHandler + ) + this.refs.root.contentWindow.document.addEventListener( + 'mouseup', + this.mouseUpHandler + ) + this.refs.root.contentWindow.document.addEventListener( + 'dblclick', + this.DoubleClickHandler + ) + this.refs.root.contentWindow.document.addEventListener( + 'drop', + onDrop || this.preventImageDroppedHandler + ) + this.refs.root.contentWindow.document.addEventListener( + 'dragover', + this.preventImageDroppedHandler + ) + this.refs.root.contentWindow.document.addEventListener( + 'scroll', + this.scrollHandler + ) eventEmitter.on('export:save-text', this.saveAsTextHandler) eventEmitter.on('export:save-md', this.saveAsMdHandler) eventEmitter.on('export:save-html', this.saveAsHtmlHandler) + eventEmitter.on('export:save-pdf', this.saveAsPdfHandler) eventEmitter.on('print', this.printHandler) } componentWillUnmount () { - this.refs.root.contentWindow.document.body.removeEventListener('contextmenu', this.contextMenuHandler) - this.refs.root.contentWindow.document.removeEventListener('mousedown', this.mouseDownHandler) - this.refs.root.contentWindow.document.removeEventListener('mouseup', this.mouseUpHandler) - this.refs.root.contentWindow.document.removeEventListener('dblclick', this.DoubleClickHandler) - this.refs.root.contentWindow.document.removeEventListener('drop', this.preventImageDroppedHandler) - this.refs.root.contentWindow.document.removeEventListener('dragover', this.preventImageDroppedHandler) - this.refs.root.contentWindow.document.removeEventListener('scroll', this.scrollHandler) + const { onDrop } = this.props + + this.refs.root.contentWindow.document.body.removeEventListener( + 'contextmenu', + this.contextMenuHandler + ) + this.refs.root.contentWindow.document.removeEventListener( + 'mousedown', + this.mouseDownHandler + ) + this.refs.root.contentWindow.document.removeEventListener( + 'mouseup', + this.mouseUpHandler + ) + this.refs.root.contentWindow.document.removeEventListener( + 'dblclick', + this.DoubleClickHandler + ) + this.refs.root.contentWindow.document.removeEventListener( + 'drop', + onDrop || this.preventImageDroppedHandler + ) + this.refs.root.contentWindow.document.removeEventListener( + 'dragover', + this.preventImageDroppedHandler + ) + this.refs.root.contentWindow.document.removeEventListener( + 'scroll', + this.scrollHandler + ) eventEmitter.off('export:save-text', this.saveAsTextHandler) eventEmitter.off('export:save-md', this.saveAsMdHandler) eventEmitter.off('export:save-html', this.saveAsHtmlHandler) + eventEmitter.off('export:save-pdf', this.saveAsPdfHandler) eventEmitter.off('print', this.printHandler) } componentDidUpdate (prevProps) { if (prevProps.value !== this.props.value) this.rewriteIframe() - if (prevProps.smartQuotes !== this.props.smartQuotes || - prevProps.sanitize !== this.props.sanitize || - prevProps.breaks !== this.props.breaks) { + if ( + prevProps.smartQuotes !== this.props.smartQuotes || + prevProps.sanitize !== this.props.sanitize || + prevProps.smartArrows !== this.props.smartArrows || + prevProps.breaks !== this.props.breaks || + prevProps.lineThroughCheckbox !== this.props.lineThroughCheckbox + ) { this.initMarkdown() this.rewriteIframe() } - if (prevProps.fontFamily !== this.props.fontFamily || + if ( + prevProps.fontFamily !== this.props.fontFamily || prevProps.fontSize !== this.props.fontSize || prevProps.codeBlockFontFamily !== this.props.codeBlockFontFamily || prevProps.codeBlockTheme !== this.props.codeBlockTheme || prevProps.lineNumber !== this.props.lineNumber || prevProps.showCopyNotification !== this.props.showCopyNotification || prevProps.theme !== this.props.theme || - prevProps.scrollPastEnd !== this.props.scrollPastEnd) { + prevProps.scrollPastEnd !== this.props.scrollPastEnd || + prevProps.allowCustomCSS !== this.props.allowCustomCSS || + prevProps.customCSS !== this.props.customCSS + ) { this.applyStyle() this.rewriteIframe() } } getStyleParams () { - const { fontSize, lineNumber, codeBlockTheme, scrollPastEnd, theme } = this.props + const { + fontSize, + lineNumber, + codeBlockTheme, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS + } = this.props let { fontFamily, codeBlockFontFamily } = this.props fontFamily = _.isString(fontFamily) && fontFamily.trim().length > 0 - ? fontFamily.split(',').map(fontName => fontName.trim()).concat(defaultFontFamily) - : defaultFontFamily - codeBlockFontFamily = _.isString(codeBlockFontFamily) && codeBlockFontFamily.trim().length > 0 - ? codeBlockFontFamily.split(',').map(fontName => fontName.trim()).concat(defaultCodeBlockFontFamily) - : defaultCodeBlockFontFamily + ? fontFamily + .split(',') + .map(fontName => fontName.trim()) + .concat(defaultFontFamily) + : defaultFontFamily + codeBlockFontFamily = _.isString(codeBlockFontFamily) && + codeBlockFontFamily.trim().length > 0 + ? codeBlockFontFamily + .split(',') + .map(fontName => fontName.trim()) + .concat(defaultCodeBlockFontFamily) + : defaultCodeBlockFontFamily - return {fontFamily, fontSize, codeBlockFontFamily, lineNumber, codeBlockTheme, scrollPastEnd, theme} + return { + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + codeBlockTheme, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS + } } applyStyle () { - const {fontFamily, fontSize, codeBlockFontFamily, lineNumber, codeBlockTheme, scrollPastEnd, theme} = this.getStyleParams() + const { + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + codeBlockTheme, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS + } = this.getStyleParams() - this.getWindow().document.getElementById('codeTheme').href = this.GetCodeThemeLink(codeBlockTheme) - this.getWindow().document.getElementById('style').innerHTML = buildStyle(fontFamily, fontSize, codeBlockFontFamily, lineNumber, scrollPastEnd, theme) + this.getWindow().document.getElementById( + 'codeTheme' + ).href = this.GetCodeThemeLink(codeBlockTheme) + this.getWindow().document.getElementById('style').innerHTML = buildStyle( + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS + ) } - GetCodeThemeLink (theme) { - theme = consts.THEMES.some((_theme) => _theme === theme) && theme !== 'default' - ? theme - : 'elegant' - 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) + + if (theme) { + return `${appPath}/${theme.path}` + } else { + return `${appPath}/node_modules/codemirror/theme/elegant.css` + } } rewriteIframe () { - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('a'), (el) => { - el.removeEventListener('click', this.anchorClickHandler) - }) - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('input[type="checkbox"]'), (el) => { - el.removeEventListener('click', this.checkboxClickHandler) - }) + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll( + 'input[type="checkbox"]' + ), + el => { + el.removeEventListener('click', this.checkboxClickHandler) + } + ) - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('a'), (el) => { - el.removeEventListener('click', this.linkClickHandler) - }) + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll('a'), + el => { + el.removeEventListener('click', this.linkClickHandler) + } + ) - const { theme, indentSize, showCopyNotification, storagePath } = this.props + const { + theme, + indentSize, + showCopyNotification, + storagePath, + noteKey, + sanitize + } = this.props let { value, codeBlockTheme } = this.props this.refs.root.contentWindow.document.body.setAttribute('data-theme', theme) - - const codeBlocks = value.match(/(```)(.|[\n])*?(```)/g) - if (codeBlocks !== null) { - codeBlocks.forEach((codeBlock) => { - value = value.replace(codeBlock, htmlTextHelper.encodeEntities(codeBlock)) - }) + if (sanitize === 'NONE') { + const splitWithCodeTag = value.split('```') + value = this.escapeHtmlCharactersInCodeTag(splitWithCodeTag) } - let renderedHTML = this.markdown.render(value) - this.refs.root.contentWindow.document.body.innerHTML = attachmentManagement.fixLocalURLS(renderedHTML, storagePath) + const renderedHTML = this.markdown.render(value) + attachmentManagement.migrateAttachments(value, storagePath, noteKey) + this.refs.root.contentWindow.document.body.innerHTML = attachmentManagement.fixLocalURLS( + renderedHTML, + storagePath + ) + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll( + 'input[type="checkbox"]' + ), + el => { + el.addEventListener('click', this.checkboxClickHandler) + } + ) - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('a'), (el) => { - this.fixDecodedURI(el) - el.addEventListener('click', this.anchorClickHandler) - }) + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll('a'), + el => { + this.fixDecodedURI(el) + el.addEventListener('click', this.linkClickHandler) + } + ) - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('input[type="checkbox"]'), (el) => { - el.addEventListener('click', this.checkboxClickHandler) - }) + codeBlockTheme = consts.THEMES.find(theme => theme.name === codeBlockTheme) - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('a'), (el) => { - el.addEventListener('click', this.linkClickHandler) - }) + const codeBlockThemeClassName = codeBlockTheme ? codeBlockTheme.className : 'cm-s-default' - codeBlockTheme = consts.THEMES.some((_theme) => _theme === codeBlockTheme) - ? codeBlockTheme - : 'default' - - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('.code code'), (el) => { - let syntax = CodeMirror.findModeByName(convertModeName(el.className)) - if (syntax == null) syntax = CodeMirror.findModeByName('Plain Text') - CodeMirror.requireMode(syntax.mode, () => { - const content = htmlTextHelper.decodeEntities(el.innerHTML) - const copyIcon = document.createElement('i') - copyIcon.innerHTML = '' - copyIcon.onclick = (e) => { - copy(content) - if (showCopyNotification) { - this.notify('Saved to Clipboard!', { - body: 'Paste it wherever you want!', - silent: true - }) + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll('.code code'), + el => { + let syntax = CodeMirror.findModeByName(convertModeName(el.className)) + if (syntax == null) syntax = CodeMirror.findModeByName('Plain Text') + CodeMirror.requireMode(syntax.mode, () => { + const content = htmlTextHelper.decodeEntities(el.innerHTML) + const copyIcon = document.createElement('i') + copyIcon.innerHTML = + '' + copyIcon.onclick = e => { + e.preventDefault() + e.stopPropagation() + copy(content) + if (showCopyNotification) { + this.notify('Saved to Clipboard!', { + body: 'Paste it wherever you want!', + silent: true + }) + } } - } - el.parentNode.appendChild(copyIcon) - el.innerHTML = '' - if (codeBlockTheme.indexOf('solarized') === 0) { - const [refThema, color] = codeBlockTheme.split(' ') - el.parentNode.className += ` cm-s-${refThema} cm-s-${color}` - } else { - el.parentNode.className += ` cm-s-${codeBlockTheme}` - } - CodeMirror.runMode(content, syntax.mime, el, { - tabSize: indentSize + + el.parentNode.appendChild(copyIcon) + el.innerHTML = '' + el.parentNode.className += ` ${codeBlockThemeClassName}` + + CodeMirror.runMode(content, syntax.mime, el, { + tabSize: indentSize + }) }) - }) - }) + } + ) const opts = {} // if (this.props.theme === 'dark') { // opts['font-color'] = '#DDD' @@ -468,37 +778,203 @@ export default class MarkdownPreview extends React.Component { // opts['element-color'] = '#DDD' // opts['fill'] = '#3A404C' // } - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('.flowchart'), (el) => { - Raphael.setWindow(this.getWindow()) - try { - const diagram = flowchart.parse(htmlTextHelper.decodeEntities(el.innerHTML)) + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll('.flowchart'), + el => { + Raphael.setWindow(this.getWindow()) + try { + const diagram = flowchart.parse( + htmlTextHelper.decodeEntities(el.innerHTML) + ) + el.innerHTML = '' + diagram.drawSVG(el, opts) + _.forEach(el.querySelectorAll('a'), el => { + el.addEventListener('click', this.linkClickHandler) + }) + } catch (e) { + el.className = 'flowchart-error' + el.innerHTML = 'Flowchart parse error: ' + e.message + } + } + ) + + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll('.sequence'), + el => { + Raphael.setWindow(this.getWindow()) + try { + const diagram = SequenceDiagram.parse( + htmlTextHelper.decodeEntities(el.innerHTML) + ) + el.innerHTML = '' + diagram.drawSVG(el, { theme: 'simple' }) + _.forEach(el.querySelectorAll('a'), el => { + el.addEventListener('click', this.linkClickHandler) + }) + } catch (e) { + el.className = 'sequence-error' + el.innerHTML = 'Sequence diagram parse error: ' + e.message + } + } + ) + + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll('.chart'), + el => { + try { + const format = el.attributes.getNamedItem('data-format').value + const chartConfig = format === 'yaml' ? yaml.load(el.innerHTML) : JSON.parse(el.innerHTML) + el.innerHTML = '' + + const canvas = document.createElement('canvas') + el.appendChild(canvas) + + const height = el.attributes.getNamedItem('data-height') + if (height && height.value !== 'undefined') { + el.style.height = height.value + 'vh' + canvas.height = height.value + 'vh' + } + + const chart = new Chart(canvas, chartConfig) + } catch (e) { + el.className = 'chart-error' + el.innerHTML = 'chartjs diagram parse error: ' + e.message + } + } + ) + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll('.mermaid'), + el => { + mermaidRender(el, htmlTextHelper.decodeEntities(el.innerHTML), theme) + } + ) + + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll('.gallery'), + el => { + const images = el.innerHTML.split(/\n/g).filter(i => i.length > 0) el.innerHTML = '' - diagram.drawSVG(el, opts) - _.forEach(el.querySelectorAll('a'), (el) => { - el.addEventListener('click', this.anchorClickHandler) - }) - } catch (e) { - console.error(e) - el.className = 'flowchart-error' - el.innerHTML = 'Flowchart parse error: ' + e.message + + const height = el.attributes.getNamedItem('data-height') + if (height && height.value !== 'undefined') { + el.style.height = height.value + 'vh' + } + + let autoplay = el.attributes.getNamedItem('data-autoplay') + if (autoplay && autoplay.value !== 'undefined') { + autoplay = parseInt(autoplay.value, 10) || 0 + } else { + autoplay = 0 + } + + render( + , + el + ) + } + ) + + 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 + } } }) - _.forEach(this.refs.root.contentWindow.document.querySelectorAll('.sequence'), (el) => { - Raphael.setWindow(this.getWindow()) - try { - const diagram = SequenceDiagram.parse(htmlTextHelper.decodeEntities(el.innerHTML)) - el.innerHTML = '' - diagram.drawSVG(el, {theme: 'simple'}) - _.forEach(el.querySelectorAll('a'), (el) => { - el.addEventListener('click', this.anchorClickHandler) - }) - } catch (e) { - console.error(e) - el.className = 'sequence-error' - el.innerHTML = 'Sequence diagram parse error: ' + e.message + 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) + } + + this.getWindow().scrollTo(0, 0) } focus () { @@ -510,7 +986,9 @@ export default class MarkdownPreview extends React.Component { } scrollTo (targetRow) { - const blocks = this.getWindow().document.querySelectorAll('body>[data-line]') + const blocks = this.getWindow().document.querySelectorAll( + 'body>[data-line]' + ) for (let index = 0; index < blocks.length; index++) { let block = blocks[index] @@ -530,27 +1008,34 @@ export default class MarkdownPreview extends React.Component { notify (title, options) { if (global.process.platform === 'win32') { - options.icon = path.join('file://', global.__dirname, '../../resources/app.png') + options.icon = path.join( + 'file://', + global.__dirname, + '../../resources/app.png' + ) } return new window.Notification(title, options) } - handlelinkClick (e) { + handleLinkClick (e) { e.preventDefault() e.stopPropagation() - const href = e.target.href - if (href.match(/^http/i)) { - shell.openExternal(href) - return - } + const rawHref = e.target.getAttribute('href') + const parser = document.createElement('a') + parser.href = e.target.getAttribute('href') + const { href, hash } = parser + const linkHash = hash === '' ? rawHref : hash // needed because we're having special link formats that are removed by parser e.g. :line:10 - const linkHash = href.split('/').pop() + if (!rawHref) return // not checked href because parser will create file://... string for [empty link]() - const regexNoteInternalLink = /main.html#(.+)/ + const extractId = /(main.html)?#/ + const regexNoteInternalLink = new RegExp(`${extractId.source}(.+)`) if (regexNoteInternalLink.test(linkHash)) { - const targetId = mdurl.encode(linkHash.match(regexNoteInternalLink)[1]) - const targetElement = this.refs.root.contentWindow.document.getElementById(targetId) + const targetId = mdurl.encode(linkHash.replace(extractId, '')) + const targetElement = this.refs.root.contentWindow.document.getElementById( + targetId + ) if (targetElement != null) { this.getWindow().scrollTo(0, targetElement.offsetTop) @@ -568,6 +1053,15 @@ export default class MarkdownPreview extends React.Component { return } + const regexIsLine = /^:line:[0-9]/ + if (regexIsLine.test(linkHash)) { + const numberPattern = /\d+/g + + const lineNumber = parseInt(linkHash.match(numberPattern)[0]) + eventEmitter.emit('line:jump', lineNumber) + return + } + // this will match the old link format storage.key-note.key // e.g. // 877f99c3268608328037-1c211eb7dcb463de6490 @@ -576,14 +1070,17 @@ export default class MarkdownPreview extends React.Component { eventEmitter.emit('list:jump', linkHash.split('-')[1]) return } + + // other case + shell.openExternal(href) } render () { const { className, style, tabIndex } = this.props return ( - \n\n## Docs :memo:\n- [Boostnote | Boost your happiness, productivity and creativity.](https://hackernoon.com/boostnote-boost-your-happiness-productivity-and-creativity-315034efeebe)\n- [Cloud Syncing & Backups](https://github.com/BoostIO/Boostnote/wiki/Cloud-Syncing-and-Backup)\n- [How to sync your data across Desktop and Mobile apps](https://github.com/BoostIO/Boostnote/wiki/Sync-Data-Across-Desktop-and-Mobile-apps)\n- [Convert data from **Evernote** to Boostnote.](https://github.com/BoostIO/Boostnote/wiki/Evernote)\n- [Keyboard Shortcuts](https://github.com/BoostIO/Boostnote/wiki/Keyboard-Shortcuts)\n- [Keymaps in Editor mode](https://github.com/BoostIO/Boostnote/wiki/Keymaps-in-Editor-mode)\n- [How to set syntax highlight in Snippet note](https://github.com/BoostIO/Boostnote/wiki/Syntax-Highlighting)\n\n---\n\n## Article Archive :books:\n- [Reddit English](http://bit.ly/2mOJPu7)\n- [Reddit Spanish](https://www.reddit.com/r/boostnote_es/)\n- [Reddit Chinese](https://www.reddit.com/r/boostnote_cn/)\n- [Reddit Japanese](https://www.reddit.com/r/boostnote_jp/)\n\n---\n\n## Community :beers:\n- [GitHub](http://bit.ly/2AWWzkD)\n- [Twitter](http://bit.ly/2z8BUJZ)\n- [Facebook Group](http://bit.ly/2jcca8t)' }) - .then((note) => { + .then(note => { store.dispatch({ type: 'UPDATE_NOTE', note: note @@ -131,10 +131,10 @@ class Main extends React.Component { .then(defaultMarkdownNote) .then(() => data.storage) }) - .then((storage) => { - hashHistory.push('/storages/' + storage.key) + .then(storage => { + store.dispatch(push('/storages/' + storage.key)) }) - .catch((err) => { + .catch(err => { throw err }) } @@ -142,12 +142,7 @@ class Main extends React.Component { componentDidMount () { const { dispatch, config } = this.props - const supportedThemes = [ - 'dark', - 'white', - 'solarized-dark', - 'monokai' - ] + const supportedThemes = ['dark', 'white', 'solarized-dark', 'monokai', 'dracula'] if (supportedThemes.indexOf(config.ui.theme) !== -1) { document.body.setAttribute('data-theme', config.ui.theme) @@ -162,24 +157,36 @@ class Main extends React.Component { } applyShortcuts() // Reload all data - dataApi.init() - .then((data) => { - dispatch({ - type: 'INIT_ALL', - storages: data.storages, - notes: data.notes - }) - - if (data.storages.length < 1) { - this.init() - } + dataApi.init().then(data => { + dispatch({ + type: 'INIT_ALL', + storages: data.storages, + notes: data.notes }) + if (data.storages.length < 1) { + this.init() + } + }) + + 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) { @@ -199,34 +206,40 @@ class Main extends React.Component { handleMouseUp (e) { // Change width of NoteList component. if (this.state.isRightSliderFocused) { - this.setState({ - isRightSliderFocused: false - }, () => { - const { dispatch } = this.props - const newListWidth = this.state.listWidth - // TODO: ConfigManager should dispatch itself. - ConfigManager.set({listWidth: newListWidth}) - dispatch({ - type: 'SET_LIST_WIDTH', - listWidth: newListWidth - }) - }) + this.setState( + { + isRightSliderFocused: false + }, + () => { + const { dispatch } = this.props + const newListWidth = this.state.listWidth + // TODO: ConfigManager should dispatch itself. + ConfigManager.set({ listWidth: newListWidth }) + dispatch({ + type: 'SET_LIST_WIDTH', + listWidth: newListWidth + }) + } + ) } // Change width of SideNav component. if (this.state.isLeftSliderFocused) { - this.setState({ - isLeftSliderFocused: false - }, () => { - const { dispatch } = this.props - const navWidth = this.state.navWidth - // TODO: ConfigManager should dispatch itself. - ConfigManager.set({ navWidth }) - dispatch({ - type: 'SET_NAV_WIDTH', - navWidth - }) - }) + this.setState( + { + isLeftSliderFocused: false + }, + () => { + const { dispatch } = this.props + const navWidth = this.state.navWidth + // TODO: ConfigManager should dispatch itself. + ConfigManager.set({ navWidth }) + dispatch({ + type: 'SET_NAV_WIDTH', + navWidth + }) + } + ) } } @@ -234,8 +247,8 @@ class Main extends React.Component { if (this.state.isRightSliderFocused) { const offset = this.refs.body.getBoundingClientRect().left let newListWidth = e.pageX - offset - if (newListWidth < 10) { - newListWidth = 10 + if (newListWidth < 180) { + newListWidth = 180 } else if (newListWidth > 600) { newListWidth = 600 } @@ -271,8 +284,8 @@ class Main extends React.Component { } hideLeftLists (noteDetail, noteList, mainBody) { - this.setState({noteDetailWidth: noteDetail.style.left}) - this.setState({mainBodyWidth: mainBody.style.left}) + this.setState({ noteDetailWidth: noteDetail.style.left }) + this.setState({ mainBodyWidth: mainBody.style.left }) noteDetail.style.left = '0px' mainBody.style.left = '0px' noteList.style.display = 'none' @@ -294,64 +307,73 @@ class Main extends React.Component {
this.handleMouseMove(e)} - onMouseUp={(e) => this.handleMouseUp(e)} + onMouseMove={e => this.handleMouseMove(e)} + onMouseUp={e => this.handleMouseUp(e)} > {!config.isSideNavFolded && -
this.handleLeftSlideMouseDown(e)} +
this.handleLeftSlideMouseDown(e)} draggable='false' >
-
- } -
} +
- - -
this.handleRightSlideMouseDown(e)} +
this.handleRightSlideMouseDown(e)} draggable='false' >
x)(CSSModules(Main, styles)) +export default connect(x => x)(CSSModules(Main, styles)) diff --git a/browser/main/NewNoteButton/NewNoteButton.styl b/browser/main/NewNoteButton/NewNoteButton.styl index e8e4b5f0..75a9061c 100644 --- a/browser/main/NewNoteButton/NewNoteButton.styl +++ b/browser/main/NewNoteButton/NewNoteButton.styl @@ -79,3 +79,7 @@ body[data-theme="solarized-dark"] body[data-theme="monokai"] .root, .root--expanded background-color $ui-monokai-noteList-backgroundColor + +body[data-theme="dracula"] + .root, .root--expanded + background-color $ui-dracula-noteList-backgroundColor \ No newline at end of file diff --git a/browser/main/NewNoteButton/index.js b/browser/main/NewNoteButton/index.js index a9b8de58..115d9530 100644 --- a/browser/main/NewNoteButton/index.js +++ b/browser/main/NewNoteButton/index.js @@ -7,6 +7,7 @@ import modal from 'browser/main/lib/modal' import NewNoteModal from 'browser/main/modals/NewNoteModal' import eventEmitter from 'browser/main/lib/eventEmitter' import i18n from 'browser/lib/i18n' +import { createMarkdownNote, createSnippetNote } from 'browser/lib/newNote' const { remote } = require('electron') const { dialog } = remote @@ -20,35 +21,39 @@ 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, dispatch } = this.props + const { location, dispatch, match: { params }, config } = this.props const { storage, folder } = this.resolveTargetFolder() - - modal.open(NewNoteModal, { - storage: storage.key, - folder: folder.key, - dispatch, - location - }) + if (config.ui.defaultNote === 'MARKDOWN_NOTE') { + createMarkdownNote(storage.key, folder.key, dispatch, location, params, config) + } else if (config.ui.defaultNote === 'SNIPPET_NOTE') { + createSnippetNote(storage.key, folder.key, dispatch, location, params, config) + } else { + modal.open(NewNoteModal, { + storage: storage.key, + folder: folder.key, + dispatch, + location, + params, + config + }) + } } 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) { @@ -84,7 +89,7 @@ class NewNoteButton extends React.Component { >
diff --git a/browser/main/global.styl b/browser/main/global.styl index 7025163f..d864993d 100644 --- a/browser/main/global.styl +++ b/browser/main/global.styl @@ -15,6 +15,15 @@ body font-weight 200 -webkit-font-smoothing antialiased +::-webkit-scrollbar + width 12px + +::-webkit-scrollbar-corner + background-color: transparent; + +::-webkit-scrollbar-thumb + background-color rgba(0, 0, 0, 0.15) + button, input, select, textarea font-family DEFAULT_FONTS @@ -85,9 +94,12 @@ modalBackColor = white absolute top left bottom right background-color modalBackColor z-index modalZIndex + 1 - + body[data-theme="dark"] + background-color $ui-dark-backgroundColor + ::-webkit-scrollbar-thumb + background-color rgba(0, 0, 0, 0.3) .ModalBase .modalBack background-color $ui-dark-backgroundColor @@ -124,10 +136,22 @@ body[data-theme="dark"] .CodeMirror-foldgutter-folded:after content: "\25B8" +.CodeMirror-hover + padding 2px 4px 0 4px + position absolute + z-index 99 + +.CodeMirror-hyperlink + cursor pointer + + .sortableItemHelper 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 .modalBack background-color $ui-solarized-dark-backgroundColor @@ -135,9 +159,27 @@ 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 .modalBack background-color $ui-monokai-backgroundColor .sortableItemHelper 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 + .modalBack + background-color $ui-dracula-backgroundColor + .sortableItemHelper + color: $ui-dracula-text-color + +body[data-theme="default"] + .SideNav ::-webkit-scrollbar-thumb + background-color rgba(255, 255, 255, 0.3) + +@import '../styles/Detail/TagSelect.styl' \ No newline at end of file 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/AwsMobileAnalyticsConfig.js b/browser/main/lib/AwsMobileAnalyticsConfig.js index 1ef4f8da..e4a21a92 100644 --- a/browser/main/lib/AwsMobileAnalyticsConfig.js +++ b/browser/main/lib/AwsMobileAnalyticsConfig.js @@ -45,7 +45,6 @@ function initAwsMobileAnalytics () { if (getSendEventCond()) return AWS.config.credentials.get((err) => { if (!err) { - console.log('Cognito Identity ID: ' + AWS.config.credentials.identityId) recordDynamicCustomEvent('APP_STARTED') recordStaticCustomEvent() } @@ -58,7 +57,7 @@ function recordDynamicCustomEvent (type, options = {}) { mobileAnalyticsClient.recordEvent(type, options) } catch (analyticsError) { if (analyticsError instanceof ReferenceError) { - console.log(analyticsError.name + ': ' + analyticsError.message) + console.error(analyticsError.name + ': ' + analyticsError.message) } } } @@ -71,7 +70,7 @@ function recordStaticCustomEvent () { }) } catch (analyticsError) { if (analyticsError instanceof ReferenceError) { - console.log(analyticsError.name + ': ' + analyticsError.message) + console.error(analyticsError.name + ': ' + analyticsError.message) } } } diff --git a/browser/main/lib/ConfigManager.js b/browser/main/lib/ConfigManager.js index 228692d6..bea019fa 100644 --- a/browser/main/lib/ConfigManager.js +++ b/browser/main/lib/ConfigManager.js @@ -11,40 +11,61 @@ const consts = require('browser/lib/consts') let isInitialized = false +const DEFAULT_MARKDOWN_LINT_CONFIG = `{ + "default": true +}` + export const DEFAULT_CONFIG = { zoom: 1, isSideNavFolded: false, listWidth: 280, navWidth: 200, - sortBy: 'UPDATED_AT', // 'CREATED_AT', 'UPDATED_AT', 'APLHABETICAL' + sortBy: { + default: 'UPDATED_AT' // 'CREATED_AT', 'UPDATED_AT', 'APLHABETICAL' + }, sortTagsBy: 'ALPHABETICAL', // 'ALPHABETICAL', 'COUNTER' listStyle: 'DEFAULT', // 'DEFAULT', 'SMALL' amaEnabled: true, hotkey: { - toggleMain: OSX ? 'Cmd + Alt + L' : 'Super + Alt + E', - toggleMode: OSX ? 'Cmd + M' : 'Ctrl + M' + 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', + 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', enableRulers: false, rulers: [80, 120], displayLineNumbers: true, - switchPreview: 'BLUR', // Available value: RIGHTCLICK, BLUR + matchingPairs: '()[]{}\'\'""$$**``~~__', + matchingTriples: '```"""\'\'\'', + explodingPairs: '[]{}``$$', + switchPreview: 'BLUR', // 'BLUR', 'DBL_CLICK', 'RIGHTCLICK' + delfaultStatus: 'PREVIEW', // 'PREVIEW', 'CODE' scrollPastEnd: false, - type: 'SPLIT', - fetchUrlTitle: true + type: 'SPLIT', // 'SPLIT', 'EDITOR_PREVIEW' + fetchUrlTitle: true, + enableTableEditor: false, + enableFrontMatterTitle: true, + frontMatterTitleField: 'title', + spellcheck: false, + enableSmartPaste: false, + enableMarkdownLint: false, + customMarkdownLintConfig: DEFAULT_MARKDOWN_LINT_CONFIG }, preview: { fontSize: '14', @@ -57,9 +78,14 @@ export const DEFAULT_CONFIG = { latexBlockClose: '$$', plantUMLServerAddress: 'http://www.plantuml.com/plantuml', scrollPastEnd: false, + scrollSync: true, smartQuotes: true, breaks: true, - sanitize: 'STRICT' // 'STRICT', 'ALLOW_STYLES', 'NONE' + smartArrows: false, + allowCustomCSS: false, + customCSS: '', + sanitize: 'STRICT', // 'STRICT', 'ALLOW_STYLES', 'NONE' + lineThroughCheckbox: true }, blog: { type: 'wordpress', // Available value: wordpress, add more types in the future plz @@ -68,7 +94,8 @@ export const DEFAULT_CONFIG = { token: '', username: '', password: '' - } + }, + coloredTags: {} } function validate (config) { @@ -111,16 +138,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' } } @@ -141,6 +164,8 @@ function set (updates) { document.body.setAttribute('data-theme', 'solarized-dark') } else if (newConfig.ui.theme === 'monokai') { document.body.setAttribute('data-theme', 'monokai') + } else if (newConfig.ui.theme === 'dracula') { + document.body.setAttribute('data-theme', 'dracula') } else { document.body.setAttribute('data-theme', 'default') } @@ -154,16 +179,11 @@ 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}`) } ipcRenderer.send('config-renew', { @@ -179,6 +199,18 @@ function assignConfigValues (originalConfig, rcConfig) { config.ui = Object.assign({}, DEFAULT_CONFIG.ui, originalConfig.ui, rcConfig.ui) config.editor = Object.assign({}, DEFAULT_CONFIG.editor, originalConfig.editor, rcConfig.editor) config.preview = Object.assign({}, DEFAULT_CONFIG.preview, originalConfig.preview, rcConfig.preview) + + rewriteHotkey(config) + + return config +} + +function rewriteHotkey (config) { + const keys = [...Object.keys(config.hotkey)] + keys.forEach(key => { + config.hotkey[key] = config.hotkey[key].replace(/Cmd\s/g, 'Command ') + config.hotkey[key] = config.hotkey[key].replace(/Opt\s/g, 'Option ') + }) return config } diff --git a/browser/main/lib/dataApi/addStorage.js b/browser/main/lib/dataApi/addStorage.js index 630c0bd3..bfd6698a 100644 --- a/browser/main/lib/dataApi/addStorage.js +++ b/browser/main/lib/dataApi/addStorage.js @@ -37,7 +37,8 @@ function addStorage (input) { key, name: input.name, type: input.type, - path: input.path + path: input.path, + isOpen: false } return Promise.resolve(newStorage) @@ -48,7 +49,8 @@ function addStorage (input) { key: newStorage.key, type: newStorage.type, name: newStorage.name, - path: newStorage.path + path: newStorage.path, + isOpen: false }) localStorage.setItem('storages', JSON.stringify(rawStorages)) diff --git a/browser/main/lib/dataApi/attachmentManagement.js b/browser/main/lib/dataApi/attachmentManagement.js index 893e03d1..d92a1eb4 100644 --- a/browser/main/lib/dataApi/attachmentManagement.js +++ b/browser/main/lib/dataApi/attachmentManagement.js @@ -6,9 +6,132 @@ 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' const STORAGE_FOLDER_PLACEHOLDER = ':storage' const DESTINATION_FOLDER = 'attachments' +const PATH_SEPARATORS = escapeStringRegexp(path.posix.sep) + escapeStringRegexp(path.win32.sep) +/** + * @description + * Create a Image element to get the real size of image. + * @param {File} file the File object dropped. + * @returns {Promise} Image element created + */ +function getImage (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) + }) + } +} + +/** + * @description + * Get the orientation info from iamges's EXIF data. + * case 1: The 0th row is at the visual top of the image, and the 0th column is the visual left-hand side. + * case 2: The 0th row is at the visual top of the image, and the 0th column is the visual right-hand side. + * case 3: The 0th row is at the visual bottom of the image, and the 0th column is the visual right-hand side. + * case 4: The 0th row is at the visual bottom of the image, and the 0th column is the visual left-hand side. + * case 5: The 0th row is the visual left-hand side of the image, and the 0th column is the visual top. + * case 6: The 0th row is the visual right-hand side of the image, and the 0th column is the visual top. + * case 7: The 0th row is the visual right-hand side of the image, and the 0th column is the visual bottom. + * case 8: The 0th row is the visual left-hand side of the image, and the 0th column is the visual bottom. + * Other: reserved + * ref: http://sylvana.net/jpegcrop/exif_orientation.html + * @param {File} file the File object dropped. + * @returns {Promise} Orientation info + */ +function getOrientation (file) { + const getData = arrayBuffer => { + const view = new DataView(arrayBuffer) + + // Not start with SOI(Start of image) Marker return fail value + if (view.getUint16(0, false) !== 0xFFD8) return -2 + const length = view.byteLength + let offset = 2 + while (offset < length) { + const marker = view.getUint16(offset, false) + offset += 2 + // Loop and seed for APP1 Marker + if (marker === 0xFFE1) { + // return fail value if it isn't EXIF data + if (view.getUint32(offset += 2, false) !== 0x45786966) { + return -1 + } + // Read TIFF header, + // First 2bytes defines byte align of TIFF data. + // If it is 0x4949="II", it means "Intel" type byte align. + // If it is 0x4d4d="MM", it means "Motorola" type byte align + const little = view.getUint16(offset += 6, false) === 0x4949 + offset += view.getUint32(offset + 4, little) + const tags = view.getUint16(offset, little) // Get TAG number + offset += 2 + for (let i = 0; i < tags; i++) { + // Loop to find Orientation TAG and return the value + if (view.getUint16(offset + (i * 12), little) === 0x0112) { + return view.getUint16(offset + (i * 12) + 8, little) + } + } + } else if ((marker & 0xFF00) !== 0xFF00) { // If not start with 0xFF, not a Marker. + break + } else { + offset += view.getUint16(offset, false) + } + } + return -1 + } + return new Promise((resolve) => { + const reader = new FileReader() + reader.onload = event => resolve(getData(event.target.result)) + reader.readAsArrayBuffer(file.slice(0, 64 * 1024)) + }) +} +/** + * @description + * Rotate image file to correct direction. + * Create a canvas and draw the image with correct direction, then export to base64 format. + * @param {*} file the File object dropped. + * @return {String} Base64 encoded image. + */ +function fixRotate (file) { + return Promise.all([getImage(file), getOrientation(file)]) + .then(([img, orientation]) => { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + if (orientation > 4 && orientation < 9) { + canvas.width = img.height + canvas.height = img.width + } else { + canvas.width = img.width + canvas.height = img.height + } + switch (orientation) { + case 2: ctx.transform(-1, 0, 0, 1, img.width, 0); break + case 3: ctx.transform(-1, 0, 0, -1, img.width, img.height); break + case 4: ctx.transform(1, 0, 0, -1, 0, img.height); break + case 5: ctx.transform(0, 1, 1, 0, 0, 0); break + case 6: ctx.transform(0, 1, -1, 0, img.height, 0); break + case 7: ctx.transform(0, -1, -1, 0, img.height, img.width); break + case 8: ctx.transform(0, -1, 1, 0, 0, img.width); break + default: break + } + ctx.drawImage(img, 0, 0) + return canvas.toDataURL() + }) +} /** * @description @@ -36,26 +159,39 @@ function copyAttachment (sourceFilePath, storageKey, noteKey, useRandomName = tr } try { - if (!fs.existsSync(sourceFilePath)) { - reject('source file does not exist') + const isBase64 = typeof sourceFilePath === 'object' && sourceFilePath.type === 'base64' + if (!isBase64 && !fs.existsSync(sourceFilePath)) { + return reject('source file does not exist') + } + + const sourcePath = sourceFilePath.sourceFilePath || sourceFilePath + const sourceURL = url.parse(/^\w+:\/\//.test(sourcePath) ? sourcePath : 'file:///' + sourcePath) + + let destinationName + if (useRandomName) { + destinationName = `${uniqueSlug()}${path.extname(sourceURL.pathname) || '.png'}` + } else { + destinationName = path.basename(sourceURL.pathname) } const targetStorage = findStorage.findStorage(storageKey) - - const inputFileStream = fs.createReadStream(sourceFilePath) - let destinationName - if (useRandomName) { - destinationName = `${uniqueSlug()}${path.extname(sourceFilePath)}` - } else { - destinationName = path.basename(sourceFilePath) - } const destinationDir = path.join(targetStorage.path, DESTINATION_FOLDER, noteKey) createAttachmentDestinationFolder(targetStorage.path, noteKey) const outputFile = fs.createWriteStream(path.join(destinationDir, destinationName)) - inputFileStream.pipe(outputFile) - inputFileStream.on('end', () => { - resolve(destinationName) - }) + + if (isBase64) { + const base64Data = sourceFilePath.data.replace(/^data:image\/\w+;base64,/, '') + const dataBuffer = Buffer.from(base64Data, 'base64') + outputFile.write(dataBuffer, () => { + resolve(destinationName) + }) + } else { + const inputFileStream = fs.createReadStream(sourceFilePath) + inputFileStream.pipe(outputFile) + inputFileStream.on('end', () => { + resolve(destinationName) + }) + } } catch (e) { return reject(e) } @@ -73,6 +209,31 @@ function createAttachmentDestinationFolder (destinationStoragePath, noteKey) { } } +/** + * @description Moves attachments from the old location ('/images') to the new one ('/attachments/noteKey) + * @param markdownContent of the current note + * @param storagePath Storage path of the current note + * @param noteKey Key of the current note + */ +function migrateAttachments (markdownContent, storagePath, noteKey) { + if (noteKey !== undefined && sander.existsSync(path.join(storagePath, 'images'))) { + const attachments = getAttachmentsInMarkdownContent(markdownContent) || [] + if (attachments.length) { + createAttachmentDestinationFolder(storagePath, noteKey) + } + for (const attachment of attachments) { + const attachmentBaseName = path.basename(attachment) + const possibleLegacyPath = path.join(storagePath, 'images', attachmentBaseName) + if (sander.existsSync(possibleLegacyPath)) { + const destinationPath = path.join(storagePath, DESTINATION_FOLDER, attachmentBaseName) + if (!sander.existsSync(destinationPath)) { + sander.copyFileSync(possibleLegacyPath).to(destinationPath) + } + } + } + } +} + /** * @description Fixes the URLs embedded in the generated HTML so that they again refer actual local files. * @param {String} renderedHTML HTML in that the links should be fixed @@ -80,7 +241,18 @@ function createAttachmentDestinationFolder (destinationStoragePath, noteKey) { * @returns {String} postprocessed HTML in which all :storage references are mapped to the actual paths. */ function fixLocalURLS (renderedHTML, storagePath) { - return renderedHTML.replace(new RegExp(mdurl.encode(path.sep), 'g'), path.sep).replace(new RegExp(STORAGE_FOLDER_PLACEHOLDER, 'g'), 'file:///' + path.join(storagePath, DESTINATION_FOLDER)) + /* + 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)` 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)) + }) } /** @@ -103,15 +275,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'] + let promise + 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 { + let imageURL = dropEvent.dataTransfer.getData('text/plain') - copyAttachment(filePath, storageKey, noteKey).then((fileName) => { - const showPreview = fileType.startsWith('image') - const imageMd = generateAttachmentMarkdown(originalFileName, path.join(STORAGE_FOLDER_PLACEHOLDER, noteKey, fileName), showPreview) - codeEditor.insertAttachmentMd(imageMd) + 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(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')) }) } @@ -122,7 +366,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') } @@ -152,20 +396,59 @@ function handlePastImageEvent (codeEditor, storageKey, noteKey, dataTransferItem base64data += base64data.replace('+', ' ') const binaryData = new Buffer(base64data, 'base64').toString('binary') fs.writeFileSync(imagePath, binaryData, 'binary') - const imageMd = generateAttachmentMarkdown(imageName, imagePath, true) + const imageReferencePath = path.join(STORAGE_FOLDER_PLACEHOLDER, noteKey, imageName) + const imageMd = generateAttachmentMarkdown(imageName, imageReferencePath, true) codeEditor.insertAttachmentMd(imageMd) } reader.readAsDataURL(blob) } /** - * @description Returns all attachment paths of the given markdown - * @param {String} markdownContent content in which the attachment paths should be found - * @returns {String[]} Array of the relative paths (starting with :storage) of the attachments of the given markdown + * @description Creates a new file in the storage folder belonging to the current note and inserts the correct markdown code + * @param {CodeEditor} codeEditor Markdown editor. Its insertAttachmentMd() method will be called to include the markdown code + * @param {String} storageKey Key of the current storage + * @param {String} noteKey Key of the current note + * @param {NativeImage} image The native image */ -function getAttachmentsInContent (markdownContent) { - const preparedInput = markdownContent.replace(new RegExp(mdurl.encode(path.sep), 'g'), path.sep) - const regexp = new RegExp(STORAGE_FOLDER_PLACEHOLDER + escapeStringRegexp(path.sep) + '([a-zA-Z0-9]|-)+' + escapeStringRegexp(path.sep) + '[a-zA-Z0-9]+(\\.[a-zA-Z0-9]+)?', 'g') +function handlePasteNativeImage (codeEditor, storageKey, noteKey, image) { + if (!codeEditor) { + throw new Error('codeEditor has to be given') + } + if (!storageKey) { + throw new Error('storageKey has to be given') + } + + if (!noteKey) { + throw new Error('noteKey has to be given') + } + if (!image) { + throw new Error('image has to be given') + } + + const targetStorage = findStorage.findStorage(storageKey) + const destinationDir = path.join(targetStorage.path, DESTINATION_FOLDER, noteKey) + + createAttachmentDestinationFolder(targetStorage.path, noteKey) + + const imageName = `${uniqueSlug()}.png` + const imagePath = path.join(destinationDir, imageName) + + const binaryData = image.toPNG() + fs.writeFileSync(imagePath, binaryData, 'binary') + + const imageReferencePath = path.join(STORAGE_FOLDER_PLACEHOLDER, noteKey, imageName) + const imageMd = generateAttachmentMarkdown(imageName, imageReferencePath, true) + codeEditor.insertAttachmentMd(imageMd) +} + +/** +* @description Returns all attachment paths of the given markdown +* @param {String} markdownContent content in which the attachment paths should be found +* @returns {String[]} Array of the relative paths (starting with :storage) of the attachments of the given markdown +*/ +function getAttachmentsInMarkdownContent (markdownContent) { + const preparedInput = markdownContent.replace(new RegExp('[' + PATH_SEPARATORS + ']', 'g'), path.sep) + const regexp = new RegExp('/?' + STORAGE_FOLDER_PLACEHOLDER + '(' + escapeStringRegexp(path.sep) + ')' + '?([a-zA-Z0-9]|-)*' + '(' + escapeStringRegexp(path.sep) + ')' + '([a-zA-Z0-9]|\\.)+(\\.[a-zA-Z0-9]+)?', 'g') return preparedInput.match(regexp) } @@ -176,7 +459,7 @@ function getAttachmentsInContent (markdownContent) { * @returns {String[]} Absolute paths of the referenced attachments */ function getAbsolutePathsOfAttachmentsInContent (markdownContent, storagePath) { - const temp = getAttachmentsInContent(markdownContent) || [] + const temp = getAttachmentsInMarkdownContent(markdownContent) || [] const result = [] for (const relativePath of temp) { result.push(relativePath.replace(new RegExp(STORAGE_FOLDER_PLACEHOLDER, 'g'), path.join(storagePath, DESTINATION_FOLDER))) @@ -184,6 +467,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. @@ -212,7 +543,8 @@ function moveAttachments (oldPath, newPath, noteKey, newNoteKey, noteContent) { */ function replaceNoteKeyWithNewNoteKey (noteContent, oldNoteKey, newNoteKey) { if (noteContent) { - return noteContent.replace(new RegExp(STORAGE_FOLDER_PLACEHOLDER + escapeStringRegexp(path.sep) + oldNoteKey, 'g'), path.join(STORAGE_FOLDER_PLACEHOLDER, newNoteKey)) + const preparedInput = noteContent.replace(new RegExp('[' + PATH_SEPARATORS + ']', 'g'), path.sep) + return preparedInput.replace(new RegExp(STORAGE_FOLDER_PLACEHOLDER + escapeStringRegexp(path.sep) + oldNoteKey, 'g'), path.join(STORAGE_FOLDER_PLACEHOLDER, newNoteKey)) } return noteContent } @@ -224,7 +556,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) + }) } /** @@ -245,20 +584,22 @@ function deleteAttachmentFolder (storageKey, noteKey) { * @param noteKey NoteKey of the current note. Is used to determine the belonging attachment folder. */ function deleteAttachmentsNotPresentInNote (markdownContent, storageKey, noteKey) { + if (storageKey == null || noteKey == null || markdownContent == null) { + return + } const targetStorage = findStorage.findStorage(storageKey) const attachmentFolder = path.join(targetStorage.path, DESTINATION_FOLDER, noteKey) - const attachmentsInNote = getAttachmentsInContent(markdownContent) + const attachmentsInNote = getAttachmentsInMarkdownContent(markdownContent) const attachmentsInNoteOnlyFileNames = [] if (attachmentsInNote) { for (let i = 0; i < attachmentsInNote.length; i++) { attachmentsInNoteOnlyFileNames.push(attachmentsInNote[i].replace(new RegExp(STORAGE_FOLDER_PLACEHOLDER + escapeStringRegexp(path.sep) + noteKey + escapeStringRegexp(path.sep), 'g'), '')) } } - if (fs.existsSync(attachmentFolder)) { fs.readdir(attachmentFolder, (err, files) => { if (err) { - console.error("Error reading directory '" + attachmentFolder + "'. Error:") + console.error('Error reading directory "' + attachmentFolder + '". Error:') console.error(err) return } @@ -267,17 +608,17 @@ function deleteAttachmentsNotPresentInNote (markdownContent, storageKey, noteKey const absolutePathOfFile = path.join(targetStorage.path, DESTINATION_FOLDER, noteKey, file) fs.unlink(absolutePathOfFile, (err) => { if (err) { - console.error("Could not delete '%s'", absolutePathOfFile) + console.error('Could not delete "%s"', absolutePathOfFile) console.error(err) return } - console.info("File '" + absolutePathOfFile + "' deleted because it was not included in the content of the note") + console.info('File "' + absolutePathOfFile + '" deleted because it was not included in the content of the note') }) } }) }) } else { - console.info("Attachment folder ('" + attachmentFolder + "') did not exist..") + console.info('Attachment folder ("' + attachmentFolder + '") did not exist..') } } @@ -308,19 +649,89 @@ function cloneAttachments (oldNote, newNote) { } } +function generateFileNotFoundMarkdown () { + return '**' + i18n.__('⚠ You have pasted a link referring an attachment that could not be found in the storage location of this note. Pasting links referring attachments is only supported if the source and destination location is the same storage. Please Drag&Drop the attachment instead! ⚠') + '**' +} + +/** + * Determines whether a given text is a link to an boostnote attachment + * @param text Text that might contain a attachment link + * @return {Boolean} Result of the test + */ +function isAttachmentLink (text) { + if (text) { + return text.match(new RegExp('.*\\[.*\\]\\( *' + escapeStringRegexp(STORAGE_FOLDER_PLACEHOLDER) + '[' + PATH_SEPARATORS + ']' + '.*\\).*', 'gi')) != null + } + return false +} + +/** + * @description Handles the paste of an attachment link. Copies the referenced attachment to the location belonging to the new note. + * Returns a modified version of the pasted text so that it matches the copied attachment (resp. the new location) + * @param storageKey StorageKey of the current note + * @param noteKey NoteKey of the currentNote + * @param linkText Text that was pasted + * @return {Promise} Promise returning the modified text + */ +function handleAttachmentLinkPaste (storageKey, noteKey, linkText) { + if (storageKey != null && noteKey != null && linkText != null) { + const storagePath = findStorage.findStorage(storageKey).path + const attachments = getAttachmentsInMarkdownContent(linkText) || [] + const replaceInstructions = [] + const copies = [] + for (const attachment of attachments) { + const absPathOfAttachment = attachment.replace(new RegExp(STORAGE_FOLDER_PLACEHOLDER, 'g'), path.join(storagePath, DESTINATION_FOLDER)) + copies.push( + sander.exists(absPathOfAttachment) + .then((fileExists) => { + if (!fileExists) { + const fileNotFoundRegexp = new RegExp('!?' + escapeStringRegexp('[') + '[\\w|\\d|\\s|\\.]*\\]\\(\\s*' + STORAGE_FOLDER_PLACEHOLDER + '[\\w|\\d|\\-|' + PATH_SEPARATORS + ']*' + escapeStringRegexp(path.basename(absPathOfAttachment)) + escapeStringRegexp(')')) + replaceInstructions.push({regexp: fileNotFoundRegexp, replacement: this.generateFileNotFoundMarkdown()}) + return Promise.resolve() + } + return this.copyAttachment(absPathOfAttachment, storageKey, noteKey) + .then((fileName) => { + const replaceLinkRegExp = new RegExp(escapeStringRegexp('(') + ' *' + STORAGE_FOLDER_PLACEHOLDER + '[\\w|\\d|\\-|' + PATH_SEPARATORS + ']*' + escapeStringRegexp(path.basename(absPathOfAttachment)) + ' *' + escapeStringRegexp(')')) + replaceInstructions.push({ + regexp: replaceLinkRegExp, + replacement: '(' + path.join(STORAGE_FOLDER_PLACEHOLDER, noteKey, fileName) + ')' + }) + return Promise.resolve() + }) + }) + ) + } + return Promise.all(copies).then(() => { + let modifiedLinkText = linkText + for (const replaceInstruction of replaceInstructions) { + modifiedLinkText = modifiedLinkText.replace(replaceInstruction.regexp, replaceInstruction.replacement) + } + return modifiedLinkText + }) + } else { + return Promise.resolve(linkText) + } +} + module.exports = { copyAttachment, fixLocalURLS, generateAttachmentMarkdown, handleAttachmentDrop, - handlePastImageEvent, - getAttachmentsInContent, + handlePasteImageEvent, + handlePasteNativeImage, + getAttachmentsInMarkdownContent, getAbsolutePathsOfAttachmentsInContent, + importAttachments, removeStorageAndNoteReferences, deleteAttachmentFolder, deleteAttachmentsNotPresentInNote, moveAttachments, cloneAttachments, + isAttachmentLink, + handleAttachmentLinkPaste, + generateFileNotFoundMarkdown, + migrateAttachments, STORAGE_FOLDER_PLACEHOLDER, DESTINATION_FOLDER } diff --git a/browser/main/lib/dataApi/copyFile.js b/browser/main/lib/dataApi/copyFile.js index 2dc66309..6f23aae2 100755 --- a/browser/main/lib/dataApi/copyFile.js +++ b/browser/main/lib/dataApi/copyFile.js @@ -16,7 +16,7 @@ function copyFile (srcPath, dstPath) { const dstFolder = path.dirname(dstPath) if (!fs.existsSync(dstFolder)) fs.mkdirSync(dstFolder) - const input = fs.createReadStream(srcPath) + const input = fs.createReadStream(decodeURI(srcPath)) const output = fs.createWriteStream(dstPath) output.on('error', reject) diff --git a/browser/main/lib/dataApi/createNote.js b/browser/main/lib/dataApi/createNote.js index e5d44489..5bfa2457 100644 --- a/browser/main/lib/dataApi/createNote.js +++ b/browser/main/lib/dataApi/createNote.js @@ -16,6 +16,7 @@ function validateInput (input) { switch (input.type) { case 'MARKDOWN_NOTE': if (!_.isString(input.content)) input.content = '' + if (!_.isArray(input.linesHighlighted)) input.linesHighlighted = [] break case 'SNIPPET_NOTE': if (!_.isString(input.description)) input.description = '' @@ -23,7 +24,8 @@ function validateInput (input) { input.snippets = [{ name: '', mode: 'text', - content: '' + content: '', + linesHighlighted: [] }] } break diff --git a/browser/main/lib/dataApi/createSnippet.js b/browser/main/lib/dataApi/createSnippet.js new file mode 100644 index 00000000..2e585c9f --- /dev/null +++ b/browser/main/lib/dataApi/createSnippet.js @@ -0,0 +1,27 @@ +import fs from 'fs' +import crypto from 'crypto' +import consts from 'browser/lib/consts' +import fetchSnippet from 'browser/main/lib/dataApi/fetchSnippet' + +function createSnippet (snippetFile) { + return new Promise((resolve, reject) => { + const newSnippet = { + id: crypto.randomBytes(16).toString('hex'), + name: 'Unnamed snippet', + prefix: [], + content: '', + linesHighlighted: [] + } + fetchSnippet(null, snippetFile).then((snippets) => { + snippets.push(newSnippet) + fs.writeFile(snippetFile || consts.SNIPPET_FILE, JSON.stringify(snippets, null, 4), (err) => { + if (err) reject(err) + resolve(newSnippet) + }) + }).catch((err) => { + reject(err) + }) + }) +} + +module.exports = createSnippet diff --git a/browser/main/lib/dataApi/deleteSnippet.js b/browser/main/lib/dataApi/deleteSnippet.js new file mode 100644 index 00000000..0e446886 --- /dev/null +++ b/browser/main/lib/dataApi/deleteSnippet.js @@ -0,0 +1,17 @@ +import fs from 'fs' +import consts from 'browser/lib/consts' +import fetchSnippet from 'browser/main/lib/dataApi/fetchSnippet' + +function deleteSnippet (snippet, snippetFile) { + return new Promise((resolve, reject) => { + fetchSnippet(null, snippetFile).then((snippets) => { + snippets = snippets.filter(currentSnippet => currentSnippet.id !== snippet.id) + fs.writeFile(snippetFile || consts.SNIPPET_FILE, JSON.stringify(snippets, null, 4), (err) => { + if (err) reject(err) + resolve(snippet) + }) + }) + }) +} + +module.exports = deleteSnippet diff --git a/browser/main/lib/dataApi/exportFolder.js b/browser/main/lib/dataApi/exportFolder.js index 3e998f15..8f15b147 100644 --- a/browser/main/lib/dataApi/exportFolder.js +++ b/browser/main/lib/dataApi/exportFolder.js @@ -1,9 +1,9 @@ import { findStorage } from 'browser/lib/findStorage' import resolveStorageData from './resolveStorageData' import resolveStorageNotes from './resolveStorageNotes' +import exportNote from './exportNote' import filenamify from 'filenamify' import * as path from 'path' -import * as fs from 'fs' /** * @param {String} storageKey @@ -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(snippet => { - const notePath = path.join(exportDir, `${filenamify(snippet.title, {replacement: '_'})}.${fileType}`) - fs.writeFileSync(notePath, snippet.content) + .map(note => { + const notePath = path.join(exportDir, `${filenamify(note.title, {replacement: '_'})}.${fileType}`) + 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 e4fec5f4..75c451c1 100755 --- a/browser/main/lib/dataApi/exportNote.js +++ b/browser/main/lib/dataApi/exportNote.js @@ -4,37 +4,56 @@ import { findStorage } from 'browser/lib/findStorage' const fs = require('fs') const path = require('path') +const attachmentManagement = require('./attachmentManagement') + /** - * Export note together with images + * Export note together with attachments * - * If images is stored in the storage, creates 'images' subfolder in target directory - * and copies images to it. Changes links to images in the content of the note + * If attachments are stored in the storage, creates 'attachments' subfolder in target directory + * and copies attachments to it. Changes links to images in the content of the note * + * @param {String} nodeKey key of the node that should be exported * @param {String} storageKey or storage path * @param {String} noteContent Content to export * @param {String} targetPath Path to exported file * @param {function} outputFormatter * @return {Promise.<*[]>} */ -function exportNote (storageKey, noteContent, targetPath, outputFormatter) { +function exportNote (nodeKey, storageKey, noteContent, targetPath, outputFormatter) { const storagePath = path.isAbsolute(storageKey) ? storageKey : findStorage(storageKey).path const exportTasks = [] if (!storagePath) { throw new Error('Storage path is not found') } + const attachmentsAbsolutePaths = attachmentManagement.getAbsolutePathsOfAttachmentsInContent( + noteContent, + storagePath + ) + attachmentsAbsolutePaths.forEach(attachment => { + exportTasks.push({ + src: attachment, + dst: attachmentManagement.DESTINATION_FOLDER + }) + }) - let exportedData = noteContent + let exportedData = attachmentManagement.removeStorageAndNoteReferences( + noteContent, + nodeKey + ) if (outputFormatter) { - exportedData = outputFormatter(exportedData, exportTasks) + exportedData = outputFormatter(exportedData, exportTasks, path.dirname(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/exportStorage.js b/browser/main/lib/dataApi/exportStorage.js new file mode 100644 index 00000000..ce2c4573 --- /dev/null +++ b/browser/main/lib/dataApi/exportStorage.js @@ -0,0 +1,63 @@ +import { findStorage } from 'browser/lib/findStorage' +import resolveStorageData from './resolveStorageData' +import resolveStorageNotes from './resolveStorageNotes' +import filenamify from 'filenamify' +import * as path from 'path' +import * as fs from 'fs' + +/** + * @param {String} storageKey + * @param {String} fileType + * @param {String} exportDir + * + * @return {Object} + * ``` + * { + * storage: Object, + * fileType: String, + * exportDir: String + * } + * ``` + */ + +function exportStorage (storageKey, fileType, exportDir) { + let targetStorage + try { + targetStorage = findStorage(storageKey) + } catch (e) { + return Promise.reject(e) + } + + return resolveStorageData(targetStorage) + .then(storage => ( + resolveStorageNotes(storage).then(notes => ({storage, notes})) + )) + .then(function exportNotes (data) { + const { storage, notes } = data + const folderNamesMapping = {} + storage.folders.forEach(folder => { + const folderExportedDir = path.join(exportDir, filenamify(folder.name, {replacement: '_'})) + folderNamesMapping[folder.key] = folderExportedDir + // make sure directory exists + try { + fs.mkdirSync(folderExportedDir) + } catch (e) {} + }) + notes + .filter(note => !note.isTrashed && note.type === 'MARKDOWN_NOTE') + .forEach(markdownNote => { + const folderExportedDir = folderNamesMapping[markdownNote.folder] + const snippetName = `${filenamify(markdownNote.title, {replacement: '_'})}.${fileType}` + const notePath = path.join(folderExportedDir, snippetName) + fs.writeFileSync(notePath, markdownNote.content) + }) + + return { + storage, + fileType, + exportDir + } + }) +} + +module.exports = exportStorage diff --git a/browser/main/lib/dataApi/fetchSnippet.js b/browser/main/lib/dataApi/fetchSnippet.js new file mode 100644 index 00000000..456a5090 --- /dev/null +++ b/browser/main/lib/dataApi/fetchSnippet.js @@ -0,0 +1,20 @@ +import fs from 'fs' +import consts from 'browser/lib/consts' + +function fetchSnippet (id, snippetFile) { + return new Promise((resolve, reject) => { + fs.readFile(snippetFile || consts.SNIPPET_FILE, 'utf8', (err, data) => { + if (err) { + reject(err) + } + const snippets = JSON.parse(data) + if (id) { + const snippet = snippets.find(snippet => { return snippet.id === id }) + resolve(snippet) + } + resolve(snippets) + }) + }) +} + +module.exports = fetchSnippet diff --git a/browser/main/lib/dataApi/index.js b/browser/main/lib/dataApi/index.js index ca54d148..6e88bbf9 100644 --- a/browser/main/lib/dataApi/index.js +++ b/browser/main/lib/dataApi/index.js @@ -1,5 +1,6 @@ const dataApi = { init: require('./init'), + toggleStorage: require('./toggleStorage'), addStorage: require('./addStorage'), renameStorage: require('./renameStorage'), removeStorage: require('./removeStorage'), @@ -8,12 +9,17 @@ const dataApi = { deleteFolder: require('./deleteFolder'), reorderFolder: require('./reorderFolder'), exportFolder: require('./exportFolder'), + exportStorage: require('./exportStorage'), createNote: require('./createNote'), createNoteFromUrl: require('./createNoteFromUrl'), updateNote: require('./updateNote'), deleteNote: require('./deleteNote'), moveNote: require('./moveNote'), migrateFromV5Storage: require('./migrateFromV5Storage'), + createSnippet: require('./createSnippet'), + deleteSnippet: require('./deleteSnippet'), + updateSnippet: require('./updateSnippet'), + fetchSnippet: require('./fetchSnippet'), _migrateFromV6Storage: require('./migrateFromV6Storage'), _resolveStorageData: require('./resolveStorageData'), diff --git a/browser/main/lib/dataApi/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/migrateFromV5Storage.js b/browser/main/lib/dataApi/migrateFromV5Storage.js index b11e66e9..78d78746 100644 --- a/browser/main/lib/dataApi/migrateFromV5Storage.js +++ b/browser/main/lib/dataApi/migrateFromV5Storage.js @@ -69,7 +69,8 @@ function importAll (storage, data) { isStarred: false, title: article.title, content: '# ' + article.title + '\n\n' + article.content, - key: noteKey + key: noteKey, + linesHighlighted: article.linesHighlighted } notes.push(newNote) } else { @@ -87,7 +88,8 @@ function importAll (storage, data) { snippets: [{ name: article.mode, mode: article.mode, - content: article.content + content: article.content, + linesHighlighted: article.linesHighlighted }] } notes.push(newNote) diff --git a/browser/main/lib/dataApi/renameStorage.js b/browser/main/lib/dataApi/renameStorage.js index 78242bed..3b806d1c 100644 --- a/browser/main/lib/dataApi/renameStorage.js +++ b/browser/main/lib/dataApi/renameStorage.js @@ -14,7 +14,6 @@ function renameStorage (key, name) { cachedStorageList = JSON.parse(localStorage.getItem('storages')) if (!_.isArray(cachedStorageList)) throw new Error('invalid storages') } catch (err) { - console.log('error got') console.error(err) return Promise.reject(err) } diff --git a/browser/main/lib/dataApi/resolveStorageData.js b/browser/main/lib/dataApi/resolveStorageData.js index af040c5d..da41f3d0 100644 --- a/browser/main/lib/dataApi/resolveStorageData.js +++ b/browser/main/lib/dataApi/resolveStorageData.js @@ -8,7 +8,8 @@ function resolveStorageData (storageCache) { key: storageCache.key, name: storageCache.name, type: storageCache.type, - path: storageCache.path + path: storageCache.path, + isOpen: storageCache.isOpen } const boostnoteJSONPath = path.join(storageCache.path, 'boostnote.json') @@ -30,13 +31,9 @@ function resolveStorageData (storageCache) { const version = parseInt(storage.version, 10) if (version >= 1) { - if (version > 1) { - console.log('The repository version is newer than one of current app.') - } return Promise.resolve(storage) } - console.log('Transform Legacy storage', storage.path) return migrateFromV6Storage(storage.path) .then(() => storage) } diff --git a/browser/main/lib/dataApi/resolveStorageNotes.js b/browser/main/lib/dataApi/resolveStorageNotes.js index fa3f19ae..9da27248 100644 --- a/browser/main/lib/dataApi/resolveStorageNotes.js +++ b/browser/main/lib/dataApi/resolveStorageNotes.js @@ -9,7 +9,7 @@ function resolveStorageNotes (storage) { notePathList = sander.readdirSync(notesDirPath) } catch (err) { if (err.code === 'ENOENT') { - console.log(notesDirPath, ' doesn\'t exist.') + console.error(notesDirPath, ' doesn\'t exist.') sander.mkdirSync(notesDirPath) } else { console.warn('Failed to find note dir', notesDirPath, err) diff --git a/browser/main/lib/dataApi/toggleStorage.js b/browser/main/lib/dataApi/toggleStorage.js new file mode 100644 index 00000000..246d85ef --- /dev/null +++ b/browser/main/lib/dataApi/toggleStorage.js @@ -0,0 +1,27 @@ +const _ = require('lodash') +const resolveStorageData = require('./resolveStorageData') + +/** + * @param {String} key + * @param {Boolean} isOpen + * @return {Object} Storage meta data + */ +function toggleStorage (key, isOpen) { + let cachedStorageList + try { + cachedStorageList = JSON.parse(localStorage.getItem('storages')) + if (!_.isArray(cachedStorageList)) throw new Error('invalid storages') + } catch (err) { + console.error(err) + return Promise.reject(err) + } + const targetStorage = _.find(cachedStorageList, {key: key}) + if (targetStorage == null) return Promise.reject('Storage') + + targetStorage.isOpen = isOpen + localStorage.setItem('storages', JSON.stringify(cachedStorageList)) + + return resolveStorageData(targetStorage) +} + +module.exports = toggleStorage diff --git a/browser/main/lib/dataApi/updateNote.js b/browser/main/lib/dataApi/updateNote.js index 147fbc06..ce9fabcf 100644 --- a/browser/main/lib/dataApi/updateNote.js +++ b/browser/main/lib/dataApi/updateNote.js @@ -39,6 +39,9 @@ function validateInput (input) { if (input.content != null) { if (!_.isString(input.content)) validatedInput.content = '' else validatedInput.content = input.content + + if (!_.isArray(input.linesHighlighted)) validatedInput.linesHighlighted = [] + else validatedInput.linesHighlighted = input.linesHighlighted } return validatedInput case 'SNIPPET_NOTE': @@ -51,7 +54,8 @@ function validateInput (input) { validatedInput.snippets = [{ name: '', mode: 'text', - content: '' + content: '', + linesHighlighted: [] }] } else { validatedInput.snippets = input.snippets @@ -96,12 +100,14 @@ function updateNote (storageKey, noteKey, input) { snippets: [{ name: '', mode: 'text', - content: '' + content: '', + linesHighlighted: [] }] } : { type: 'MARKDOWN_NOTE', - content: '' + content: '', + linesHighlighted: [] } noteData.title = '' if (storage.folders.length === 0) throw new Error('Failed to restore note: No folder exists.') diff --git a/browser/main/lib/dataApi/updateSnippet.js b/browser/main/lib/dataApi/updateSnippet.js new file mode 100644 index 00000000..f132d83f --- /dev/null +++ b/browser/main/lib/dataApi/updateSnippet.js @@ -0,0 +1,35 @@ +import fs from 'fs' +import consts from 'browser/lib/consts' + +function updateSnippet (snippet, snippetFile) { + return new Promise((resolve, reject) => { + const snippets = JSON.parse(fs.readFileSync(snippetFile || consts.SNIPPET_FILE, 'utf-8')) + + for (let i = 0; i < snippets.length; i++) { + const currentSnippet = snippets[i] + + if (currentSnippet.id === snippet.id) { + if ( + currentSnippet.name === snippet.name && + currentSnippet.prefix === snippet.prefix && + currentSnippet.content === snippet.content && + currentSnippet.linesHighlighted === snippet.linesHighlighted + ) { + // if everything is the same then don't write to disk + resolve(snippets) + } else { + currentSnippet.name = snippet.name + currentSnippet.prefix = snippet.prefix + currentSnippet.content = snippet.content + currentSnippet.linesHighlighted = (snippet.linesHighlighted) + fs.writeFile(snippetFile || consts.SNIPPET_FILE, JSON.stringify(snippets, null, 4), (err) => { + if (err) reject(err) + resolve(snippets) + }) + } + } + } + }) +} + +module.exports = updateSnippet diff --git a/browser/main/lib/eventEmitter.js b/browser/main/lib/eventEmitter.js index de08f078..1276545b 100644 --- a/browser/main/lib/eventEmitter.js +++ b/browser/main/lib/eventEmitter.js @@ -14,7 +14,6 @@ function once (name, listener) { } function emit (name, ...args) { - console.log(name) remote.getCurrentWindow().webContents.send(name, ...args) } diff --git a/browser/main/lib/ipcClient.js b/browser/main/lib/ipcClient.js index 0c916617..c06296b5 100644 --- a/browser/main/lib/ipcClient.js +++ b/browser/main/lib/ipcClient.js @@ -14,14 +14,13 @@ nodeIpc.connectTo( path.join(app.getPath('userData'), 'boostnote.service'), function () { nodeIpc.of.node.on('error', function (err) { - console.log(err) + console.error(err) }) nodeIpc.of.node.on('connect', function () { - console.log('Connected successfully') ipcRenderer.send('config-renew', {config: ConfigManager.get()}) }) nodeIpc.of.node.on('disconnect', function () { - console.log('disconnected') + return }) } ) 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 a6f33196..3165606a 100644 --- a/browser/main/lib/shortcut.js +++ b/browser/main/lib/shortcut.js @@ -3,5 +3,11 @@ import ee from 'browser/main/lib/eventEmitter' module.exports = { 'toggleMode': () => { ee.emit('topbar:togglemodebutton') + }, + 'deleteNote': () => { + ee.emit('hotkey:deletenote') + }, + 'toggleMenuBar': () => { + ee.emit('menubar:togglemenubar') } } diff --git a/browser/main/lib/shortcutManager.js b/browser/main/lib/shortcutManager.js index 2b937aea..ac2a3a08 100644 --- a/browser/main/lib/shortcutManager.js +++ b/browser/main/lib/shortcutManager.js @@ -3,7 +3,7 @@ import CM from 'browser/main/lib/ConfigManager' import ee from 'browser/main/lib/eventEmitter' import { isObjectEqual } from 'browser/lib/utils' require('mousetrap-global-bind') -const functions = require('./shortcut') +import functions from './shortcut' let shortcuts = CM.get().hotkey 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/CreateFolderModal.styl b/browser/main/modals/CreateFolderModal.styl index 1b96e123..93848683 100644 --- a/browser/main/modals/CreateFolderModal.styl +++ b/browser/main/modals/CreateFolderModal.styl @@ -128,3 +128,29 @@ body[data-theme="monokai"] .control-confirmButton colorMonokaiPrimaryButton() + +body[data-theme="dracula"] + .root + modalDracula() + width 500px + height 270px + overflow hidden + position relative + + .header + background-color transparent + border-color $ui-dark-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() \ No newline at end of file diff --git a/browser/main/modals/NewNoteModal.js b/browser/main/modals/NewNoteModal.js index d610714b..476fa252 100644 --- a/browser/main/modals/NewNoteModal.js +++ b/browser/main/modals/NewNoteModal.js @@ -1,21 +1,18 @@ import React from 'react' import CSSModules from 'browser/lib/CSSModules' import styles from './NewNoteModal.styl' -import dataApi from 'browser/main/lib/dataApi' -import { hashHistory } from 'react-router' -import ee from 'browser/main/lib/eventEmitter' import ModalEscButton from 'browser/components/ModalEscButton' -import AwsMobileAnalyticsConfig from 'browser/main/lib/AwsMobileAnalyticsConfig' import i18n from 'browser/lib/i18n' 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.state = { - } + this.lock = false + this.state = {} } componentDidMount () { @@ -26,7 +23,7 @@ class NewNoteModal extends React.Component { this.props.close() } - handleCreateMarkdownFromUrlClick(e) { + handleCreateMarkdownFromUrlClick (e) { this.props.close() const { storage, folder, dispatch, location } = this.props @@ -35,34 +32,18 @@ class NewNoteModal extends React.Component { folder: folder, dispatch, location - }); + }) } handleMarkdownNoteButtonClick (e) { - AwsMobileAnalyticsConfig.recordDynamicCustomEvent('ADD_MARKDOWN') - AwsMobileAnalyticsConfig.recordDynamicCustomEvent('ADD_ALLNOTE') - const { storage, folder, dispatch, location } = this.props - dataApi - .createNote(storage, { - type: 'MARKDOWN_NOTE', - folder: folder, - title: '', - content: '' - }) - .then((note) => { - const noteHash = note.key - dispatch({ - type: 'UPDATE_NOTE', - note: note - }) - hashHistory.push({ - pathname: location.pathname, - query: {key: noteHash} - }) - ee.emit('list:jump', noteHash) - ee.emit('detail:focus') - this.props.close() + 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) { @@ -73,36 +54,14 @@ class NewNoteModal extends React.Component { } handleSnippetNoteButtonClick (e) { - AwsMobileAnalyticsConfig.recordDynamicCustomEvent('ADD_SNIPPET') - AwsMobileAnalyticsConfig.recordDynamicCustomEvent('ADD_ALLNOTE') - const { storage, folder, dispatch, location } = this.props - - dataApi - .createNote(storage, { - type: 'SNIPPET_NOTE', - folder: folder, - title: '', - description: '', - snippets: [{ - name: '', - mode: 'text', - content: '' - }] - }) - .then((note) => { - const noteHash = note.key - dispatch({ - type: 'UPDATE_NOTE', - note: note - }) - hashHistory.push({ - pathname: location.pathname, - query: {key: noteHash} - }) - ee.emit('list:jump', noteHash) - ee.emit('detail:focus') - this.props.close() + 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) { @@ -120,50 +79,63 @@ class NewNoteModal extends React.Component { render () { return ( -
this.handleKeyDown(e)} + onKeyDown={e => this.handleKeyDown(e)} >
{i18n.__('Make a note')}
- this.handleCloseButtonClick(e)} /> + this.handleCloseButtonClick(e)} + />
- -
{i18n.__('Tab to switch format')}
this.handleCreateMarkdownFromUrlClick(e)}>Or, create a new markdown note from a URL
-
) } } -NewNoteModal.propTypes = { -} +NewNoteModal.propTypes = {} export default CSSModules(NewNoteModal, styles) diff --git a/browser/main/modals/NewNoteModal.styl b/browser/main/modals/NewNoteModal.styl index c0783ac3..b79c2501 100644 --- a/browser/main/modals/NewNoteModal.styl +++ b/browser/main/modals/NewNoteModal.styl @@ -103,3 +103,20 @@ body[data-theme="monokai"] .description, .from-url color $ui-monokai-text-color + +body[data-theme="dracula"] + .root + background-color transparent + + .header + color $ui-dracula-text-color + + .control-button + border-color $ui-dracula-borderColor + color $ui-dracula-text-color + background-color transparent + &:focus + colorDraculaPrimaryButton() + + .description + color $ui-dracula-text-color \ No newline at end of file diff --git a/browser/main/modals/PreferencesModal/Blog.js b/browser/main/modals/PreferencesModal/Blog.js index 9f4d33f6..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' @@ -43,7 +43,7 @@ class Blog extends React.Component { this.handleSettingError = (err) => { this.setState({BlogAlert: { type: 'error', - message: err.message != null ? err.message : i18n.__('Error occurs!') + message: err.message != null ? err.message : i18n.__('An error occurred!') }}) } this.oldBlog = this.state.config.blog @@ -70,7 +70,7 @@ class Blog extends React.Component { this.props.haveToSave({ tab: 'Blog', type: 'warning', - message: i18n.__('You have to save!') + message: i18n.__('Unsaved Changes!') }) } } diff --git a/browser/main/modals/PreferencesModal/ConfigTab.styl b/browser/main/modals/PreferencesModal/ConfigTab.styl index 0e5f81fb..0e22833d 100644 --- a/browser/main/modals/PreferencesModal/ConfigTab.styl +++ b/browser/main/modals/PreferencesModal/ConfigTab.styl @@ -18,13 +18,21 @@ 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 line-height 30px .group-section-label - width 150px + width 200px text-align left margin-right 10px font-size 14px @@ -68,9 +76,9 @@ :global .alert display inline-block - position absolute - top 60px - right 15px + position fixed + top 130px + right 100px font-size 14px .success color #1EC38B @@ -127,7 +135,7 @@ colorDarkControl() border-color $ui-dark-borderColor background-color $ui-dark-backgroundColor color $ui-dark-text-color - + colorSolarizedDarkControl() border none background-color $ui-solarized-dark-button-backgroundColor @@ -138,16 +146,22 @@ colorMonokaiControl() background-color $ui-monokai-button-backgroundColor color $ui-monokai-text-color +colorDraculaControl() + border none + background-color $ui-dracula-button-backgroundColor + color $ui-dracula-text-color body[data-theme="dark"] .root 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 @@ -165,27 +179,29 @@ body[data-theme="dark"] .group-section-control select, .group-section-control-input colorDarkControl() - - + + body[data-theme="solarized-dark"] .root color $ui-solarized-dark-text-color .group-header + .group-header--sub color $ui-solarized-dark-text-color - border-color $ui-solarized-dark-borderColor + border-color $ui-solarized-dark-borderColor .group-header2 + .group-header2--sub color $ui-solarized-dark-text-color .group-section-control-input - border-color $ui-solarized-dark-borderColor + border-color $ui-solarized-dark-borderColor .group-control - border-color $ui-solarized-dark-borderColor + border-color $ui-solarized-dark-borderColor .group-control-leftButton colorDarkDefaultButton() - border-color $ui-solarized-dark-borderColor + border-color $ui-solarized-dark-borderColor .group-control-rightButton colorSolarizedDarkPrimaryButton() .group-hint @@ -199,20 +215,22 @@ body[data-theme="monokai"] color $ui-monokai-text-color .group-header + .group-header--sub color $ui-monokai-text-color - border-color $ui-monokai-borderColor + border-color $ui-monokai-borderColor .group-header2 + .group-header2--sub color $ui-monokai-text-color .group-section-control-input - border-color $ui-monokai-borderColor + border-color $ui-monokai-borderColor .group-control - border-color $ui-monokai-borderColor + border-color $ui-monokai-borderColor .group-control-leftButton colorDarkDefaultButton() - border-color $ui-monokai-borderColor + border-color $ui-monokai-borderColor .group-control-rightButton colorMonokaiPrimaryButton() .group-hint @@ -220,3 +238,32 @@ body[data-theme="monokai"] .group-section-control select, .group-section-control-input colorMonokaiControl() + +body[data-theme="dracula"] + .root + 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 + border-color $ui-dracula-borderColor + + .group-control + border-color $ui-dracula-borderColor + .group-control-leftButton + colorDarkDefaultButton() + border-color $ui-dracula-borderColor + .group-control-rightButton + colorDraculaPrimaryButton() + .group-hint + colorDraculaControl() + .group-section-control + select, .group-section-control-input + colorDraculaControl() \ No newline at end of file diff --git a/browser/main/modals/PreferencesModal/Crowdfunding.js b/browser/main/modals/PreferencesModal/Crowdfunding.js index 196c1cb3..56bb6e34 100644 --- a/browser/main/modals/PreferencesModal/Crowdfunding.js +++ b/browser/main/modals/PreferencesModal/Crowdfunding.js @@ -22,22 +22,28 @@ class Crowdfunding extends React.Component { render () { return (
-
{i18n.__('Crowdfunding')}
-

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

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

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

-

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


-

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

-

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

+

{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.__('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 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.')}


-

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

+

{i18n.__('As same as issues of Boostnote are already funded on IssueHunt, your open-source projects can be also started funding from now.')}


-

{i18n.__('Thanks,')}

-

{i18n.__('Boostnote maintainers')}

+

{i18n.__('Thank you,')}

+

{i18n.__('The Boostnote Team')}


) diff --git a/browser/main/modals/PreferencesModal/Crowdfunding.styl b/browser/main/modals/PreferencesModal/Crowdfunding.styl index 3d4af539..d1d6fc9f 100644 --- a/browser/main/modals/PreferencesModal/Crowdfunding.styl +++ b/browser/main/modals/PreferencesModal/Crowdfunding.styl @@ -1,21 +1,16 @@ -@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 - width 250px height 35px border-radius 2px border none background-color alpha(#1EC38B, 90%) + padding-left 20px + padding-right 20px &:hover background-color #1EC38B transition 0.2s @@ -28,7 +23,7 @@ p body[data-theme="dark"] p color $ui-dark-text-color - + body[data-theme="solarized-dark"] .root color $ui-solarized-dark-text-color @@ -40,3 +35,9 @@ body[data-theme="monokai"] color $ui-monokai-text-color p color $ui-monokai-text-color + +body[data-theme="dracula"] + .root + color $ui-dracula-text-color + p + color $ui-dracula-text-color \ No newline at end of file diff --git a/browser/main/modals/PreferencesModal/FolderItem.js b/browser/main/modals/PreferencesModal/FolderItem.js index dc9082b9..e6bd1e37 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' diff --git a/browser/main/modals/PreferencesModal/FolderItem.styl b/browser/main/modals/PreferencesModal/FolderItem.styl index 8bcf2b02..618e9bc4 100644 --- a/browser/main/modals/PreferencesModal/FolderItem.styl +++ b/browser/main/modals/PreferencesModal/FolderItem.styl @@ -2,6 +2,7 @@ height 35px box-sizing border-box padding 2.5px 15px + display flex &:hover background-color darken(white, 3%) @@ -18,7 +19,10 @@ border-left solid 2px transparent padding 0 10px line-height 30px - float left + flex 1 + white-space nowrap + text-overflow ellipsis + overflow hidden .folderItem-left-danger color $danger-color font-weight bold @@ -52,12 +56,13 @@ outline none .folderItem-right - float right + -webkit-box-flex: 1 + white-space nowrap .folderItem-right-button vertical-align middle height 25px - margin-top 2.5px + margin-top 2px colorDefaultButton() border-radius 2px border $ui-border @@ -149,3 +154,26 @@ body[data-theme="monokai"] .folderItem-right-dangerButton colorMonokaiPrimaryButton() + +body[data-theme="dracula"] + .folderItem + &:hover + background-color $ui-dracula-button-backgroundColor + + .folderItem-left-danger + color $danger-color + + .folderItem-left-key + color $ui-dark-inactive-text-color + + .folderItem-left-colorButton + colorDraculaPrimaryButton() + + .folderItem-right-button + colorDraculaPrimaryButton() + + .folderItem-right-confirmButton + colorDraculaPrimaryButton() + + .folderItem-right-dangerButton + colorDraculaPrimaryButton() \ No newline at end of file diff --git a/browser/main/modals/PreferencesModal/FolderList.js b/browser/main/modals/PreferencesModal/FolderList.js index e7cc6f94..02f5cee9 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' diff --git a/browser/main/modals/PreferencesModal/HotkeyTab.js b/browser/main/modals/PreferencesModal/HotkeyTab.js index 671e1516..713f6a65 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' @@ -28,10 +28,20 @@ class HotkeyTab extends React.Component { }}) } this.handleSettingError = (err) => { - this.setState({keymapAlert: { - type: 'error', - message: err.message != null ? err.message : i18n.__('Error occurs!') - }}) + if ( + this.state.config.hotkey.toggleMain === '' || + this.state.config.hotkey.toggleMode === '' + ) { + this.setState({keymapAlert: { + type: 'success', + message: i18n.__('Successfully applied!') + }}) + } else { + this.setState({keymapAlert: { + type: 'error', + message: err.message != null ? err.message : i18n.__('An error occurred!') + }}) + } } this.oldHotkey = this.state.config.hotkey ipc.addListener('APP_SETTING_DONE', this.handleSettingDone) @@ -68,7 +78,10 @@ class HotkeyTab extends React.Component { const { config } = this.state config.hotkey = { toggleMain: this.refs.toggleMain.value, - toggleMode: this.refs.toggleMode.value + toggleMode: this.refs.toggleMode.value, + deleteNote: this.refs.deleteNote.value, + pasteSmartly: this.refs.pasteSmartly.value, + toggleMenuBar: this.refs.toggleMenuBar.value } this.setState({ config @@ -79,7 +92,7 @@ class HotkeyTab extends React.Component { this.props.haveToSave({ tab: 'Hotkey', type: 'warning', - message: i18n.__('You have to save!') + message: i18n.__('Unsaved Changes!') }) } } @@ -117,7 +130,18 @@ class HotkeyTab extends React.Component {
-
{i18n.__('Toggle editor mode')}
+
{i18n.__('Show/Hide Menu Bar')}
+
+ this.handleHotkeyChange(e)} + ref='toggleMenuBar' + value={config.hotkey.toggleMenuBar} + type='text' + /> +
+
+
+
{i18n.__('Toggle Editor Mode')}
this.handleHotkeyChange(e)} @@ -127,6 +151,28 @@ class HotkeyTab extends React.Component { />
+
+
{i18n.__('Delete Note')}
+
+ this.handleHotkeyChange(e)} + ref='deleteNote' + value={config.hotkey.deleteNote} + type='text' + /> +
+
+
+
{i18n.__('Paste HTML')}
+
+ this.handleHotkeyChange(e)} + ref='pasteSmartly' + value={config.hotkey.pasteSmartly} + type='text' + /> +
+
+
+
+
    + { + snippets.map((snippet) => ( +
  • this.handleSnippetContextMenu(snippet)} + onClick={() => this.handleSnippetClick(snippet)}> + {snippet.name} +
  • + )) + } +
+
+ ) + } +} + +export default CSSModules(SnippetList, styles) diff --git a/browser/main/modals/PreferencesModal/SnippetTab.js b/browser/main/modals/PreferencesModal/SnippetTab.js new file mode 100644 index 00000000..df338d7f --- /dev/null +++ b/browser/main/modals/PreferencesModal/SnippetTab.js @@ -0,0 +1,154 @@ +import React from 'react' +import CSSModules from 'browser/lib/CSSModules' +import styles from './SnippetTab.styl' +import SnippetEditor from './SnippetEditor' +import i18n from 'browser/lib/i18n' +import dataApi from 'browser/main/lib/dataApi' +import SnippetList from './SnippetList' +import eventEmitter from 'browser/main/lib/eventEmitter' +import copy from 'copy-to-clipboard' + +const path = require('path') + +class SnippetTab extends React.Component { + constructor (props) { + super(props) + this.state = { + currentSnippet: null + } + this.changeDelay = null + } + + notify (title, options) { + if (global.process.platform === 'win32') { + options.icon = path.join( + 'file://', + global.__dirname, + '../../resources/app.png' + ) + } + return new window.Notification(title, options) + } + + handleSnippetNameOrPrefixChange () { + clearTimeout(this.changeDelay) + this.changeDelay = setTimeout(() => { + // notify the snippet editor that the name or prefix of snippet has been changed + this.snippetEditor.onSnippetNameOrPrefixChanged(this.state.currentSnippet) + eventEmitter.emit('snippetList:reload') + }, 500) + } + + handleSnippetSelect (snippet) { + const { currentSnippet } = this.state + if (snippet !== null) { + if (currentSnippet === null || currentSnippet.id !== snippet.id) { + dataApi.fetchSnippet(snippet.id).then(changedSnippet => { + // notify the snippet editor to load the content of the new snippet + this.snippetEditor.onSnippetChanged(changedSnippet) + this.setState({currentSnippet: changedSnippet}) + }) + } + } + } + + onSnippetNameOrPrefixChanged (e, type) { + const newSnippet = Object.assign({}, this.state.currentSnippet) + if (type === 'name') { + newSnippet.name = e.target.value + } else { + newSnippet.prefix = e.target.value + } + this.setState({ currentSnippet: newSnippet }) + this.handleSnippetNameOrPrefixChange() + } + + handleDeleteSnippet (snippet) { + // prevent old snippet still display when deleted + if (snippet.id === this.state.currentSnippet.id) { + this.setState({currentSnippet: null}) + } + } + + handleCopySnippet (e) { + const showCopyNotification = this.props.config.ui.showCopyNotification + copy(this.state.currentSnippet.content) + if (showCopyNotification) { + this.notify('Saved to Clipboard!', { + body: 'Paste it wherever you want!', + silent: true + }) + } + } + + render () { + const { config, storageKey } = this.props + const { currentSnippet } = this.state + + let editorFontSize = parseInt(config.editor.fontSize, 10) + if (!(editorFontSize > 0 && editorFontSize < 101)) editorFontSize = 14 + let editorIndentSize = parseInt(config.editor.indentSize, 10) + if (!(editorFontSize > 0 && editorFontSize < 132)) editorIndentSize = 4 + return ( +
+
{i18n.__('Snippets')}
+ +
+
+
+ +
+
+
+
{i18n.__('Snippet name')}
+
+ { this.onSnippetNameOrPrefixChanged(e, 'name') }} + type='text' /> +
+
+
+
{i18n.__('Snippet prefix')}
+
+ { this.onSnippetNameOrPrefixChanged(e, 'prefix') }} + type='text' /> +
+
+
+ { this.snippetEditor = ref }} /> +
+
+
+ ) + } +} + +SnippetTab.PropTypes = { +} + +export default CSSModules(SnippetTab, styles) diff --git a/browser/main/modals/PreferencesModal/SnippetTab.styl b/browser/main/modals/PreferencesModal/SnippetTab.styl new file mode 100644 index 00000000..296f8167 --- /dev/null +++ b/browser/main/modals/PreferencesModal/SnippetTab.styl @@ -0,0 +1,205 @@ +@import('./ConfigTab') + +.group + margin-bottom 45px + +.group-header + @extend .header + color $ui-text-color + +.group-header2 + font-size 20px + color $ui-text-color + margin-bottom 15px + margin-top 30px + +.group-section + margin-bottom 20px + display flex + line-height 30px + +.group-section-label + width 150px + text-align left + margin-right 10px + font-size 14px + +.group-section-control + flex 1 + margin-left 5px + +.group-section-control select + outline none + border 1px solid $ui-borderColor + font-size 16px + height 30px + width 250px + margin-bottom 5px + background-color transparent + +.group-section-control-input + height 30px + vertical-align middle + width 400px + font-size $tab--button-font-size + border solid 1px $border-color + border-radius 2px + padding 0 5px + outline none + &:disabled + background-color $ui-input--disabled-backgroundColor + +.group-control-button + height 30px + border none + border-top-right-radius 2px + border-bottom-right-radius 2px + colorPrimaryButton() + vertical-align middle + padding 0 20px + +.group-checkBoxSection + margin-bottom 15px + display flex + line-height 30px + padding-left 15px + +.group-control + padding-top 10px + box-sizing border-box + height 40px + text-align right + :global + .alert + display inline-block + position absolute + top 60px + right 15px + font-size 14px + .success + color #1EC38B + .error + color red + .warning + color #FFA500 + +.snippet-list + width 30% + height calc(100% - 200px) + position absolute + + .snippets + height calc(100% - 8px) + overflow scroll + background: #f5f5f5 + + .snippet-item + height 50px + font-size 15px + line-height 50px + padding 0 5% + cursor pointer + position relative + + &::after + width 90% + height 1px + background rgba(0, 0, 0, 0.1) + position absolute + top 100% + left 5% + content '' + + &:hover + background darken(#f5f5f5, 5) + + .snippet-item-selected + @extend .snippet-list .snippet-item + background darken(#f5f5f5, 5) + +.snippet-detail + width 67% + height calc(100% - 200px) + position absolute + left 33% + +.SnippetEditor + position absolute + width 100% + height 90% + +body[data-theme="default"], body[data-theme="white"] + .snippets + background $ui-backgroundColor + .snippet-item + color black + &::after + background $ui-borderColor + &:hover + background darken($ui-backgroundColor, 5) + .snippet-item-selected + background darken($ui-backgroundColor, 5) + +body[data-theme="dark"] + .snippets + background $ui-dark-backgroundColor + .snippet-item + color white + &::after + background $ui-dark-borderColor + &:hover + background darken($ui-dark-backgroundColor, 5) + .snippet-item-selected + background darken($ui-dark-backgroundColor, 5) + .snippet-detail + color white + .group-control-button + colorDarkPrimaryButton() + +body[data-theme="solarized-dark"] + .snippets + background $ui-solarized-dark-backgroundColor + .snippet-item + color white + &::after + background $ui-solarized-dark-borderColor + &:hover + background darken($ui-solarized-dark-backgroundColor, 5) + .snippet-item-selected + background darken($ui-solarized-dark-backgroundColor, 5) + .snippet-detail + color white + .group-control-button + colorSolarizedDarkPrimaryButton() + +body[data-theme="monokai"] + .snippets + background $ui-monokai-backgroundColor + .snippet-item + color White + &::after + background $ui-monokai-borderColor + &:hover + background darken($ui-monokai-backgroundColor, 5) + .snippet-item-selected + background darken($ui-monokai-backgroundColor, 5) + .snippet-detail + color white + .group-control-button + colorMonokaiPrimaryButton() + +body[data-theme="dracula"] + .snippets + background $ui-dracula-backgroundColor + .snippet-item + color #f8f8f2 + &::after + background $ui-dracula-borderColor + &:hover + background darken($ui-dracula-backgroundColor, 5) + .snippet-item-selected + background darken($ui-dracula-backgroundColor, 5) + .snippet-detail + color #f8f8f2 + .group-control-button + colorDraculaPrimaryButton() \ No newline at end of file diff --git a/browser/main/modals/PreferencesModal/StorageItem.js b/browser/main/modals/PreferencesModal/StorageItem.js index 3a2b075c..9af02962 100644 --- a/browser/main/modals/PreferencesModal/StorageItem.js +++ b/browser/main/modals/PreferencesModal/StorageItem.js @@ -4,7 +4,7 @@ import CSSModules from 'browser/lib/CSSModules' import styles from './StorageItem.styl' import consts from 'browser/lib/consts' import dataApi from 'browser/main/lib/dataApi' -import store from 'browser/main/store' +import { store } from 'browser/main/store' import FolderList from './FolderList' import i18n from 'browser/lib/i18n' diff --git a/browser/main/modals/PreferencesModal/StorageItem.styl b/browser/main/modals/PreferencesModal/StorageItem.styl index 29dfbd0b..adcc483e 100644 --- a/browser/main/modals/PreferencesModal/StorageItem.styl +++ b/browser/main/modals/PreferencesModal/StorageItem.styl @@ -9,13 +9,17 @@ box-sizing border-box border-bottom $default-border margin-bottom 5px + display flex .header-label - float left cursor pointer &:hover .header-label-editButton opacity 1 + flex 1 + white-space nowrap + text-overflow ellipsis + overflow hidden .header-label-path color $ui-inactive-text-color @@ -38,8 +42,8 @@ outline none .header-control - float right - + -webkit-box-flex: 1 + white-space nowrap .header-control-button width 30px height 25px diff --git a/browser/main/modals/PreferencesModal/StoragesTab.js b/browser/main/modals/PreferencesModal/StoragesTab.js index d85ed8e3..046b24e6 100644 --- a/browser/main/modals/PreferencesModal/StoragesTab.js +++ b/browser/main/modals/PreferencesModal/StoragesTab.js @@ -70,7 +70,7 @@ class StoragesTab extends React.Component { }) return (
-
{i18n.__('Storages')}
+
{i18n.__('Storage Locations')}
{storageList.length > 0 ? storageList :
{i18n.__('No storage found.')}
@@ -182,7 +182,7 @@ class StoragesTab extends React.Component {
this.handleAddStorageChange(e)} /> diff --git a/browser/main/modals/PreferencesModal/StoragesTab.styl b/browser/main/modals/PreferencesModal/StoragesTab.styl index 9804d7e7..b63cc85e 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 @@ -158,7 +154,7 @@ body[data-theme="dark"] .addStorage-body-control-cancelButton colorDarkDefaultButton() border-color $ui-dark-borderColor - + body[data-theme="solarized-dark"] @@ -236,3 +232,41 @@ body[data-theme="monokai"] .addStorage-body-control-cancelButton colorDarkDefaultButton() border-color $ui-monokai-borderColor + +body[data-theme="dracula"] + .root + color $ui-dracula-text-color + + .folderList-item + border-bottom $ui-dracula-borderColor + + .folderList-empty + color $ui-dracula-text-color + + .list-empty + color $ui-dracula-text-color + .list-control-addStorageButton + border-color $ui-dracula-button-backgroundColor + background-color $ui-dracula-button-backgroundColor + color $ui-dracula-text-color + + .addStorage-header + color $ui-dracula-text-color + border-color $ui-dracula-borderColor + + .addStorage-body-section-name-input + border-color $$ui-dracula-borderColor + + .addStorage-body-section-type-description + color $ui-dracula-text-color + + .addStorage-body-section-path-button + colorPrimaryButton() + .addStorage-body-control + border-color $ui-dracula-borderColor + + .addStorage-body-control-createButton + colorDarkPrimaryButton() + .addStorage-body-control-cancelButton + colorDarkDefaultButton() + border-color $ui-dracula-borderColor \ No newline at end of file diff --git a/browser/main/modals/PreferencesModal/UiTab.js b/browser/main/modals/PreferencesModal/UiTab.js index d2a2f178..f74dbda5 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' @@ -11,6 +11,7 @@ import 'codemirror-mode-elixir' import _ from 'lodash' import i18n from 'browser/lib/i18n' import { getLanguages } from 'browser/lib/Languages' +import normalizeEditorFontFamily from 'browser/lib/normalizeEditorFontFamily' const OSX = global.process.platform === 'darwin' @@ -28,6 +29,10 @@ class UiTab extends React.Component { componentDidMount () { CodeMirror.autoLoadMode(this.codeMirrorInstance.getCodeMirror(), 'javascript') + CodeMirror.autoLoadMode(this.customCSSCM.getCodeMirror(), 'css') + CodeMirror.autoLoadMode(this.customMarkdownLintConfigCM.getCodeMirror(), 'javascript') + this.customCSSCM.getCodeMirror().setSize('400px', '400px') + this.customMarkdownLintConfigCM.getCodeMirror().setSize('400px', '200px') this.handleSettingDone = () => { this.setState({UiAlert: { type: 'success', @@ -37,7 +42,7 @@ class UiTab extends React.Component { this.handleSettingError = (err) => { this.setState({UiAlert: { type: 'error', - message: err.message != null ? err.message : i18n.__('Error occurs!') + message: err.message != null ? err.message : i18n.__('An error occurred!') }}) } ipc.addListener('APP_SETTING_DONE', this.handleSettingDone) @@ -64,9 +69,15 @@ class UiTab extends React.Component { ui: { theme: this.refs.uiTheme.value, language: this.refs.uiLanguage.value, + defaultNote: this.refs.defaultNote.value, + tagNewNoteWithFilteringTags: this.refs.tagNewNoteWithFilteringTags.checked, showCopyNotification: this.refs.showCopyNotification.checked, confirmDeletion: this.refs.confirmDeletion.checked, showOnlyRelatedTags: this.refs.showOnlyRelatedTags.checked, + 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 @@ -82,8 +93,19 @@ class UiTab extends React.Component { displayLineNumbers: this.refs.editorDisplayLineNumbers.checked, switchPreview: this.refs.editorSwitchPreview.value, keyMap: this.refs.editorKeyMap.value, + snippetDefaultLanguage: this.refs.editorSnippetDefaultLanguage.value, scrollPastEnd: this.refs.scrollPastEnd.checked, - fetchUrlTitle: this.refs.editorFetchUrlTitle.checked + fetchUrlTitle: this.refs.editorFetchUrlTitle.checked, + 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, + enableMarkdownLint: this.refs.enableMarkdownLint.checked, + customMarkdownLintConfig: this.customMarkdownLintConfigCM.getCodeMirror().getValue() }, preview: { fontSize: this.refs.previewFontSize.value, @@ -96,17 +118,27 @@ class UiTab extends React.Component { latexBlockClose: this.refs.previewLatexBlockClose.value, plantUMLServerAddress: this.refs.previewPlantUMLServerAddress.value, scrollPastEnd: this.refs.previewScrollPastEnd.checked, + scrollSync: this.refs.previewScrollSync.checked, smartQuotes: this.refs.previewSmartQuotes.checked, breaks: this.refs.previewBreaks.checked, - sanitize: this.refs.previewSanitize.value + smartArrows: this.refs.previewSmartArrows.checked, + sanitize: this.refs.previewSanitize.value, + allowCustomCSS: this.refs.previewAllowCustomCSS.checked, + lineThroughCheckbox: this.refs.lineThroughCheckbox.checked, + customCSS: this.customCSSCM.getCodeMirror().getValue() } } 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} @@ -116,7 +148,7 @@ class UiTab extends React.Component { this.props.haveToSave({ tab: 'UI', type: 'warning', - message: i18n.__('You have to save!') + message: i18n.__('Unsaved Changes!') }) } }) @@ -159,13 +191,16 @@ class UiTab extends React.Component { const { config, codemirrorTheme } = this.state const codemirrorSampleCode = 'function iamHappy (happy) {\n\tif (happy) {\n\t console.log("I am Happy!")\n\t} else {\n\t console.log("I am not Happy!")\n\t}\n};' const enableEditRulersStyle = config.editor.enableRulers ? 'block' : 'none' + const fontFamily = normalizeEditorFontFamily(config.editor.fontFamily) return (
{i18n.__('Interface')}
- {i18n.__('Interface Theme')} +
+ {i18n.__('Interface Theme')} +
- {i18n.__('Language')} +
+ {i18n.__('Language')} +
this.handleUIChange(e)} + ref='defaultNote' + > + + + + +
+
+ +
+ +
-
- -
{ global.process.platform === 'win32' ?
@@ -234,11 +288,69 @@ class UiTab extends React.Component { disabled={OSX} type='checkbox' />  - Disable Direct Write(It will be applied after restarting) + {i18n.__('Disable Direct Write (It will be applied after restarting)')}
: null } + +
Tags
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
Editor
@@ -252,12 +364,20 @@ class UiTab extends React.Component { > { themes.map((theme) => { - return () + return () }) } -
- (this.codeMirrorInstance = e)} value={codemirrorSampleCode} options={{ lineNumbers: true, readOnly: true, mode: 'javascript', theme: codemirrorTheme }} /> +
+ (this.codeMirrorInstance = e)} + value={codemirrorSampleCode} + options={{ + lineNumbers: true, + readOnly: true, + mode: 'javascript', + theme: codemirrorTheme + }} />
@@ -372,6 +492,48 @@ class UiTab extends React.Component {
+
+
+ {i18n.__('Snippet Default Language')} +
+
+ +
+
+ +
+
+ {i18n.__('Front matter title field')} +
+
+ this.handleUIChange(e)} + type='text' + /> +
+
+ +
+ +
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+ {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')}
@@ -432,8 +697,9 @@ class UiTab extends React.Component { />
+
-
{i18n.__('Code block Theme')}
+
{i18n.__('Code Block Theme')}
+
+ +
+
+ +
@@ -484,7 +770,17 @@ class UiTab extends React.Component { ref='previewBreaks' type='checkbox' />  - Render newlines in Markdown paragraphs as <br> + {i18n.__('Render newlines in Markdown paragraphs as
')} + +
+
+
@@ -569,6 +865,33 @@ class UiTab extends React.Component { />
+
+
+ {i18n.__('Custom CSS')} +
+
+ this.handleUIChange(e)} + checked={config.preview.allowCustomCSS} + ref='previewAllowCustomCSS' + type='checkbox' + />  + {i18n.__('Allow custom CSS for preview')} +
+ this.handleUIChange(e)} + ref={e => (this.customCSSCM = e)} + value={config.preview.customCSS} + defaultValue={'/* Drop Your Custom CSS Code Here */\n'} + options={{ + lineNumbers: true, + mode: 'css', + theme: codemirrorTheme + }} /> +
+
+