diff --git a/.babelrc b/.babelrc index 270349d2..3a366286 100644 --- a/.babelrc +++ b/.babelrc @@ -7,7 +7,7 @@ "test": { "presets": ["env" ,"react", "es2015"], "plugins": [ - [ "babel-plugin-webpack-alias", { "config": "${PWD}/webpack.config.js" } ] + [ "babel-plugin-webpack-alias", { "config": "/webpack.config.js" } ] ] } } diff --git a/.eslintrc b/.eslintrc index 1709c9d8..be8cb903 100644 --- a/.eslintrc +++ b/.eslintrc @@ -18,7 +18,9 @@ "globals": { "FileReader": true, "localStorage": true, - "fetch": true + "fetch": true, + "Image": true, + "MutationObserver": true }, "env": { "jest": true diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..ea304082 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +issuehunt: BoostIo/Boostnote diff --git a/.gitignore b/.gitignore index ace5316c..aac64950 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ node_modules/* /secret *.log .idea -.vscode \ No newline at end of file +.vscode +package-lock.json diff --git a/.travis.yml b/.travis.yml index d9267f77..71b65671 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,9 @@ language: node_js node_js: - - 7 + - 8 script: - npm run lint && npm run test - - yarn jest - - 'if [[ ${TRAVIS_PULL_REQUEST_BRANCH:-$TRAVIS_BRANCH} = "master" ]]; then npm install -g grunt npm@5.2 && grunt pre-build; fi' + - 'if [[ ${TRAVIS_PULL_REQUEST_BRANCH:-$TRAVIS_BRANCH} = "master" ]]; then npm install -g grunt npm@6.4 && grunt pre-build; fi' after_success: - openssl aes-256-cbc -K $encrypted_440d7f9a3c38_key -iv $encrypted_440d7f9a3c38_iv -in .snapcraft/travis_snapcraft.cfg -out .snapcraft/snapcraft.cfg -d diff --git a/browser/components/CodeEditor.js b/browser/components/CodeEditor.js index 6ad294ed..bc2719dc 100644 --- a/browser/components/CodeEditor.js +++ b/browser/components/CodeEditor.js @@ -14,18 +14,21 @@ import { import TextEditorInterface from 'browser/lib/TextEditorInterface' import eventEmitter from 'browser/main/lib/eventEmitter' import iconv from 'iconv-lite' -import crypto from 'crypto' -import consts from 'browser/lib/consts' + +import { isMarkdownTitleURL } from 'browser/lib/utils' import styles from '../components/CodeEditor.styl' -import fs from 'fs' const { ipcRenderer, remote, clipboard } = require('electron') import normalizeEditorFontFamily from 'browser/lib/normalizeEditorFontFamily' const spellcheck = require('browser/lib/spellcheck') -const buildEditorContextMenu = require('browser/lib/contextMenuBuilder') -import TurndownService from 'turndown' -import { - gfm -} from 'turndown-plugin-gfm' +const buildEditorContextMenu = require('browser/lib/contextMenuBuilder').buildEditorContextMenu +import { createTurndownService } from '../lib/turndown' +import {languageMaps} from '../lib/CMLanguageList' +import snippetManager from '../lib/SnippetManager' +import {generateInEditor, tocExistsInEditor} from 'browser/lib/markdown-toc-generator' +import markdownlint from 'markdownlint' +import Jsonlint from 'jsonlint-mod' +import { DEFAULT_CONFIG } from '../main/lib/ConfigManager' +import prettier from 'prettier' CodeMirror.modeURL = '../node_modules/codemirror/mode/%N/%N.js' @@ -38,85 +41,6 @@ function translateHotkey (hotkey) { return hotkey.replace(/\s*\+\s*/g, '-').replace(/Command/g, 'Cmd').replace(/Control/g, 'Ctrl') } -const languageMaps = { - brainfuck: 'Brainfuck', - cpp: 'C++', - cs: 'C#', - clojure: 'Clojure', - 'clojure-repl': 'ClojureScript', - cmake: 'CMake', - coffeescript: 'CoffeeScript', - crystal: 'Crystal', - css: 'CSS', - d: 'D', - dart: 'Dart', - delphi: 'Pascal', - diff: 'Diff', - django: 'Django', - dockerfile: 'Dockerfile', - ebnf: 'EBNF', - elm: 'Elm', - erlang: 'Erlang', - 'erlang-repl': 'Erlang', - fortran: 'Fortran', - fsharp: 'F#', - gherkin: 'Gherkin', - go: 'Go', - groovy: 'Groovy', - haml: 'HAML', - haskell: 'Haskell', - haxe: 'Haxe', - http: 'HTTP', - ini: 'toml', - java: 'Java', - javascript: 'JavaScript', - json: 'JSON', - julia: 'Julia', - kotlin: 'Kotlin', - less: 'LESS', - livescript: 'LiveScript', - lua: 'Lua', - markdown: 'Markdown', - mathematica: 'Mathematica', - nginx: 'Nginx', - nsis: 'NSIS', - objectivec: 'Objective-C', - ocaml: 'Ocaml', - perl: 'Perl', - php: 'PHP', - powershell: 'PowerShell', - properties: 'Properties files', - protobuf: 'ProtoBuf', - python: 'Python', - puppet: 'Puppet', - q: 'Q', - r: 'R', - ruby: 'Ruby', - rust: 'Rust', - sas: 'SAS', - scala: 'Scala', - scheme: 'Scheme', - scss: 'SCSS', - shell: 'Shell', - smalltalk: 'Smalltalk', - sml: 'SML', - sql: 'SQL', - stylus: 'Stylus', - swift: 'Swift', - tcl: 'Tcl', - tex: 'LaTex', - typescript: 'TypeScript', - twig: 'Twig', - vbnet: 'VB.NET', - vbscript: 'VBScript', - verilog: 'Verilog', - vhdl: 'VHDL', - xml: 'HTML', - xquery: 'XQuery', - yaml: 'YAML', - elixir: 'Elixir' -} - export default class CodeEditor extends React.Component { constructor (props) { super(props) @@ -130,6 +54,7 @@ export default class CodeEditor extends React.Component { this.focusHandler = () => { ipcRenderer.send('editor:focused', true) } + const debouncedDeletionOfAttachments = _.debounce(attachmentManagement.deleteAttachmentsNotPresentInNote, 30000) this.blurHandler = (editor, e) => { ipcRenderer.send('editor:focused', false) if (e == null) return null @@ -141,16 +66,13 @@ export default class CodeEditor extends React.Component { el = el.parentNode } this.props.onBlur != null && this.props.onBlur(e) - const { storageKey, noteKey } = this.props - attachmentManagement.deleteAttachmentsNotPresentInNote( - this.editor.getValue(), - storageKey, - noteKey - ) + if (this.props.deleteUnusedAttachments === true) { + debouncedDeletionOfAttachments(this.editor.getValue(), storageKey, noteKey) + } } this.pasteHandler = (editor, e) => { e.preventDefault() @@ -163,6 +85,8 @@ export default class CodeEditor extends React.Component { this.searchHandler = (e, msg) => this.handleSearch(msg) this.searchState = null this.scrollToLineHandeler = this.scrollToLine.bind(this) + this.getCodeEditorLintConfig = this.getCodeEditorLintConfig.bind(this) + this.validatorOfMarkdown = this.validatorOfMarkdown.bind(this) this.formatTable = () => this.handleFormatTable() @@ -177,7 +101,7 @@ export default class CodeEditor extends React.Component { this.editorActivityHandler = () => this.handleEditorActivity() - this.turndownService = new TurndownService() + this.turndownService = createTurndownService() } handleSearch (msg) { @@ -185,7 +109,7 @@ export default class CodeEditor extends React.Component { const component = this if (component.searchState) cm.removeOverlay(component.searchState) - if (msg.length < 3) return + if (msg.length < 1) return cm.operation(function () { component.searchState = makeOverlay(msg, 'searching') @@ -228,7 +152,8 @@ export default class CodeEditor extends React.Component { updateDefaultKeyMap () { const { hotkey } = this.props - const expandSnippet = this.expandSnippet.bind(this) + const self = this + const expandSnippet = snippetManager.expandSnippet this.defaultKeyMap = CodeMirror.normalizeKeyMap({ Tab: function (cm) { @@ -248,14 +173,16 @@ export default class CodeEditor extends React.Component { } cm.execCommand('goLineEnd') } else if ( - !charBeforeCursor.match(/\t|\s|\r|\n/) && + !charBeforeCursor.match(/\t|\s|\r|\n|\$/) && cursor.ch > 1 ) { // text expansion on tab key if the char before is alphabet - const snippets = JSON.parse( - fs.readFileSync(consts.SNIPPET_FILE, 'utf8') + const wordBeforeCursor = self.getWordBeforeCursor( + line, + cursor.line, + cursor.ch ) - if (expandSnippet(line, cursor, cm, snippets) === false) { + if (expandSnippet(wordBeforeCursor, cursor, cm) === false) { if (tabs) { cm.execCommand('insertTab') } else { @@ -277,6 +204,14 @@ export default class CodeEditor extends React.Component { 'Cmd-T': function (cm) { // Do nothing }, + [translateHotkey(hotkey.insertDate)]: function (cm) { + const dateNow = new Date() + cm.replaceSelection(dateNow.toLocaleDateString()) + }, + [translateHotkey(hotkey.insertDateTime)]: function (cm) { + const dateNow = new Date() + cm.replaceSelection(dateNow.toLocaleString()) + }, Enter: 'boostNewLineAndIndentContinueMarkdownList', 'Ctrl-C': cm => { if (cm.getOption('keyMap').substr(0, 3) === 'vim') { @@ -284,6 +219,37 @@ export default class CodeEditor extends React.Component { } return CodeMirror.Pass }, + [translateHotkey(hotkey.prettifyMarkdown)]: cm => { + // Default / User configured prettier options + const currentConfig = JSON.parse(self.props.prettierConfig) + + // Parser type will always need to be markdown so we override the option before use + currentConfig.parser = 'markdown' + + // Get current cursor position + const cursorPos = cm.getCursor() + currentConfig.cursorOffset = cm.doc.indexFromPos(cursorPos) + + // Prettify contents of editor + const formattedTextDetails = prettier.formatWithCursor(cm.doc.getValue(), currentConfig) + + const formattedText = formattedTextDetails.formatted + const formattedCursorPos = formattedTextDetails.cursorOffset + cm.doc.setValue(formattedText) + + // Reset Cursor position to be at the same markdown as was before prettifying + const newCursorPos = cm.doc.posFromIndex(formattedCursorPos) + cm.doc.setCursor(newCursorPos) + }, + [translateHotkey(hotkey.sortLines)]: cm => { + const selection = cm.doc.getSelection() + const appendLineBreak = /\n$/.test(selection) + + const sorted = _.split(selection.trim(), '\n').sort() + const sortedString = _.join(sorted, '\n') + (appendLineBreak ? '\n' : '') + + cm.doc.replaceSelection(sortedString) + }, [translateHotkey(hotkey.pasteSmartly)]: cm => { this.handlePaste(cm, true) } @@ -307,25 +273,10 @@ export default class CodeEditor extends React.Component { } componentDidMount () { - const { rulers, enableRulers } = this.props + const { rulers, enableRulers, enableMarkdownLint, RTL } = this.props eventEmitter.on('line:jump', this.scrollToLineHandeler) - const defaultSnippet = [ - { - id: crypto.randomBytes(16).toString('hex'), - name: 'Dummy text', - prefix: ['lorem', 'ipsum'], - content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.' - } - ] - if (!fs.existsSync(consts.SNIPPET_FILE)) { - fs.writeFileSync( - consts.SNIPPET_FILE, - JSON.stringify(defaultSnippet, null, 4), - 'utf8' - ) - } - + snippetManager.init() this.updateDefaultKeyMap() this.value = this.props.value @@ -334,7 +285,7 @@ export default class CodeEditor extends React.Component { value: this.props.value, linesHighlighted: this.props.linesHighlighted, lineNumbers: this.props.displayLineNumbers, - lineWrapping: true, + lineWrapping: this.props.lineWrapping, theme: this.props.theme, indentUnit: this.props.indentSize, tabSize: this.props.indentSize, @@ -343,17 +294,23 @@ export default class CodeEditor extends React.Component { scrollPastEnd: this.props.scrollPastEnd, inputStyle: 'textarea', dragDrop: false, + direction: RTL ? 'rtl' : 'ltr', + rtlMoveVisually: RTL, foldGutter: true, - gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'], + 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 + extraKeys: this.defaultKeyMap, + prettierConfig: this.props.prettierConfig }) + 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 { @@ -520,61 +477,12 @@ export default class CodeEditor extends React.Component { this.initialHighlighting() } - expandSnippet (line, cursor, cm, snippets) { - const wordBeforeCursor = this.getWordBeforeCursor( - line, - cursor.line, - cursor.ch - ) - const templateCursorString = ':{}' - for (let i = 0; i < snippets.length; i++) { - if (snippets[i].prefix.indexOf(wordBeforeCursor.text) !== -1) { - if (snippets[i].content.indexOf(templateCursorString) !== -1) { - const snippetLines = snippets[i].content.split('\n') - let cursorLineNumber = 0 - let cursorLinePosition = 0 - - let cursorIndex - for (let j = 0; j < snippetLines.length; j++) { - cursorIndex = snippetLines[j].indexOf(templateCursorString) - - if (cursorIndex !== -1) { - cursorLineNumber = j - cursorLinePosition = cursorIndex - - break - } - } - - cm.replaceRange( - snippets[i].content.replace(templateCursorString, ''), - wordBeforeCursor.range.from, - wordBeforeCursor.range.to - ) - cm.setCursor({ - line: cursor.line + cursorLineNumber, - ch: cursorLinePosition + cursor.ch - wordBeforeCursor.text.length - }) - } else { - cm.replaceRange( - snippets[i].content, - wordBeforeCursor.range.from, - wordBeforeCursor.range.to - ) - } - return true - } - } - - return false - } - getWordBeforeCursor (line, lineNumber, cursorPosition) { let wordBeforeCursor = '' const originCursorPosition = cursorPosition - const emptyChars = /\t|\s|\r|\n/ + const emptyChars = /\t|\s|\r|\n|\$/ - // to prevent the word to expand is long that will crash the whole app + // to prevent the word is long that will crash the whole app // the safeStop is there to stop user to expand words that longer than 20 chars const safeStop = 20 @@ -584,7 +492,7 @@ export default class CodeEditor extends React.Component { if (!emptyChars.test(currentChar)) { wordBeforeCursor = currentChar + wordBeforeCursor } else if (wordBeforeCursor.length >= safeStop) { - throw new Error('Your snippet trigger is too long !') + throw new Error('Stopped after 20 loops for safety reason !') } else { break } @@ -629,7 +537,9 @@ export default class CodeEditor extends React.Component { let needRefresh = false const { rulers, - enableRulers + enableRulers, + enableMarkdownLint, + customMarkdownLintConfig } = this.props if (prevProps.mode !== this.props.mode) { this.setMode(this.props.mode) @@ -647,6 +557,20 @@ export default class CodeEditor extends React.Component { if (prevProps.keyMap !== this.props.keyMap) { needRefresh = true } + if (prevProps.RTL !== this.props.RTL) { + this.editor.setOption('direction', this.props.RTL ? 'rtl' : 'ltr') + this.editor.setOption('rtlMoveVisually', this.props.RTL) + } + 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 || @@ -667,6 +591,10 @@ export default class CodeEditor extends React.Component { this.editor.setOption('lineNumbers', this.props.displayLineNumbers) } + if (prevProps.lineWrapping !== this.props.lineWrapping) { + this.editor.setOption('lineWrapping', this.props.lineWrapping) + } + if (prevProps.scrollPastEnd !== this.props.scrollPastEnd) { this.editor.setOption('scrollPastEnd', this.props.scrollPastEnd) } @@ -721,12 +649,65 @@ export default class CodeEditor extends React.Component { this.editor.addPanel(this.createSpellCheckPanel(), {position: 'bottom'}) } } + if (prevProps.deleteUnusedAttachments !== this.props.deleteUnusedAttachments) { + this.editor.setOption('deleteUnusedAttachments', this.props.deleteUnusedAttachments) + } if (needRefresh) { this.editor.refresh() } } + getCodeEditorLintConfig () { + const { mode } = this.props + const checkMarkdownNoteIsOpen = mode === 'Boost Flavored Markdown' + + return checkMarkdownNoteIsOpen ? { + getAnnotations: this.validatorOfMarkdown, + async: true + } : false + } + + validatorOfMarkdown (text, updateLinting) { + const { customMarkdownLintConfig } = this.props + let lintConfigJson + try { + Jsonlint.parse(customMarkdownLintConfig) + lintConfigJson = JSON.parse(customMarkdownLintConfig) + } catch (err) { + eventEmitter.emit('APP_SETTING_ERROR') + return + } + const lintOptions = { + strings: { + content: text + }, + config: lintConfigJson + } + + return markdownlint(lintOptions, (err, result) => { + if (!err) { + const foundIssues = [] + const splitText = text.split('\n') + result.content.map(item => { + let ruleNames = '' + item.ruleNames.map((ruleName, index) => { + ruleNames += ruleName + ruleNames += (index === item.ruleNames.length - 1) ? ': ' : '/' + }) + const lineNumber = item.lineNumber - 1 + foundIssues.push({ + from: CodeMirror.Pos(lineNumber, 0), + to: CodeMirror.Pos(lineNumber, splitText[lineNumber].length), + message: ruleNames + item.ruleDescription, + severity: 'warning' + }) + }) + updateLinting(foundIssues) + } + }) + } + setMode (mode) { let syntax = CodeMirror.findModeByName(convertModeName(mode || 'text')) if (syntax == null) syntax = CodeMirror.findModeByName('Plain Text') @@ -738,6 +719,34 @@ export default class CodeEditor extends React.Component { handleChange (editor, changeObject) { spellcheck.handleChange(editor, changeObject) + // The current note contains an toc. We'll check for changes on headlines. + // origin is undefined when markdownTocGenerator replace the old tod + if (tocExistsInEditor(editor) && changeObject.origin !== undefined) { + let requireTocUpdate + + // Check if one of the changed lines contains a headline + for (let line = 0; line < changeObject.text.length; line++) { + if (this.linePossibleContainsHeadline(editor.getLine(changeObject.from.line + line))) { + requireTocUpdate = true + break + } + } + + if (!requireTocUpdate) { + // Check if one of the removed lines contains a headline + for (let line = 0; line < changeObject.removed.length; line++) { + if (this.linePossibleContainsHeadline(changeObject.removed[line])) { + requireTocUpdate = true + break + } + } + } + + if (requireTocUpdate) { + generateInEditor(editor) + } + } + this.updateHighlight(editor, changeObject) this.value = editor.getValue() @@ -746,15 +755,21 @@ export default class CodeEditor extends React.Component { } } + linePossibleContainsHeadline (currentLine) { + // We can't check if the line start with # because when some write text before + // the # we also need to update the toc + return currentLine.includes('# ') + } + incrementLines (start, linesAdded, linesRemoved, editor) { - let highlightedLines = editor.options.linesHighlighted + const highlightedLines = editor.options.linesHighlighted const totalHighlightedLines = highlightedLines.length - let offset = linesAdded - linesRemoved + const offset = linesAdded - linesRemoved // Store new items to be added as we're changing the lines - let newLines = [] + const newLines = [] let i = totalHighlightedLines @@ -835,6 +850,9 @@ export default class CodeEditor extends React.Component { ch: 1 } this.editor.setCursor(cursor) + const top = this.editor.charCoords({line: num, ch: 0}, 'local').top + const middleHeight = this.editor.getScrollerElement().offsetHeight / 2 + this.editor.scrollTo(null, top - middleHeight - 5) } focus () { @@ -862,6 +880,17 @@ export default class CodeEditor extends React.Component { this.editor.setCursor(cursor) } + /** + * Update content of one line + * @param {Number} lineNumber + * @param {String} content + */ + setLineContent (lineNumber, content) { + const prevContent = this.editor.getLine(lineNumber) + const prevContentLength = prevContent ? prevContent.length : 0 + this.editor.replaceRange(content, { line: lineNumber, ch: 0 }, { line: lineNumber, ch: prevContentLength }) + } + handleDropImage (dropEvent) { dropEvent.preventDefault() const { @@ -950,6 +979,8 @@ export default class CodeEditor extends React.Component { if (isInFencedCodeBlock(editor)) { this.handlePasteText(editor, pastedTxt) + } else if (fetchUrlTitle && isMarkdownTitleURL(pastedTxt) && !isInLinkTag(editor)) { + this.handlePasteUrl(editor, pastedTxt) } else if (fetchUrlTitle && isURL(pastedTxt) && !isInLinkTag(editor)) { this.handlePasteUrl(editor, pastedTxt) } else if (attachmentManagement.isAttachmentLink(pastedTxt)) { @@ -991,7 +1022,17 @@ export default class CodeEditor extends React.Component { } handlePasteUrl (editor, pastedTxt) { - const taggedUrl = `<${pastedTxt}>` + let taggedUrl = `<${pastedTxt}>` + let urlToFetch = pastedTxt + let titleMark = '' + + if (isMarkdownTitleURL(pastedTxt)) { + const pastedTxtSplitted = pastedTxt.split(' ') + titleMark = `${pastedTxtSplitted[0]} ` + urlToFetch = pastedTxtSplitted[1] + taggedUrl = `<${urlToFetch}>` + } + editor.replaceSelection(taggedUrl) const isImageReponse = response => { @@ -1003,22 +1044,23 @@ export default class CodeEditor extends React.Component { const replaceTaggedUrl = replacement => { const value = editor.getValue() const cursor = editor.getCursor() - const newValue = value.replace(taggedUrl, replacement) + const newValue = value.replace(taggedUrl, titleMark + replacement) const newCursor = Object.assign({}, cursor, { - ch: cursor.ch + newValue.length - value.length + ch: cursor.ch + newValue.length - (value.length - titleMark.length) }) + editor.setValue(newValue) editor.setCursor(newCursor) } - fetch(pastedTxt, { + fetch(urlToFetch, { method: 'get' }) .then(response => { if (isImageReponse(response)) { - return this.mapImageResponse(response, pastedTxt) + return this.mapImageResponse(response, urlToFetch) } else { - return this.mapNormalResponse(response, pastedTxt) + return this.mapNormalResponse(response, urlToFetch) } }) .then(replacement => { @@ -1138,13 +1180,11 @@ export default class CodeEditor extends React.Component { } ref='root' tabIndex='-1' - style={ - { + style={{ fontFamily, fontSize: fontSize, width: width - } - } + }} onDrop={ e => this.handleDropImage(e) } @@ -1182,7 +1222,11 @@ CodeEditor.propTypes = { onChange: PropTypes.func, readOnly: PropTypes.bool, autoDetect: PropTypes.bool, - spellCheck: PropTypes.bool + spellCheck: PropTypes.bool, + enableMarkdownLint: PropTypes.bool, + customMarkdownLintConfig: PropTypes.string, + deleteUnusedAttachments: PropTypes.bool, + RTL: PropTypes.bool } CodeEditor.defaultProps = { @@ -1194,5 +1238,10 @@ CodeEditor.defaultProps = { indentSize: 4, indentType: 'space', autoDetect: false, - spellCheck: false + spellCheck: false, + enableMarkdownLint: DEFAULT_CONFIG.editor.enableMarkdownLint, + customMarkdownLintConfig: DEFAULT_CONFIG.editor.customMarkdownLintConfig, + prettierConfig: DEFAULT_CONFIG.editor.prettierConfig, + deleteUnusedAttachments: DEFAULT_CONFIG.editor.deleteUnusedAttachments, + RTL: false } diff --git a/browser/components/CodeEditor.styl b/browser/components/CodeEditor.styl index 7a254935..1aa0e466 100644 --- a/browser/components/CodeEditor.styl +++ b/browser/components/CodeEditor.styl @@ -3,4 +3,3 @@ .spellcheck-select border: none - text-decoration underline wavy red diff --git a/browser/components/MarkdownEditor.js b/browser/components/MarkdownEditor.js index 466be3fa..a2265321 100644 --- a/browser/components/MarkdownEditor.js +++ b/browser/components/MarkdownEditor.js @@ -32,6 +32,7 @@ class MarkdownEditor extends React.Component { componentDidMount () { this.value = this.refs.code.value eventEmitter.on('editor:lock', this.lockEditorCode) + eventEmitter.on('editor:focus', this.focusEditor.bind(this)) } componentDidUpdate () { @@ -47,6 +48,15 @@ class MarkdownEditor extends React.Component { componentWillUnmount () { this.cancelQueue() eventEmitter.off('editor:lock', this.lockEditorCode) + eventEmitter.off('editor:focus', this.focusEditor.bind(this)) + } + + focusEditor () { + this.setState({ + status: 'CODE' + }, () => { + this.refs.code.focus() + }) } queueRendering (value) { @@ -109,7 +119,7 @@ class MarkdownEditor extends React.Component { status: 'PREVIEW' }, () => { this.refs.preview.focus() - this.refs.preview.scrollTo(cursorPosition.line) + this.refs.preview.scrollToRow(cursorPosition.line) }) eventEmitter.emit('topbar:togglelockbutton', this.state.status) } @@ -149,24 +159,25 @@ class MarkdownEditor extends React.Component { e.preventDefault() e.stopPropagation() const idMatch = /checkbox-([0-9]+)/ - const checkedMatch = /^\s*[\+\-\*] \[x\]/i - const uncheckedMatch = /^\s*[\+\-\*] \[ \]/ - const checkReplace = /\[x\]/i - const uncheckReplace = /\[ \]/ + const checkedMatch = /^(\s*>?)*\s*[+\-*] \[x]/i + const uncheckedMatch = /^(\s*>?)*\s*[+\-*] \[ ]/ + const checkReplace = /\[x]/i + const uncheckReplace = /\[ ]/ if (idMatch.test(e.target.getAttribute('id'))) { const lineIndex = parseInt(e.target.getAttribute('id').match(idMatch)[1], 10) - 1 const lines = this.refs.code.value .split('\n') const targetLine = lines[lineIndex] + let newLine = targetLine if (targetLine.match(checkedMatch)) { - lines[lineIndex] = targetLine.replace(checkReplace, '[ ]') + newLine = targetLine.replace(checkReplace, '[ ]') } if (targetLine.match(uncheckedMatch)) { - lines[lineIndex] = targetLine.replace(uncheckReplace, '[x]') + newLine = targetLine.replace(uncheckReplace, '[x]') } - this.refs.code.setValue(lines.join('\n')) + this.refs.code.setLineContent(lineIndex, newLine) } } @@ -256,7 +267,7 @@ class MarkdownEditor extends React.Component { } render () { - const {className, value, config, storageKey, noteKey, linesHighlighted} = this.props + const {className, value, config, storageKey, noteKey, linesHighlighted, RTL} = this.props let editorFontSize = parseInt(config.editor.fontSize, 10) if (!(editorFontSize > 0 && editorFontSize < 101)) editorFontSize = 14 @@ -294,6 +305,7 @@ class MarkdownEditor extends React.Component { enableRulers={config.editor.enableRulers} rulers={config.editor.rulers} displayLineNumbers={config.editor.displayLineNumbers} + lineWrapping matchingPairs={config.editor.matchingPairs} matchingTriples={config.editor.matchingTriples} explodingPairs={config.editor.explodingPairs} @@ -309,6 +321,11 @@ class MarkdownEditor extends React.Component { enableSmartPaste={config.editor.enableSmartPaste} hotkey={config.hotkey} switchPreview={config.editor.switchPreview} + enableMarkdownLint={config.editor.enableMarkdownLint} + customMarkdownLintConfig={config.editor.customMarkdownLintConfig} + prettierConfig={config.editor.prettierConfig} + deleteUnusedAttachments={config.editor.deleteUnusedAttachments} + RTL={RTL} /> this.handleContextMenu(e)} onDoubleClick={(e) => this.handleDoubleClick(e)} @@ -343,6 +361,7 @@ class MarkdownEditor extends React.Component { allowCustomCSS={config.preview.allowCustomCSS} lineThroughCheckbox={config.preview.lineThroughCheckbox} onDrop={(e) => this.handleDropImage(e)} + RTL={RTL} /> ) diff --git a/browser/components/MarkdownPreview.js b/browser/components/MarkdownPreview.js index 2cde0fb7..ff88540f 100755 --- a/browser/components/MarkdownPreview.js +++ b/browser/components/MarkdownPreview.js @@ -8,7 +8,7 @@ import consts from 'browser/lib/consts' import Raphael from 'raphael' import flowchart from 'flowchart' import mermaidRender from './render/MermaidRender' -import SequenceDiagram from 'js-sequence-diagrams' +import SequenceDiagram from '@rokt33r/js-sequence-diagrams' import Chart from 'chart.js' import eventEmitter from 'browser/main/lib/eventEmitter' import htmlTextHelper from 'browser/lib/htmlTextHelper' @@ -18,16 +18,15 @@ 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' import uiThemes from 'browser/lib/ui-themes' +import i18n from 'browser/lib/i18n' const { remote, shell } = require('electron') const attachmentManagement = require('../main/lib/dataApi/attachmentManagement') +const buildMarkdownPreviewContextMenu = require('browser/lib/contextMenuBuilder').buildMarkdownPreviewContextMenu const { app } = remote const path = require('path') @@ -35,8 +34,6 @@ const fileUrl = require('file-url') const dialog = remote.dialog -const uri2path = require('file-uri-to-path') - const markdownStyle = require('!!css!stylus?sourceMap!./markdown.styl')[0][1] const appPath = fileUrl( process.env.NODE_ENV === 'production' ? app.getAppPath() : path.resolve() @@ -47,16 +44,30 @@ const CSS_FILES = [ `${appPath}/node_modules/react-image-carousel/lib/css/main.min.css` ] -function buildStyle ( - fontFamily, - fontSize, - codeBlockFontFamily, - lineNumber, - scrollPastEnd, - theme, - allowCustomCSS, - customCSS -) { +/** + * @param {Object} opts + * @param {String} opts.fontFamily + * @param {Numberl} opts.fontSize + * @param {String} opts.codeBlockFontFamily + * @param {String} opts.theme + * @param {Boolean} [opts.lineNumber] Should show line number + * @param {Boolean} [opts.scrollPastEnd] + * @param {Boolean} [opts.allowCustomCSS] Should add custom css + * @param {String} [opts.customCSS] Will be added to bottom, only if `opts.allowCustomCSS` is truthy + * @returns {String} + */ +function buildStyle (opts) { + const { + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS, + RTL + } = opts return ` @font-face { font-family: 'Lato'; @@ -86,12 +97,20 @@ function buildStyle ( url('${appPath}/resources/fonts/MaterialIcons-Regular.woff') format('woff'), url('${appPath}/resources/fonts/MaterialIcons-Regular.ttf') format('truetype'); } + ${markdownStyle} body { font-family: '${fontFamily.join("','")}'; font-size: ${fontSize}px; - ${scrollPastEnd && 'padding-bottom: 90vh;'} + + ${scrollPastEnd ? ` + padding-bottom: 90vh; + box-sizing: border-box; + ` + : ''} + ${RTL ? 'direction: rtl;' : ''} + ${RTL ? 'text-align: right;' : ''} } @media print { body { @@ -101,6 +120,8 @@ body { code { font-family: '${codeBlockFontFamily.join("','")}'; background-color: rgba(0,0,0,0.04); + text-align: left; + direction: ltr; } .lineNumber { ${lineNumber && 'display: block !important;'} @@ -167,6 +188,10 @@ const scrollBarStyle = ` ::-webkit-scrollbar-thumb { background-color: rgba(0, 0, 0, 0.15); } + +::-webkit-scrollbar-track-piece { + background-color: inherit; +} ` const scrollBarDarkStyle = ` ::-webkit-scrollbar { @@ -176,6 +201,10 @@ const scrollBarDarkStyle = ` ::-webkit-scrollbar-thumb { background-color: rgba(0, 0, 0, 0.3); } + +::-webkit-scrollbar-track-piece { + background-color: inherit; +} ` const OSX = global.process.platform === 'darwin' @@ -193,6 +222,19 @@ const defaultCodeBlockFontFamily = [ 'source-code-pro', 'monospace' ] + +// return the line number of the line that used to generate the specified element +// return -1 if the line is not found +function getSourceLineNumberByElement (element) { + let isHasLineNumber = element.dataset.line !== undefined + let parent = element + while (!isHasLineNumber && parent.parentElement !== null) { + parent = parent.parentElement + isHasLineNumber = parent.dataset.line !== undefined + } + return parent.dataset.line !== undefined ? parseInt(parent.dataset.line) : -1 +} + export default class MarkdownPreview extends React.Component { constructor (props) { super(props) @@ -209,7 +251,9 @@ export default class MarkdownPreview extends React.Component { this.saveAsTextHandler = () => this.handleSaveAsText() this.saveAsMdHandler = () => this.handleSaveAsMd() this.saveAsHtmlHandler = () => this.handleSaveAsHtml() + this.saveAsPdfHandler = () => this.handleSaveAsPdf() this.printHandler = () => this.handlePrint() + this.resizeHandler = _.throttle(this.handleResize.bind(this), 100) this.linkClickHandler = this.handleLinkClick.bind(this) this.initMarkdown = this.initMarkdown.bind(this) @@ -236,30 +280,12 @@ export default class MarkdownPreview extends React.Component { } handleContextMenu (event) { - // If a contextMenu handler was passed to us, use it instead of the self-defined one -> return - if (_.isFunction(this.props.onContextMenu)) { + const menu = buildMarkdownPreviewContextMenu(this, event) + const switchPreview = ConfigManager.get().editor.switchPreview + if (menu != null && switchPreview !== 'RIGHTCLICK') { + menu.popup(remote.getCurrentWindow()) + } else if (_.isFunction(this.props.onContextMenu)) { this.props.onContextMenu(event) - return - } - // No contextMenu was passed to us -> execute our own link-opener - if (event.target.tagName.toLowerCase() === 'a') { - const href = event.target.href - const isLocalFile = href.startsWith('file:') - if (isLocalFile) { - const absPath = uri2path(href) - try { - if (fs.lstatSync(absPath).isFile()) { - context.popup([ - { - label: i18n.__('Show in explorer'), - click: (e) => shell.showItemInFolder(absPath) - } - ]) - } - } catch (e) { - console.log('Error while evaluating if the file is locally available', e) - } - } } } @@ -269,17 +295,27 @@ export default class MarkdownPreview extends React.Component { handleMouseDown (e) { const config = ConfigManager.get() + const clickElement = e.target + const targetTag = clickElement.tagName // The direct parent HTML of where was clicked ie "BODY" or "DIV" + const lineNumber = getSourceLineNumberByElement(clickElement) // Line location of element clicked. + if (config.editor.switchPreview === 'RIGHTCLICK' && e.buttons === 2 && config.editor.type === 'SPLIT') { eventEmitter.emit('topbar:togglemodebutton', 'CODE') } - if (e.target != null) { - switch (e.target.tagName) { - case 'A': - case 'INPUT': - return null + if (e.ctrlKey) { + if (config.editor.type === 'SPLIT') { + if (lineNumber !== -1) { + eventEmitter.emit('line:jump', lineNumber) + } + } else { + if (lineNumber !== -1) { + eventEmitter.emit('editor:focus') + eventEmitter.emit('line:jump', lineNumber) + } } } - if (this.props.onMouseDown != null) this.props.onMouseDown(e) + + if (this.props.onMouseDown != null && targetTag === 'BODY') this.props.onMouseDown(e) } handleMouseUp (e) { @@ -298,58 +334,83 @@ export default class MarkdownPreview extends React.Component { this.exportAsDocument('md') } - handleSaveAsHtml () { - this.exportAsDocument('html', (noteContent, exportTasks) => { - const { - fontFamily, - fontSize, - codeBlockFontFamily, - lineNumber, - codeBlockTheme, - scrollPastEnd, - theme, - allowCustomCSS, - customCSS - } = this.getStyleParams() + htmlContentFormatter (noteContent, exportTasks, targetDir) { + const { + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + codeBlockTheme, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS, + RTL + } = this.getStyleParams() - const inlineStyles = buildStyle( - fontFamily, - fontSize, - codeBlockFontFamily, - lineNumber, - scrollPastEnd, - theme, - allowCustomCSS, - customCSS - ) - let body = this.markdown.render(noteContent) - const files = [this.GetCodeThemeLink(codeBlockTheme), ...CSS_FILES] - files.forEach(file => { - if (global.process.platform === 'win32') { - file = file.replace('file:///', '') - } else { - file = file.replace('file://', '') - } - exportTasks.push({ - src: file, - dst: 'css' + const inlineStyles = buildStyle({ + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS, + RTL + }) + let body = this.refs.root.contentWindow.document.body.innerHTML + body = attachmentManagement.fixLocalURLS( + body, + this.props.storagePath + ) + const files = [this.getCodeThemeLink(codeBlockTheme), ...CSS_FILES] + files.forEach(file => { + if (global.process.platform === 'win32') { + file = file.replace('file:///', '') + } else { + file = file.replace('file://', '') + } + exportTasks.push({ + src: file, + dst: 'css' + }) + }) + + let styles = '' + files.forEach(file => { + styles += `` + }) + + return ` + + + + + + ${styles} + + ${body} + ` + } + + handleSaveAsHtml () { + this.exportAsDocument('html', (noteContent, exportTasks, targetDir) => Promise.resolve(this.htmlContentFormatter(noteContent, exportTasks, targetDir))) + } + + handleSaveAsPdf () { + this.exportAsDocument('pdf', (noteContent, exportTasks, targetDir) => { + const printout = new remote.BrowserWindow({show: false, webPreferences: {webSecurity: false, javascript: false}}) + printout.loadURL('data:text/html;charset=UTF-8,' + this.htmlContentFormatter(noteContent, exportTasks, targetDir)) + return new Promise((resolve, reject) => { + printout.webContents.on('did-finish-load', () => { + printout.webContents.printToPDF({}, (err, data) => { + if (err) reject(err) + else resolve(data) + printout.destroy() + }) }) }) - - let styles = '' - files.forEach(file => { - styles += `` - }) - - return ` - - - - - ${styles} - - ${body} - ` }) } @@ -373,7 +434,8 @@ export default class MarkdownPreview extends React.Component { .then(res => { dialog.showMessageBox(remote.getCurrentWindow(), { type: 'info', - message: `Exported to ${filename}` + message: `Exported to ${filename}`, + buttons: [i18n.__('Ok')] }) }) .catch(err => { @@ -480,9 +542,14 @@ export default class MarkdownPreview extends React.Component { 'scroll', this.scrollHandler ) + this.refs.root.contentWindow.addEventListener( + 'resize', + this.resizeHandler + ) 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) } @@ -517,23 +584,31 @@ export default class MarkdownPreview extends React.Component { 'scroll', this.scrollHandler ) + this.refs.root.contentWindow.removeEventListener( + 'resize', + this.resizeHandler + ) eventEmitter.off('export:save-text', this.saveAsTextHandler) eventEmitter.off('export:save-md', this.saveAsMdHandler) eventEmitter.off('export:save-html', this.saveAsHtmlHandler) + eventEmitter.off('export:save-pdf', this.saveAsPdfHandler) eventEmitter.off('print', this.printHandler) } componentDidUpdate (prevProps) { - if (prevProps.value !== this.props.value) this.rewriteIframe() + // actual rewriteIframe function should be called only once + let needsRewriteIframe = false + if (prevProps.value !== this.props.value) needsRewriteIframe = true if ( prevProps.smartQuotes !== this.props.smartQuotes || prevProps.sanitize !== this.props.sanitize || + prevProps.mermaidHTMLLabel !== this.props.mermaidHTMLLabel || prevProps.smartArrows !== this.props.smartArrows || prevProps.breaks !== this.props.breaks || prevProps.lineThroughCheckbox !== this.props.lineThroughCheckbox ) { this.initMarkdown() - this.rewriteIframe() + needsRewriteIframe = true } if ( prevProps.fontFamily !== this.props.fontFamily || @@ -545,11 +620,21 @@ export default class MarkdownPreview extends React.Component { prevProps.theme !== this.props.theme || prevProps.scrollPastEnd !== this.props.scrollPastEnd || prevProps.allowCustomCSS !== this.props.allowCustomCSS || - prevProps.customCSS !== this.props.customCSS + prevProps.customCSS !== this.props.customCSS || + prevProps.RTL !== this.props.RTL ) { this.applyStyle() + needsRewriteIframe = true + } + + if (needsRewriteIframe) { this.rewriteIframe() } + + // Should scroll to top after selecting another note + if (prevProps.noteKey !== this.props.noteKey) { + this.scrollTo(0, 0) + } } getStyleParams () { @@ -560,7 +645,8 @@ export default class MarkdownPreview extends React.Component { scrollPastEnd, theme, allowCustomCSS, - customCSS + customCSS, + RTL } = this.props let { fontFamily, codeBlockFontFamily } = this.props fontFamily = _.isString(fontFamily) && fontFamily.trim().length > 0 @@ -586,7 +672,8 @@ export default class MarkdownPreview extends React.Component { scrollPastEnd, theme, allowCustomCSS, - customCSS + customCSS, + RTL } } @@ -600,13 +687,14 @@ export default class MarkdownPreview extends React.Component { scrollPastEnd, theme, allowCustomCSS, - customCSS + customCSS, + RTL } = this.getStyleParams() this.getWindow().document.getElementById( 'codeTheme' - ).href = this.GetCodeThemeLink(codeBlockTheme) - this.getWindow().document.getElementById('style').innerHTML = buildStyle( + ).href = this.getCodeThemeLink(codeBlockTheme) + this.getWindow().document.getElementById('style').innerHTML = buildStyle({ fontFamily, fontSize, codeBlockFontFamily, @@ -614,18 +702,17 @@ export default class MarkdownPreview extends React.Component { scrollPastEnd, theme, allowCustomCSS, - customCSS - ) + customCSS, + RTL + }) } - GetCodeThemeLink (theme) { - theme = consts.THEMES.some(_theme => _theme === theme) && - theme !== 'default' - ? theme - : 'elegant' - return theme.startsWith('solarized') - ? `${appPath}/node_modules/codemirror/theme/solarized.css` - : `${appPath}/node_modules/codemirror/theme/${theme}.css` + getCodeThemeLink (name) { + const theme = consts.THEMES.find(theme => theme.name === name) + + return theme != null + ? theme.path + : `${appPath}/node_modules/codemirror/theme/elegant.css` } rewriteIframe () { @@ -651,7 +738,8 @@ export default class MarkdownPreview extends React.Component { showCopyNotification, storagePath, noteKey, - sanitize + sanitize, + mermaidHTMLLabel } = this.props let { value, codeBlockTheme } = this.props @@ -683,9 +771,9 @@ export default class MarkdownPreview extends React.Component { } ) - codeBlockTheme = consts.THEMES.some(_theme => _theme === codeBlockTheme) - ? codeBlockTheme - : 'default' + codeBlockTheme = consts.THEMES.find(theme => theme.name === codeBlockTheme) + + const codeBlockThemeClassName = codeBlockTheme ? codeBlockTheme.className : 'cm-s-default' _.forEach( this.refs.root.contentWindow.document.querySelectorAll('.code code'), @@ -698,6 +786,8 @@ export default class MarkdownPreview extends React.Component { copyIcon.innerHTML = '' copyIcon.onclick = e => { + e.preventDefault() + e.stopPropagation() copy(content) if (showCopyNotification) { this.notify('Saved to Clipboard!', { @@ -706,14 +796,11 @@ export default class MarkdownPreview extends React.Component { }) } } + el.parentNode.appendChild(copyIcon) el.innerHTML = '' - if (codeBlockTheme.indexOf('solarized') === 0) { - const [refThema, color] = codeBlockTheme.split(' ') - el.parentNode.className += ` cm-s-${refThema} cm-s-${color}` - } else { - el.parentNode.className += ` cm-s-${codeBlockTheme}` - } + el.parentNode.className += ` ${codeBlockThemeClassName}` + CodeMirror.runMode(content, syntax.mime, el, { tabSize: indentSize }) @@ -784,6 +871,7 @@ export default class MarkdownPreview extends React.Component { canvas.height = height.value + 'vh' } + // eslint-disable-next-line no-unused-vars const chart = new Chart(canvas, chartConfig) } catch (e) { el.className = 'chart-error' @@ -794,7 +882,7 @@ export default class MarkdownPreview extends React.Component { _.forEach( this.refs.root.contentWindow.document.querySelectorAll('.mermaid'), el => { - mermaidRender(el, htmlTextHelper.decodeEntities(el.innerHTML), theme) + mermaidRender(el, htmlTextHelper.decodeEntities(el.innerHTML), theme, mermaidHTMLLabel) } ) @@ -828,80 +916,111 @@ export default class MarkdownPreview extends React.Component { const markdownPreviewIframe = document.querySelector('.MarkdownPreview') const rect = markdownPreviewIframe.getBoundingClientRect() + const config = { attributes: true, subtree: true } + const imgObserver = new MutationObserver((mutationList) => { + for (const mu of mutationList) { + if (mu.target.className === 'carouselContent-enter-done') { + this.setImgOnClickEventHelper(mu.target, rect) + break + } + } + }) + const imgList = markdownPreviewIframe.contentWindow.document.body.querySelectorAll('img') for (const img of imgList) { - 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 parentEl = img.parentElement + this.setImgOnClickEventHelper(img, rect) + imgObserver.observe(parentEl, config) + } - 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 aList = markdownPreviewIframe.contentWindow.document.body.querySelectorAll('a') + for (const a of aList) { + a.removeEventListener('click', this.linkClickHandler) + a.addEventListener('click', this.linkClickHandler) + } + } - const zoomImg = document.createElement('img') - zoomImg.src = img.src + 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: ${baseOnWidth ? zoomImgTop : 0}px; - left: ${baseOnWidth ? 0 : zoomImgLeft}px; - width: ${zoomImgWidth}; - height: ${zoomImgHeight}px; + top: ${originalImgTop}px; + left: ${originalImgLeft}px; + width: ${img.width}px; + height: ${img.height}px; ` - zoomImg.animate([ - originalImgRect, - zoomInImgRect + const zoomOutImgAnimation = zoomImg.animate([ + zoomInImgRect, + originalImgRect ], 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) + zoomOutImgAnimation.onfinish = () => overlay.remove() } + + overlay.appendChild(zoomImg) + document.body.appendChild(overlay) } } + handleResize () { + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll('svg[ratio]'), + el => { + el.setAttribute('height', el.clientWidth / el.getAttribute('ratio')) + } + ) + } + focus () { this.refs.root.focus() } @@ -910,7 +1029,11 @@ export default class MarkdownPreview extends React.Component { return this.refs.root.contentWindow } - scrollTo (targetRow) { + /** + * @public + * @param {Number} targetRow + */ + scrollToRow (targetRow) { const blocks = this.getWindow().document.querySelectorAll( 'body>[data-line]' ) @@ -920,12 +1043,21 @@ export default class MarkdownPreview extends React.Component { const row = parseInt(block.getAttribute('data-line')) if (row > targetRow || index === blocks.length - 1) { block = blocks[index - 1] - block != null && this.getWindow().scrollTo(0, block.offsetTop) + block != null && this.scrollTo(0, block.offsetTop) break } } } + /** + * `document.body.scrollTo` + * @param {Number} x + * @param {Number} y + */ + scrollTo (x, y) { + this.getWindow().document.body.scrollTo(x, y) + } + preventImageDroppedHandler (e) { e.preventDefault() e.stopPropagation() @@ -946,20 +1078,32 @@ export default class MarkdownPreview extends React.Component { e.preventDefault() e.stopPropagation() - const href = e.target.href - const linkHash = href.split('/').pop() + const rawHref = e.target.getAttribute('href') + if (!rawHref) return // not checked href because parser will create file://... string for [empty link]() - const regexNoteInternalLink = /main.html#(.+)/ - if (regexNoteInternalLink.test(linkHash)) { - const targetId = mdurl.encode(linkHash.match(regexNoteInternalLink)[1]) - const targetElement = this.refs.root.contentWindow.document.getElementById( - targetId - ) + const parser = document.createElement('a') + parser.href = rawHref + const isStartWithHash = rawHref[0] === '#' + const { href, hash } = parser - if (targetElement != null) { - this.getWindow().scrollTo(0, targetElement.offsetTop) + const linkHash = hash === '' ? rawHref : hash // needed because we're having special link formats that are removed by parser e.g. :line:10 + + const extractIdRegex = /file:\/\/.*main.?\w*.html#/ // file://path/to/main(.development.)html + const regexNoteInternalLink = new RegExp(`${extractIdRegex.source}(.+)`) + if (isStartWithHash || regexNoteInternalLink.test(rawHref)) { + const posOfHash = linkHash.indexOf('#') + if (posOfHash > -1) { + const extractedId = linkHash.slice(posOfHash + 1) + const targetId = mdurl.encode(extractedId) + const targetElement = this.getWindow().document.getElementById( + targetId + ) + + if (targetElement != null) { + this.scrollTo(0, targetElement.offsetTop) + } + return } - return } // this will match the new uuid v4 hash and the old hash diff --git a/browser/components/MarkdownSplitEditor.js b/browser/components/MarkdownSplitEditor.js index 4477288a..8ae9b751 100644 --- a/browser/components/MarkdownSplitEditor.js +++ b/browser/components/MarkdownSplitEditor.js @@ -78,24 +78,25 @@ class MarkdownSplitEditor extends React.Component { e.preventDefault() e.stopPropagation() const idMatch = /checkbox-([0-9]+)/ - const checkedMatch = /^\s*[\+\-\*] \[x\]/i - const uncheckedMatch = /^\s*[\+\-\*] \[ \]/ - const checkReplace = /\[x\]/i - const uncheckReplace = /\[ \]/ + const checkedMatch = /^(\s*>?)*\s*[+\-*] \[x]/i + const uncheckedMatch = /^(\s*>?)*\s*[+\-*] \[ ]/ + const checkReplace = /\[x]/i + const uncheckReplace = /\[ ]/ if (idMatch.test(e.target.getAttribute('id'))) { const lineIndex = parseInt(e.target.getAttribute('id').match(idMatch)[1], 10) - 1 const lines = this.refs.code.value .split('\n') const targetLine = lines[lineIndex] + let newLine = targetLine if (targetLine.match(checkedMatch)) { - lines[lineIndex] = targetLine.replace(checkReplace, '[ ]') + newLine = targetLine.replace(checkReplace, '[ ]') } if (targetLine.match(uncheckedMatch)) { - lines[lineIndex] = targetLine.replace(uncheckReplace, '[x]') + newLine = targetLine.replace(uncheckReplace, '[x]') } - this.refs.code.setValue(lines.join('\n')) + this.refs.code.setLineContent(lineIndex, newLine) } } @@ -136,7 +137,7 @@ class MarkdownSplitEditor extends React.Component { } render () { - const {config, value, storageKey, noteKey, linesHighlighted} = this.props + const {config, value, storageKey, noteKey, linesHighlighted, RTL} = this.props const storage = findStorage(storageKey) let editorFontSize = parseInt(config.editor.fontSize, 10) if (!(editorFontSize > 0 && editorFontSize < 101)) editorFontSize = 14 @@ -150,7 +151,6 @@ class MarkdownSplitEditor extends React.Component { onMouseMove={e => this.handleMouseMove(e)} onMouseUp={e => this.handleMouseUp(e)}>
this.handleMouseDown(e)} >
) diff --git a/browser/components/MarkdownSplitEditor.styl b/browser/components/MarkdownSplitEditor.styl index f7e2ccb1..d716c85f 100644 --- a/browser/components/MarkdownSplitEditor.styl +++ b/browser/components/MarkdownSplitEditor.styl @@ -8,9 +8,30 @@ top -2px width 0 z-index 0 + border-left 1px solid $ui-borderColor .slider-hitbox absolute top bottom left right width 7px left -3px z-index 10 cursor col-resize + +body[data-theme="dark"] + .root + .slider + border-left 1px solid $ui-dark-borderColor + +body[data-theme="solarized-dark"] + .root + .slider + border-left 1px solid $ui-solarized-dark-borderColor + +body[data-theme="monokai"] + .root + .slider + border-left 1px solid $ui-monokai-borderColor + +body[data-theme="dracula"] + .root + .slider + border-left 1px solid $ui-dracula-borderColor diff --git a/browser/components/ModalEscButton.js b/browser/components/ModalEscButton.js index 836c0052..39129d18 100644 --- a/browser/components/ModalEscButton.js +++ b/browser/components/ModalEscButton.js @@ -8,7 +8,7 @@ const ModalEscButton = ({ }) => ( ) diff --git a/browser/components/NavToggleButton.js b/browser/components/NavToggleButton.js index ad0ff54c..7dc75e90 100644 --- a/browser/components/NavToggleButton.js +++ b/browser/components/NavToggleButton.js @@ -16,8 +16,8 @@ const NavToggleButton = ({isFolded, handleToggleButtonClick}) => ( onClick={(e) => handleToggleButtonClick(e)} > {isFolded - ? - : + ? + : } ) diff --git a/browser/components/NavToggleButton.styl b/browser/components/NavToggleButton.styl index ae9dd6ca..a4bfc83e 100644 --- a/browser/components/NavToggleButton.styl +++ b/browser/components/NavToggleButton.styl @@ -7,7 +7,7 @@ border-radius 16.5px height 34px width 34px - line-height 32px + line-height 100% padding 0 &:hover border: 1px solid #1EC38B; @@ -17,10 +17,16 @@ body[data-theme="white"] navWhiteButtonColor() -body[data-theme="dark"] - .navToggle - &:hover - background-color alpha($ui-dark-button--active-backgroundColor, 20%) +apply-theme(theme) + body[data-theme={theme}] + .navToggle:hover + background-color alpha(get-theme-var(theme, 'button--active-backgroundColor'), 20%) + border 1px solid get-theme-var(theme, 'button--active-backgroundColor') transition 0.15s - color $ui-dark-text-color + color get-theme-var(theme, 'text-color') +for theme in 'dark' 'dracula' 'solarized-dark' + apply-theme(theme) + +for theme in $themes + apply-theme(theme) \ No newline at end of file diff --git a/browser/components/NoteItem.js b/browser/components/NoteItem.js index 625bb38d..b672bfcc 100644 --- a/browser/components/NoteItem.js +++ b/browser/components/NoteItem.js @@ -3,8 +3,9 @@ */ import PropTypes from 'prop-types' import React from 'react' -import { isArray } from 'lodash' +import { isArray, sortBy } from 'lodash' import invertColor from 'invert-color' +import Emoji from 'react-emoji-render' import CSSModules from 'browser/lib/CSSModules' import { getTodoStatus } from 'browser/lib/getTodoStatus' import styles from './NoteItem.styl' @@ -43,7 +44,7 @@ const TagElementList = (tags, showTagsAlphabetically, coloredTags) => { } if (showTagsAlphabetically) { - return _.sortBy(tags).map(tag => TagElement({ tagName: tag, color: coloredTags[tag] })) + return sortBy(tags).map(tag => TagElement({ tagName: tag, color: coloredTags[tag] })) } else { return tags.map(tag => TagElement({ tagName: tag, color: coloredTags[tag] })) } @@ -87,7 +88,7 @@ const NoteItem = ({ : }
{note.title.trim().length > 0 - ? note.title + ? : {i18n.__('Empty note')}}
@@ -148,15 +149,14 @@ NoteItem.propTypes = { tags: PropTypes.array, isStarred: PropTypes.bool.isRequired, isTrashed: PropTypes.bool.isRequired, - blog: { + blog: PropTypes.shape({ blogLink: PropTypes.string, blogId: PropTypes.number - } + }) }), handleNoteClick: PropTypes.func.isRequired, handleNoteContextMenu: PropTypes.func.isRequired, - handleDragStart: PropTypes.func.isRequired, - handleDragEnd: PropTypes.func.isRequired + handleDragStart: PropTypes.func.isRequired } export default CSSModules(NoteItem, styles) diff --git a/browser/components/SideNavFilter.js b/browser/components/SideNavFilter.js index 3a259ce7..5d5d627f 100644 --- a/browser/components/SideNavFilter.js +++ b/browser/components/SideNavFilter.js @@ -74,7 +74,7 @@ SideNavFilter.propTypes = { isStarredActive: PropTypes.bool.isRequired, isTrashedActive: PropTypes.bool.isRequired, handleStarredButtonClick: PropTypes.func.isRequired, - handleTrashdButtonClick: PropTypes.func.isRequired + handleTrashedButtonClick: PropTypes.func.isRequired } export default CSSModules(SideNavFilter, styles) diff --git a/browser/components/SnippetTab.js b/browser/components/SnippetTab.js index c030351f..d29130c7 100644 --- a/browser/components/SnippetTab.js +++ b/browser/components/SnippetTab.js @@ -114,7 +114,7 @@ class SnippetTab extends React.Component { > {snippet.name.trim().length > 0 ? snippet.name - : + : {i18n.__('Unnamed')} } diff --git a/browser/components/TodoProcess.js b/browser/components/TodoProcess.js index 251fd5b9..9d1f93cf 100644 --- a/browser/components/TodoProcess.js +++ b/browser/components/TodoProcess.js @@ -25,10 +25,10 @@ const TodoProcess = ({ ) TodoProcess.propTypes = { - todoStatus: { + todoStatus: PropTypes.exact({ total: PropTypes.number.isRequired, completed: PropTypes.number.isRequired - } + }) } export default CSSModules(TodoProcess, styles) diff --git a/browser/components/markdown.styl b/browser/components/markdown.styl index ca1f9cdd..00f0b51c 100644 --- a/browser/components/markdown.styl +++ b/browser/components/markdown.styl @@ -363,7 +363,10 @@ admonition_types = { danger: {color: #c2185b, icon: "block"}, caution: {color: #ffa726, icon: "warning"}, error: {color: #d32f2f, icon: "error_outline"}, - attention: {color: #455a64, icon: "priority_high"} + question: {color: #64dd17, icon: "help_outline"}, + quote: {color: #9e9e9e, icon: "format_quote"}, + abstract: {color: #00b0ff, icon: "subject"}, + attention: {color: #455a64, icon: "priority_high"}, } for name, val in admonition_types @@ -424,6 +427,9 @@ pre.fence canvas, svg max-width 100% !important + svg[ratio] + width 100% + .gallery width 100% height 50vh @@ -444,6 +450,44 @@ pre.fence color $ui-text-color background-color $ui-tag-backgroundColor +.markdownIt-TOC-wrapper + list-style none + position fixed + right 0 + top 0 + margin-left 15px + z-index 1000 + transition transform .2s ease-in-out + transform translateX(100%) + + .markdownIt-TOC + display block + max-height 90vh + overflow-y auto + padding 25px + padding-left 38px + + &, + &:before + background-color $ui-dark-backgroundColor + color: $ui-dark-text-color + + &:hover + transform translateX(-15px) + + &:before + content 'TOC' + position absolute + width 60px + height 30px + top 60px + left -29px + display flex + align-items center + justify-content center + transform-origin top left + transform rotate(-90deg) + themeDarkBackground = darken(#21252B, 10%) themeDarkText = #f9f9f9 themeDarkBorder = lighten(themeDarkBackground, 20%) @@ -511,6 +555,12 @@ body[data-theme="dark"] color $ui-dark-text-color background-color $ui-dark-tag-backgroundColor + .markdownIt-TOC-wrapper + &, + &:before + background-color darken(themeDarkBackground, 5%) + color themeDarkText + apply-theme(theme) body[data-theme={theme}] color get-theme-var(theme, 'text-color') @@ -554,8 +604,14 @@ apply-theme(theme) color get-theme-var(theme, 'button--active-color') background-color get-theme-var(theme, 'button-backgroundColor') + .markdownIt-TOC-wrapper + &, + &:before + background-color darken(get-theme-var(theme, 'noteDetail-backgroundColor'), 15%) + color themeDarkText + for theme in 'solarized-dark' 'dracula' apply-theme(theme) for theme in $themes - apply-theme(theme) \ No newline at end of file + apply-theme(theme) diff --git a/browser/components/render/MermaidRender.js b/browser/components/render/MermaidRender.js index 61bd8504..5bed9bf1 100644 --- a/browser/components/render/MermaidRender.js +++ b/browser/components/render/MermaidRender.js @@ -20,11 +20,12 @@ function getId () { return id } -function render (element, content, theme) { +function render (element, content, theme, enableHTMLLabel) { try { const height = element.attributes.getNamedItem('data-height') + const isPredefined = height && height.value !== 'undefined' - if (height && height.value !== 'undefined') { + if (isPredefined) { element.style.height = height.value + 'vh' } @@ -33,11 +34,33 @@ function render (element, content, theme) { mermaidAPI.initialize({ theme: isDarkTheme ? 'dark' : 'default', themeCSS: isDarkTheme ? darkThemeStyling : '', - useMaxWidth: false + flowchart: { + htmlLabels: enableHTMLLabel + }, + gantt: { + useWidth: element.clientWidth + } }) mermaidAPI.render(getId(), content, (svgGraph) => { element.innerHTML = svgGraph + + if (!isPredefined) { + const el = element.firstChild + const viewBox = el.getAttribute('viewBox').split(' ') + + let ratio = viewBox[2] / viewBox[3] + + if (el.style.maxWidth) { + const maxWidth = parseFloat(el.style.maxWidth) + + ratio *= el.parentNode.clientWidth / maxWidth + } + + el.setAttribute('ratio', ratio) + el.setAttribute('height', el.parentNode.clientWidth / ratio) + console.log(el) + } }) } catch (e) { element.className = 'mermaid-error' diff --git a/browser/lib/CMLanguageList.js b/browser/lib/CMLanguageList.js new file mode 100644 index 00000000..0a8652a6 --- /dev/null +++ b/browser/lib/CMLanguageList.js @@ -0,0 +1,78 @@ +export const languageMaps = { + brainfuck: 'Brainfuck', + cpp: 'C++', + cs: 'C#', + clojure: 'Clojure', + 'clojure-repl': 'ClojureScript', + cmake: 'CMake', + coffeescript: 'CoffeeScript', + crystal: 'Crystal', + css: 'CSS', + d: 'D', + dart: 'Dart', + delphi: 'Pascal', + diff: 'Diff', + django: 'Django', + dockerfile: 'Dockerfile', + ebnf: 'EBNF', + elm: 'Elm', + erlang: 'Erlang', + 'erlang-repl': 'Erlang', + fortran: 'Fortran', + fsharp: 'F#', + gherkin: 'Gherkin', + go: 'Go', + groovy: 'Groovy', + haml: 'HAML', + haskell: 'Haskell', + haxe: 'Haxe', + http: 'HTTP', + ini: 'toml', + java: 'Java', + javascript: 'JavaScript', + json: 'JSON', + julia: 'Julia', + kotlin: 'Kotlin', + less: 'LESS', + livescript: 'LiveScript', + lua: 'Lua', + markdown: 'Markdown', + mathematica: 'Mathematica', + nginx: 'Nginx', + nsis: 'NSIS', + objectivec: 'Objective-C', + ocaml: 'Ocaml', + perl: 'Perl', + php: 'PHP', + powershell: 'PowerShell', + properties: 'Properties files', + protobuf: 'ProtoBuf', + python: 'Python', + puppet: 'Puppet', + q: 'Q', + r: 'R', + ruby: 'Ruby', + rust: 'Rust', + sas: 'SAS', + scala: 'Scala', + scheme: 'Scheme', + scss: 'SCSS', + shell: 'Shell', + smalltalk: 'Smalltalk', + sml: 'SML', + sql: 'SQL', + stylus: 'Stylus', + swift: 'Swift', + tcl: 'Tcl', + tex: 'LaTex', + typescript: 'TypeScript', + twig: 'Twig', + vbnet: 'VB.NET', + vbscript: 'VBScript', + verilog: 'Verilog', + vhdl: 'VHDL', + xml: 'HTML', + xquery: 'XQuery', + yaml: 'YAML', + elixir: 'Elixir' +} diff --git a/browser/lib/CSSModules.js b/browser/lib/CSSModules.js index 181274f4..691b44d2 100644 --- a/browser/lib/CSSModules.js +++ b/browser/lib/CSSModules.js @@ -1,5 +1,5 @@ import CSSModules from 'react-css-modules' export default function (component, styles) { - return CSSModules(component, styles, {errorWhenNotFound: false}) + return CSSModules(component, styles, {handleNotFoundStyleName: 'log'}) } diff --git a/browser/lib/Languages.js b/browser/lib/Languages.js index 8c3747a9..42755af9 100644 --- a/browser/lib/Languages.js +++ b/browser/lib/Languages.js @@ -11,6 +11,10 @@ const languages = [ name: 'Chinese (zh-TW)', locale: 'zh-TW' }, + { + name: 'Czech', + locale: 'cs' + }, { name: 'Danish', locale: 'da' @@ -62,10 +66,12 @@ const languages = [ { name: 'Spanish', locale: 'es-ES' - }, { + }, + { name: 'Turkish', locale: 'tr' - }, { + }, + { name: 'Thai', locale: 'th' } @@ -82,4 +88,3 @@ module.exports = { return languages } } - diff --git a/browser/lib/SnippetManager.js b/browser/lib/SnippetManager.js new file mode 100644 index 00000000..70f9b400 --- /dev/null +++ b/browser/lib/SnippetManager.js @@ -0,0 +1,91 @@ +import crypto from 'crypto' +import fs from 'fs' +import consts from './consts' + +class SnippetManager { + constructor () { + this.defaultSnippet = [ + { + id: crypto.randomBytes(16).toString('hex'), + name: 'Dummy text', + prefix: ['lorem', 'ipsum'], + content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.' + } + ] + this.snippets = [] + this.expandSnippet = this.expandSnippet.bind(this) + this.init = this.init.bind(this) + this.assignSnippets = this.assignSnippets.bind(this) + } + + init () { + if (fs.existsSync(consts.SNIPPET_FILE)) { + try { + this.snippets = JSON.parse( + fs.readFileSync(consts.SNIPPET_FILE, { encoding: 'UTF-8' }) + ) + } catch (error) { + console.log('Error while parsing snippet file') + } + return + } + fs.writeFileSync( + consts.SNIPPET_FILE, + JSON.stringify(this.defaultSnippet, null, 4), + 'utf8' + ) + this.snippets = this.defaultSnippet + } + + assignSnippets (snippets) { + this.snippets = snippets + } + + expandSnippet (wordBeforeCursor, cursor, cm) { + const templateCursorString = ':{}' + for (let i = 0; i < this.snippets.length; i++) { + if (this.snippets[i].prefix.indexOf(wordBeforeCursor.text) === -1) { + continue + } + if (this.snippets[i].content.indexOf(templateCursorString) !== -1) { + const snippetLines = this.snippets[i].content.split('\n') + let cursorLineNumber = 0 + let cursorLinePosition = 0 + + let cursorIndex + for (let j = 0; j < snippetLines.length; j++) { + cursorIndex = snippetLines[j].indexOf(templateCursorString) + + if (cursorIndex !== -1) { + cursorLineNumber = j + cursorLinePosition = cursorIndex + + break + } + } + + cm.replaceRange( + this.snippets[i].content.replace(templateCursorString, ''), + wordBeforeCursor.range.from, + wordBeforeCursor.range.to + ) + cm.setCursor({ + line: cursor.line + cursorLineNumber, + ch: cursorLinePosition + cursor.ch - wordBeforeCursor.text.length + }) + } else { + cm.replaceRange( + this.snippets[i].content, + wordBeforeCursor.range.from, + wordBeforeCursor.range.to + ) + } + return true + } + + return false + } +} + +const manager = new SnippetManager() +export default manager diff --git a/browser/lib/confirmDeleteNote.js b/browser/lib/confirmDeleteNote.js index 80d1ffc7..b67ed7e3 100644 --- a/browser/lib/confirmDeleteNote.js +++ b/browser/lib/confirmDeleteNote.js @@ -6,7 +6,7 @@ const { dialog } = remote export function confirmDeleteNote (confirmDeletion, permanent) { if (confirmDeletion || permanent) { const alertConfig = { - ype: 'warning', + type: 'warning', message: i18n.__('Confirm note deletion'), detail: i18n.__('This will permanently remove this note.'), buttons: [i18n.__('Confirm'), i18n.__('Cancel')] diff --git a/browser/lib/consts.js b/browser/lib/consts.js index 84b962eb..ed497376 100644 --- a/browser/lib/consts.js +++ b/browser/lib/consts.js @@ -3,14 +3,43 @@ const fs = require('sander') const { remote } = require('electron') const { app } = remote -const themePath = process.env.NODE_ENV === 'production' - ? path.join(app.getAppPath(), './node_modules/codemirror/theme') - : require('path').resolve('./node_modules/codemirror/theme') -const themes = fs.readdirSync(themePath) - .map((themePath) => { - return themePath.substring(0, themePath.lastIndexOf('.')) - }) -themes.splice(themes.indexOf('solarized'), 1, 'solarized dark', 'solarized light') +const CODEMIRROR_THEME_PATH = 'node_modules/codemirror/theme' +const CODEMIRROR_EXTRA_THEME_PATH = 'extra_scripts/codemirror/theme' + +const isProduction = process.env.NODE_ENV === 'production' + +const paths = [ + isProduction ? path.join(app.getAppPath(), CODEMIRROR_THEME_PATH) : path.resolve(CODEMIRROR_THEME_PATH), + isProduction ? path.join(app.getAppPath(), CODEMIRROR_EXTRA_THEME_PATH) : path.resolve(CODEMIRROR_EXTRA_THEME_PATH) +] + +const themes = paths + .map(directory => fs.readdirSync(directory).map(file => { + const name = file.substring(0, file.lastIndexOf('.')) + + return { + name, + path: path.join(directory, file), + className: `cm-s-${name}` + } + })) + .reduce((accumulator, value) => accumulator.concat(value), []) + .sort((a, b) => a.name.localeCompare(b.name)) + +themes.splice(themes.findIndex(({ name }) => name === 'solarized'), 1, { + name: 'solarized dark', + path: path.join(paths[0], 'solarized.css'), + className: `cm-s-solarized cm-s-dark` +}, { + name: 'solarized light', + path: path.join(paths[0], 'solarized.css'), + className: `cm-s-solarized cm-s-light` +}) +themes.splice(0, 0, { + name: 'default', + path: path.join(paths[0], 'elegant.css'), + className: `cm-s-default` +}) const snippetFile = process.env.NODE_ENV !== 'test' ? path.join(app.getPath('userData'), 'snippets.json') @@ -35,7 +64,7 @@ const consts = { 'Dodger Blue', 'Violet Eggplant' ], - THEMES: ['default'].concat(themes), + THEMES: themes, SNIPPET_FILE: snippetFile, DEFAULT_EDITOR_FONT_FAMILY: [ 'Monaco', diff --git a/browser/lib/contextMenuBuilder.js b/browser/lib/contextMenuBuilder.js index cf92f52e..ff3349eb 100644 --- a/browser/lib/contextMenuBuilder.js +++ b/browser/lib/contextMenuBuilder.js @@ -1,6 +1,12 @@ +import i18n from 'browser/lib/i18n' +import fs from 'fs' + const {remote} = require('electron') const {Menu} = remote.require('electron') +const {clipboard} = remote.require('electron') +const {shell} = remote.require('electron') const spellcheck = require('./spellcheck') +const uri2path = require('file-uri-to-path') /** * Creates the context menu that is shown when there is a right click in the editor of a (not-snippet) note. @@ -62,4 +68,57 @@ const buildEditorContextMenu = function (editor, event) { return Menu.buildFromTemplate(template) } -module.exports = buildEditorContextMenu +/** + * Creates the context menu that is shown when there is a right click Markdown preview of a (not-snippet) note. + * @param {MarkdownPreview} markdownPreview + * @param {MouseEvent} event that has triggered the creation of the context menu + * @returns {Electron.Menu} The created electron context menu + */ +const buildMarkdownPreviewContextMenu = function (markdownPreview, event) { + if (markdownPreview == null || event == null || event.pageX == null || event.pageY == null) { + return null + } + + // Default context menu inclusions + const template = [{ + role: 'copy' + }, { + role: 'selectall' + }] + + if (event.target.tagName.toLowerCase() === 'a' && event.target.getAttribute('href')) { + // Link opener for files on the local system pointed to by href + const href = event.target.href + const isLocalFile = href.startsWith('file:') + if (isLocalFile) { + const absPath = uri2path(href) + try { + if (fs.lstatSync(absPath).isFile()) { + template.push( + { + label: i18n.__('Show in explorer'), + click: (e) => shell.showItemInFolder(absPath) + } + ) + } + } catch (e) { + console.log('Error while evaluating if the file is locally available', e) + } + } + + // Add option to context menu to copy url + template.push( + { + label: i18n.__('Copy Url'), + click: (e) => clipboard.writeText(href) + } + ) + } + return Menu.buildFromTemplate(template) +} + +module.exports = +{ + buildEditorContextMenu: buildEditorContextMenu, + buildMarkdownPreviewContextMenu: buildMarkdownPreviewContextMenu +} diff --git a/browser/lib/customMeta.js b/browser/lib/customMeta.js index 0d4ee1e3..b890cf55 100644 --- a/browser/lib/customMeta.js +++ b/browser/lib/customMeta.js @@ -1,5 +1,10 @@ import CodeMirror from 'codemirror' import 'codemirror-mode-elixir' -CodeMirror.modeInfo.push({name: 'Stylus', mime: 'text/x-styl', mode: 'stylus', ext: ['styl'], alias: ['styl']}) +const stylusCodeInfo = CodeMirror.modeInfo.find(info => info.name === 'Stylus') +if (stylusCodeInfo == null) { + CodeMirror.modeInfo.push({name: 'Stylus', mime: 'text/x-styl', mode: 'stylus', ext: ['styl'], alias: ['styl']}) +} else { + stylusCodeInfo.alias = ['styl'] +} CodeMirror.modeInfo.push({name: 'Elixir', mime: 'text/x-elixir', mode: 'elixir', ext: ['ex']}) diff --git a/browser/lib/getTodoStatus.js b/browser/lib/getTodoStatus.js index ab0d7809..8b552109 100644 --- a/browser/lib/getTodoStatus.js +++ b/browser/lib/getTodoStatus.js @@ -4,11 +4,11 @@ export function getTodoStatus (content) { let numberOfCompletedTodo = 0 splitted.forEach((line) => { - const trimmedLine = line.trim() - if (trimmedLine.match(/^[\+\-\*] \[(\s|x)\] ./i)) { + const trimmedLine = line.trim().replace(/^(>\s*)*/, '') + if (trimmedLine.match(/^[+\-*] \[(\s|x)] ./i)) { numberOfTodo++ } - if (trimmedLine.match(/^[\+\-\*] \[x\] ./i)) { + if (trimmedLine.match(/^[+\-*] \[x] ./i)) { numberOfCompletedTodo++ } }) diff --git a/browser/lib/keygen.js b/browser/lib/keygen.js index 814efedd..557a8a40 100644 --- a/browser/lib/keygen.js +++ b/browser/lib/keygen.js @@ -1,5 +1,4 @@ const crypto = require('crypto') -const _ = require('lodash') const uuidv4 = require('uuid/v4') module.exports = function (uuid) { diff --git a/browser/lib/markdown-it-sanitize-html.js b/browser/lib/markdown-it-sanitize-html.js index 8f6d86a8..641216e3 100644 --- a/browser/lib/markdown-it-sanitize-html.js +++ b/browser/lib/markdown-it-sanitize-html.js @@ -15,7 +15,7 @@ module.exports = function sanitizePlugin (md, options) { options ) } - if (state.tokens[tokenIdx].type === '_fence') { + if (state.tokens[tokenIdx].type.match(/.*_fence$/)) { // escapeHtmlCharacters has better performance state.tokens[tokenIdx].content = escapeHtmlCharacters( state.tokens[tokenIdx].content, @@ -96,6 +96,10 @@ function sanitizeInline (html, options) { function naughtyHRef (href, options) { // href = href.replace(/[\x00-\x20]+/g, '') + if (!href) { + // No href + return false + } href = href.replace(/<\!\-\-.*?\-\-\>/g, '') const matches = href.match(/^([a-zA-Z]+)\:/) diff --git a/browser/lib/markdown-toc-generator.js b/browser/lib/markdown-toc-generator.js index af1c833f..7c76c1f3 100644 --- a/browser/lib/markdown-toc-generator.js +++ b/browser/lib/markdown-toc-generator.js @@ -21,13 +21,15 @@ function uniqueSlug (slug, slugs, opts) { } function linkify (token) { - token.content = mdlink(token.content, '#' + token.slug) + token.content = mdlink(token.content, `#${decodeURI(token.slug)}`) return token } const TOC_MARKER_START = '' const TOC_MARKER_END = '' +const tocRegex = new RegExp(`${TOC_MARKER_START}[\\s\\S]*?${TOC_MARKER_END}`) + /** * Takes care of proper updating given editor with TOC. * If TOC doesn't exit in the editor, it's inserted at current caret position. @@ -35,12 +37,6 @@ const TOC_MARKER_END = '' * @param editor CodeMirror editor to be updated with TOC */ export function generateInEditor (editor) { - const tocRegex = new RegExp(`${TOC_MARKER_START}[\\s\\S]*?${TOC_MARKER_END}`) - - function tocExistsInEditor () { - return tocRegex.test(editor.getValue()) - } - function updateExistingToc () { const toc = generate(editor.getValue()) const search = editor.getSearchCursor(tocRegex) @@ -54,13 +50,17 @@ export function generateInEditor (editor) { editor.replaceRange(wrapTocWithEol(toc, editor), editor.getCursor()) } - if (tocExistsInEditor()) { + if (tocExistsInEditor(editor)) { updateExistingToc() } else { addTocAtCursorPosition() } } +export function tocExistsInEditor (editor) { + return tocRegex.test(editor.getValue()) +} + /** * Generates MD TOC based on MD document passed as string. * @param markdownText MD document @@ -94,5 +94,6 @@ function wrapTocWithEol (toc, editor) { export default { generate, - generateInEditor + generateInEditor, + tocExistsInEditor } diff --git a/browser/lib/markdown.js b/browser/lib/markdown.js index 0ea15ba9..c915f0ed 100644 --- a/browser/lib/markdown.js +++ b/browser/lib/markdown.js @@ -2,7 +2,9 @@ import markdownit from 'markdown-it' import sanitize from './markdown-it-sanitize-html' import emoji from 'markdown-it-emoji' import math from '@rokt33r/markdown-it-math' +import mdurl from 'mdurl' import smartArrows from 'markdown-it-smartarrows' +import markdownItTocAndAnchor from '@hikerpig/markdown-it-toc-and-anchor' import _ from 'lodash' import ConfigManager from 'browser/main/lib/ConfigManager' import katex from 'katex' @@ -32,6 +34,7 @@ class Markdown { const updatedOptions = Object.assign(defaultOptions, options) this.md = markdownit(updatedOptions) + this.md.linkify.set({ fuzzyLink: false }) if (updatedOptions.sanitize !== 'NONE') { const allowedTags = ['iframe', 'input', 'b', @@ -121,10 +124,23 @@ class Markdown { slugify: require('./slugify') }) this.md.use(require('markdown-it-kbd')) - this.md.use(require('markdown-it-admonition'), {types: ['note', 'hint', 'attention', 'caution', 'danger', 'error']}) + this.md.use(require('markdown-it-admonition'), {types: ['note', 'hint', 'attention', 'caution', 'danger', 'error', 'quote', 'abstract', 'question']}) this.md.use(require('markdown-it-abbr')) this.md.use(require('markdown-it-sub')) this.md.use(require('markdown-it-sup')) + + this.md.use(md => { + markdownItTocAndAnchor(md, { + toc: true, + tocPattern: /\[TOC\]/i, + anchorLink: false, + appendIdToHeading: false + }) + + md.renderer.rules.toc_open = () => '
' + md.renderer.rules.toc_close = () => '
' + }) + this.md.use(require('./markdown-it-deflist')) this.md.use(require('./markdown-it-frontmatter')) @@ -149,9 +165,9 @@ class Markdown { const content = token.content.split('\n').slice(0, -1).map(line => { const match = /!\[[^\]]*]\(([^\)]*)\)/.exec(line) if (match) { - return match[1] + return mdurl.encode(match[1]) } else { - return line + return mdurl.encode(line) } }).join('\n') @@ -181,32 +197,47 @@ class Markdown { }) const deflate = require('markdown-it-plantuml/lib/deflate') - this.md.use(require('markdown-it-plantuml'), '', { - generateSource: function (umlCode) { - const stripTrailingSlash = (url) => url.endsWith('/') ? url.slice(0, -1) : url - const serverAddress = stripTrailingSlash(config.preview.plantUMLServerAddress) + '/svg' - const s = unescape(encodeURIComponent(umlCode)) - const zippedCode = deflate.encode64( - deflate.zip_deflate(`@startuml\n${s}\n@enduml`, 9) - ) - return `${serverAddress}/${zippedCode}` - } + const plantuml = require('markdown-it-plantuml') + const plantUmlStripTrailingSlash = (url) => url.endsWith('/') ? url.slice(0, -1) : url + const plantUmlServerAddress = plantUmlStripTrailingSlash(config.preview.plantUMLServerAddress) + const parsePlantUml = function (umlCode, openMarker, closeMarker, type) { + const s = unescape(encodeURIComponent(umlCode)) + const zippedCode = deflate.encode64( + deflate.zip_deflate(`${openMarker}\n${s}\n${closeMarker}`, 9) + ) + return `${plantUmlServerAddress}/${type}/${zippedCode}` + } + + this.md.use(plantuml, { + generateSource: (umlCode) => parsePlantUml(umlCode, '@startuml', '@enduml', 'svg') }) - // Ditaa support - this.md.use(require('markdown-it-plantuml'), { + // Ditaa support. PlantUML server doesn't support Ditaa in SVG, so we set the format as PNG at the moment. + this.md.use(plantuml, { openMarker: '@startditaa', closeMarker: '@endditaa', - generateSource: function (umlCode) { - const stripTrailingSlash = (url) => url.endsWith('/') ? url.slice(0, -1) : url - // Currently PlantUML server doesn't support Ditaa in SVG, so we set the format as PNG at the moment. - const serverAddress = stripTrailingSlash(config.preview.plantUMLServerAddress) + '/png' - const s = unescape(encodeURIComponent(umlCode)) - const zippedCode = deflate.encode64( - deflate.zip_deflate(`@startditaa\n${s}\n@endditaa`, 9) - ) - return `${serverAddress}/${zippedCode}` - } + generateSource: (umlCode) => parsePlantUml(umlCode, '@startditaa', '@endditaa', 'png') + }) + + // Mindmap support + this.md.use(plantuml, { + openMarker: '@startmindmap', + closeMarker: '@endmindmap', + generateSource: (umlCode) => parsePlantUml(umlCode, '@startmindmap', '@endmindmap', 'svg') + }) + + // WBS support + this.md.use(plantuml, { + openMarker: '@startwbs', + closeMarker: '@endwbs', + generateSource: (umlCode) => parsePlantUml(umlCode, '@startwbs', '@endwbs', 'svg') + }) + + // Gantt support + this.md.use(plantuml, { + openMarker: '@startgantt', + closeMarker: '@endgantt', + generateSource: (umlCode) => parsePlantUml(umlCode, '@startgantt', '@endgantt', 'svg') }) // Override task item @@ -287,7 +318,9 @@ class Markdown { case 'list_item_open': case 'paragraph_open': case 'table_open': - token.attrPush(['data-line', token.map[0]]) + if (token.map) { + token.attrPush(['data-line', token.map[0]]) + } } }) const result = originalRender.call(this.md.renderer, tokens, options, env) diff --git a/browser/lib/newNote.js b/browser/lib/newNote.js index d8ef196f..4eec24f1 100644 --- a/browser/lib/newNote.js +++ b/browser/lib/newNote.js @@ -1,7 +1,8 @@ -import { hashHistory } from 'react-router' import dataApi from 'browser/main/lib/dataApi' import ee from 'browser/main/lib/eventEmitter' import AwsMobileAnalyticsConfig from 'browser/main/lib/AwsMobileAnalyticsConfig' +import queryString from 'query-string' +import { push } from 'connected-react-router' export function createMarkdownNote (storage, folder, dispatch, location, params, config) { AwsMobileAnalyticsConfig.recordDynamicCustomEvent('ADD_MARKDOWN') @@ -28,10 +29,10 @@ export function createMarkdownNote (storage, folder, dispatch, location, params, note: note }) - hashHistory.push({ + dispatch(push({ pathname: location.pathname, - query: { key: noteHash } - }) + search: queryString.stringify({ key: noteHash }) + })) ee.emit('list:jump', noteHash) ee.emit('detail:focus') }) @@ -70,10 +71,10 @@ export function createSnippetNote (storage, folder, dispatch, location, params, type: 'UPDATE_NOTE', note: note }) - hashHistory.push({ + dispatch(push({ pathname: location.pathname, - query: { key: noteHash } - }) + search: queryString.stringify({ key: noteHash }) + })) ee.emit('list:jump', noteHash) ee.emit('detail:focus') }) diff --git a/browser/lib/slugify.js b/browser/lib/slugify.js index a3447a90..21c18e02 100644 --- a/browser/lib/slugify.js +++ b/browser/lib/slugify.js @@ -1,17 +1,11 @@ -import diacritics from 'diacritics-map' - -function replaceDiacritics (str) { - return str.replace(/[À-ž]/g, function (ch) { - return diacritics[ch] || ch - }) -} - module.exports = function slugify (title) { - let slug = title.trim() + const slug = encodeURI( + title.trim() + .replace(/^\s+/, '') + .replace(/\s+$/, '') + .replace(/\s+/g, '-') + .replace(/[\]\[\!\'\#\$\%\&\(\)\*\+\,\.\/\:\;\<\=\>\?\@\\\^\{\|\}\~\`]/g, '') + ) - slug = replaceDiacritics(slug) - - slug = slug.replace(/[^\w\s-]/g, '').replace(/\s+/g, '-') - - return encodeURI(slug).replace(/\-+$/, '') + return slug } diff --git a/browser/lib/spellcheck.js b/browser/lib/spellcheck.js index dd04e575..ab8fc81b 100644 --- a/browser/lib/spellcheck.js +++ b/browser/lib/spellcheck.js @@ -14,7 +14,7 @@ let self function getAvailableDictionaries () { return [ - {label: i18n.__('Disabled'), value: SPELLCHECK_DISABLED}, + {label: i18n.__('Spellcheck disabled'), value: SPELLCHECK_DISABLED}, {label: i18n.__('English'), value: 'en_GB'}, {label: i18n.__('German'), value: 'de_DE'}, {label: i18n.__('French'), value: 'fr_FR'} diff --git a/browser/lib/turndown.js b/browser/lib/turndown.js new file mode 100644 index 00000000..a1c3e128 --- /dev/null +++ b/browser/lib/turndown.js @@ -0,0 +1,9 @@ +const TurndownService = require('turndown') +const { gfm } = require('turndown-plugin-gfm') + +export const createTurndownService = function () { + const turndown = new TurndownService() + turndown.use(gfm) + turndown.remove('script') + return turndown +} diff --git a/browser/lib/utils.js b/browser/lib/utils.js index 1d15b722..9f6f1425 100644 --- a/browser/lib/utils.js +++ b/browser/lib/utils.js @@ -132,8 +132,28 @@ export function isObjectEqual (a, b) { return true } +export function isMarkdownTitleURL (str) { + return /(^#{1,6}\s)(?:\w+:|^)\/\/(?:[^\s\.]+\.\S{2}|localhost[\:?\d]*)/.test(str) +} + +export function humanFileSize (bytes) { + const threshold = 1000 + if (Math.abs(bytes) < threshold) { + return bytes + ' B' + } + var units = ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + var u = -1 + do { + bytes /= threshold + ++u + } while (Math.abs(bytes) >= threshold && u < units.length - 1) + return bytes.toFixed(1) + ' ' + units[u] +} + export default { lastFindInArray, escapeHtmlCharacters, - isObjectEqual + isObjectEqual, + isMarkdownTitleURL, + humanFileSize } diff --git a/browser/main/Detail/FromUrlButton.js b/browser/main/Detail/FromUrlButton.js new file mode 100644 index 00000000..0d1c1b4c --- /dev/null +++ b/browser/main/Detail/FromUrlButton.js @@ -0,0 +1,69 @@ +import PropTypes from 'prop-types' +import React from 'react' +import CSSModules from 'browser/lib/CSSModules' +import styles from './FromUrlButton.styl' +import _ from 'lodash' +import i18n from 'browser/lib/i18n' + +class FromUrlButton extends React.Component { + constructor (props) { + super(props) + + this.state = { + isActive: false + } + } + + handleMouseDown (e) { + this.setState({ + isActive: true + }) + } + + handleMouseUp (e) { + this.setState({ + isActive: false + }) + } + + handleMouseLeave (e) { + this.setState({ + isActive: false + }) + } + + render () { + const { className } = this.props + + return ( + + ) + } +} + +FromUrlButton.propTypes = { + isActive: PropTypes.bool, + onClick: PropTypes.func, + className: PropTypes.string +} + +export default CSSModules(FromUrlButton, styles) diff --git a/browser/main/Detail/FromUrlButton.styl b/browser/main/Detail/FromUrlButton.styl new file mode 100644 index 00000000..66c2d730 --- /dev/null +++ b/browser/main/Detail/FromUrlButton.styl @@ -0,0 +1,41 @@ +.root + top 45px + topBarButtonRight() + &:hover + transition 0.2s + color alpha($ui-favorite-star-button-color, 0.6) + &:hover .tooltip + opacity 1 + +.tooltip + tooltip() + position absolute + pointer-events none + top 50px + right 125px + width 90px + z-index 200 + padding 5px + line-height normal + border-radius 2px + opacity 0 + transition 0.1s + +.root--active + @extend .root + transition 0.15s + color $ui-favorite-star-button-color + &:hover + transition 0.2s + color alpha($ui-favorite-star-button-color, 0.6) + +.icon + transition transform 0.15s + height 13px + +body[data-theme="dark"] + .root + topBarButtonDark() + &:hover + transition 0.2s + color alpha($ui-favorite-star-button-color, 0.6) diff --git a/browser/main/Detail/FullscreenButton.js b/browser/main/Detail/FullscreenButton.js index bd76447c..eb33165f 100644 --- a/browser/main/Detail/FullscreenButton.js +++ b/browser/main/Detail/FullscreenButton.js @@ -11,7 +11,7 @@ const FullscreenButton = ({ const hotkey = (OSX ? i18n.__('Command(⌘)') : i18n.__('Ctrl(^)')) + '+B' return ( ) diff --git a/browser/main/Detail/InfoPanel.js b/browser/main/Detail/InfoPanel.js index 15535186..86b5ae86 100644 --- a/browser/main/Detail/InfoPanel.js +++ b/browser/main/Detail/InfoPanel.js @@ -14,7 +14,7 @@ class InfoPanel extends React.Component { render () { const { - storageName, folderName, noteLink, updatedAt, createdAt, exportAsMd, exportAsTxt, exportAsHtml, wordCount, letterCount, type, print + storageName, folderName, noteLink, updatedAt, createdAt, exportAsMd, exportAsTxt, exportAsHtml, exportAsPdf, wordCount, letterCount, type, print } = this.props return (
@@ -60,7 +60,7 @@ class InfoPanel extends React.Component {
- { e.target.select() }} /> + { e.target.select() }} /> @@ -85,6 +85,11 @@ class InfoPanel extends React.Component {

{i18n.__('.html')}

+ + - @@ -61,7 +61,8 @@ InfoPanelTrashed.propTypes = { createdAt: PropTypes.string.isRequired, exportAsMd: PropTypes.func.isRequired, exportAsTxt: PropTypes.func.isRequired, - exportAsHtml: PropTypes.func.isRequired + exportAsHtml: PropTypes.func.isRequired, + exportAsPdf: PropTypes.func.isRequired } export default CSSModules(InfoPanelTrashed, styles) diff --git a/browser/main/Detail/MarkdownNoteDetail.js b/browser/main/Detail/MarkdownNoteDetail.js index 3ed61eb7..083259d8 100755 --- a/browser/main/Detail/MarkdownNoteDetail.js +++ b/browser/main/Detail/MarkdownNoteDetail.js @@ -9,7 +9,6 @@ import StarButton from './StarButton' import TagSelect from './TagSelect' import FolderSelect from './FolderSelect' import dataApi from 'browser/main/lib/dataApi' -import { hashHistory } from 'react-router' import ee from 'browser/main/lib/eventEmitter' import markdown from 'browser/lib/markdownTextHelper' import StatusBar from '../StatusBar' @@ -30,6 +29,9 @@ import { getTodoPercentageOfCompleted } from 'browser/lib/getTodoStatus' import striptags from 'striptags' import { confirmDeleteNote } from 'browser/lib/confirmDeleteNote' import markdownToc from 'browser/lib/markdown-toc-generator' +import queryString from 'query-string' +import { replace } from 'connected-react-router' +import ToggleDirectionButton from 'browser/main/Detail/ToggleDirectionButton' class MarkdownNoteDetail extends React.Component { constructor (props) { @@ -45,7 +47,8 @@ class MarkdownNoteDetail extends React.Component { isLockButtonShown: props.config.editor.type !== 'SPLIT', isLocked: false, editorType: props.config.editor.type, - switchPreview: props.config.editor.switchPreview + switchPreview: props.config.editor.switchPreview, + RTL: false } this.dispatchTimer = null @@ -60,15 +63,13 @@ class MarkdownNoteDetail extends React.Component { componentDidMount () { ee.on('topbar:togglelockbutton', this.toggleLockButton) + ee.on('topbar:toggledirectionbutton', () => this.handleSwitchDirection()) ee.on('topbar:togglemodebutton', () => { const reversedType = this.state.editorType === 'SPLIT' ? 'EDITOR_PREVIEW' : 'SPLIT' this.handleSwitchMode(reversedType) }) ee.on('hotkey:deletenote', this.handleDeleteNote.bind(this)) ee.on('code:generate-toc', this.generateToc) - - // Focus content if using blur or double click - if (this.state.switchPreview === 'BLUR' || this.state.switchPreview === 'DBL_CLICK') this.focus() } componentWillReceiveProps (nextProps) { @@ -83,10 +84,25 @@ class MarkdownNoteDetail extends React.Component { if (this.refs.tags) this.refs.tags.reset() }) } + + // Focus content if using blur or double click + // --> Moved here from componentDidMount so a re-render during search won't set focus to the editor + const {switchPreview} = nextProps.config.editor + + if (this.state.switchPreview !== switchPreview) { + this.setState({ + switchPreview + }) + if (switchPreview === 'BLUR' || switchPreview === 'DBL_CLICK') { + console.log('setting focus', switchPreview) + this.focus() + } + } } componentWillUnmount () { ee.off('topbar:togglelockbutton', this.toggleLockButton) + ee.on('topbar:toggledirectionbutton', this.handleSwitchDirection) ee.off('code:generate-toc', this.generateToc) if (this.saveQueue != null) this.saveNow() } @@ -159,12 +175,12 @@ class MarkdownNoteDetail extends React.Component { originNote: note, note: newNote }) - hashHistory.replace({ + dispatch(replace({ pathname: location.pathname, - query: { + search: queryString.stringify({ key: newNote.key - } - }) + }) + })) this.setState({ isMovingNote: false }) @@ -201,6 +217,10 @@ class MarkdownNoteDetail extends React.Component { ee.emit('export:save-html') } + exportAsPdf () { + ee.emit('export:save-pdf') + } + handleKeyDown (e) { switch (e.keyCode) { // tab key @@ -294,7 +314,7 @@ class MarkdownNoteDetail extends React.Component { } getToggleLockButton () { - return this.state.isLocked ? '../resources/icon/icon-previewoff-on.svg' : '../resources/icon/icon-previewoff-off.svg' + return this.state.isLocked ? '../resources/icon/icon-lock.svg' : '../resources/icon/icon-unlock.svg' } handleDeleteKeyDown (e) { @@ -338,6 +358,12 @@ class MarkdownNoteDetail extends React.Component { }) } + handleSwitchDirection () { + // If in split mode, hide the lock button + const direction = this.state.RTL + this.setState({ RTL: !direction }) + } + handleDeleteNote () { this.handleTrashButtonClick() } @@ -377,6 +403,7 @@ class MarkdownNoteDetail extends React.Component { onChange={this.handleUpdateContent.bind(this)} isLocked={this.state.isLocked} ignorePreviewPointerEvents={ignorePreviewPointerEvents} + RTL={this.state.RTL} /> } else { return } } render () { - const { data, location, config } = this.props + const { data, dispatch, location, config } = this.props const { note, editorType } = this.state const storageKey = note.storage const folderKey = note.folder @@ -426,13 +454,14 @@ class MarkdownNoteDetail extends React.Component { exportAsHtml={this.exportAsHtml} exportAsMd={this.exportAsMd} exportAsTxt={this.exportAsTxt} + exportAsPdf={this.exportAsPdf} />
const detailTopBar =
-
+
@@ -454,6 +484,7 @@ class MarkdownNoteDetail extends React.Component {
this.handleSwitchMode(e)} editorType={editorType} /> + this.handleSwitchDirection(e)} isRTL={this.state.RTL} /> this.handleStarButtonClick(e)} isActive={note.isStarred} @@ -466,7 +497,7 @@ class MarkdownNoteDetail extends React.Component { onFocus={(e) => this.handleFocus(e)} onMouseDown={(e) => this.handleLockButtonMouseDown(e)} > - + {this.state.isLocked ? Unlock : Lock} @@ -486,18 +517,20 @@ class MarkdownNoteDetail extends React.Component {
+
return ( diff --git a/browser/main/Detail/MarkdownNoteDetail.styl b/browser/main/Detail/MarkdownNoteDetail.styl index 678969a5..a9ae1421 100644 --- a/browser/main/Detail/MarkdownNoteDetail.styl +++ b/browser/main/Detail/MarkdownNoteDetail.styl @@ -15,7 +15,7 @@ .control-lockButton topBarButtonRight() position absolute - right 225px + right 265px &:hover .tooltip opacity 1 @@ -76,4 +76,4 @@ for theme in 'solarized-dark' 'dracula' apply-theme(theme) for theme in $themes - apply-theme(theme) \ No newline at end of file + apply-theme(theme) diff --git a/browser/main/Detail/NoteDetailInfo.styl b/browser/main/Detail/NoteDetailInfo.styl index d44f61b6..59d67583 100644 --- a/browser/main/Detail/NoteDetailInfo.styl +++ b/browser/main/Detail/NoteDetailInfo.styl @@ -15,6 +15,14 @@ $info-margin-under-border = 30px padding 0 20px z-index 99 +.info > div + > button + -webkit-user-drag none + user-select none + > img, span + -webkit-user-drag none + user-select none + .info-left padding 0 10px width 100% @@ -104,4 +112,4 @@ for theme in 'solarized-dark' 'dracula' apply-theme(theme) for theme in $themes - apply-theme(theme) \ No newline at end of file + apply-theme(theme) diff --git a/browser/main/Detail/PermanentDeleteButton.js b/browser/main/Detail/PermanentDeleteButton.js index fa00ef17..7c27ede1 100644 --- a/browser/main/Detail/PermanentDeleteButton.js +++ b/browser/main/Detail/PermanentDeleteButton.js @@ -10,7 +10,7 @@ const PermanentDeleteButton = ({ ) diff --git a/browser/main/Detail/SnippetNoteDetail.js b/browser/main/Detail/SnippetNoteDetail.js index 11d8ac2a..ec9a1d0b 100644 --- a/browser/main/Detail/SnippetNoteDetail.js +++ b/browser/main/Detail/SnippetNoteDetail.js @@ -8,7 +8,6 @@ import StarButton from './StarButton' import TagSelect from './TagSelect' import FolderSelect from './FolderSelect' import dataApi from 'browser/main/lib/dataApi' -import {hashHistory} from 'react-router' import ee from 'browser/main/lib/eventEmitter' import CodeMirror from 'codemirror' import 'codemirror-mode-elixir' @@ -18,7 +17,6 @@ import context from 'browser/lib/context' import ConfigManager from 'browser/main/lib/ConfigManager' import _ from 'lodash' import {findNoteTitle} from 'browser/lib/findNoteTitle' -import convertModeName from 'browser/lib/convertModeName' import AwsMobileAnalyticsConfig from 'browser/main/lib/AwsMobileAnalyticsConfig' import FullscreenButton from './FullscreenButton' import TrashButton from './TrashButton' @@ -31,6 +29,8 @@ import { formatDate } from 'browser/lib/date-formatter' import i18n from 'browser/lib/i18n' import { confirmDeleteNote } from 'browser/lib/confirmDeleteNote' import markdownToc from 'browser/lib/markdown-toc-generator' +import queryString from 'query-string' +import { replace } from 'connected-react-router' const electron = require('electron') const { remote } = electron @@ -166,12 +166,12 @@ class SnippetNoteDetail extends React.Component { originNote: note, note: newNote }) - hashHistory.replace({ + dispatch(replace({ pathname: location.pathname, - query: { + search: queryString.stringify({ key: newNote.key - } - }) + }) + })) this.setState({ isMovingNote: false }) @@ -518,6 +518,19 @@ class SnippetNoteDetail extends React.Component { ]) } + handleWrapLineButtonClick (e) { + context.popup([ + { + label: 'on', + click: (e) => this.handleWrapLineItemClick(e, true) + }, + { + label: 'off', + click: (e) => this.handleWrapLineItemClick(e, false) + } + ]) + } + handleIndentSizeItemClick (e, indentSize) { const { config, dispatch } = this.props const editor = Object.assign({}, config.editor, { @@ -550,6 +563,22 @@ class SnippetNoteDetail extends React.Component { }) } + handleWrapLineItemClick (e, lineWrapping) { + const { config, dispatch } = this.props + const editor = Object.assign({}, config.editor, { + lineWrapping + }) + ConfigManager.set({ + editor + }) + dispatch({ + type: 'SET_CONFIG', + config: { + editor + } + }) + } + focus () { this.refs.description.focus() } @@ -657,6 +686,7 @@ class SnippetNoteDetail extends React.Component { 'export-txt': 'Text export', 'export-md': 'Markdown export', 'export-html': 'HTML export', + 'export-pdf': 'PDF export', 'print': 'Print' })[msg] @@ -669,7 +699,7 @@ class SnippetNoteDetail extends React.Component { } render () { - const { data, config, location } = this.props + const { data, dispatch, config, location } = this.props const { note } = this.state const storageKey = note.storage @@ -719,6 +749,7 @@ class SnippetNoteDetail extends React.Component { mode={snippet.mode || (autoDetect ? null : config.editor.snippetDefaultLanguage)} value={snippet.content} linesHighlighted={snippet.linesHighlighted} + lineWrapping={config.editor.lineWrapping} theme={config.editor.theme} fontFamily={config.editor.fontFamily} fontSize={editorFontSize} @@ -770,13 +801,14 @@ class SnippetNoteDetail extends React.Component { exportAsMd={this.showWarning} exportAsTxt={this.showWarning} exportAsHtml={this.showWarning} + exportAsPdf={this.showWarning} />
const detailTopBar =
-
+
this.handleChange(e)} coloredTags={config.coloredTags} /> @@ -812,12 +845,13 @@ class SnippetNoteDetail extends React.Component { @@ -896,6 +930,12 @@ class SnippetNoteDetail extends React.Component { size: {config.editor.indentSize}  +
( +
+
onClick()}> + +
+
onClick()}> + +
+ {i18n.__('Toggle Direction')} +
+) + +ToggleDirectionButton.propTypes = { + onClick: PropTypes.func.isRequired, + isRTL: PropTypes.string.isRequired +} + +export default CSSModules(ToggleDirectionButton, styles) diff --git a/browser/main/Detail/ToggleDirectionButton.styl b/browser/main/Detail/ToggleDirectionButton.styl new file mode 100644 index 00000000..15f90778 --- /dev/null +++ b/browser/main/Detail/ToggleDirectionButton.styl @@ -0,0 +1,85 @@ +.control-toggleModeButton + height 25px + border-radius 50px + background-color #F4F4F4 + width 52px + display flex + align-items center + position: relative + top 2px + margin-left 5px + .active + background-color #1EC38B + width 33px + height 24px + box-shadow 2px 0px 7px #eee + z-index 1 + + div + width 40px + height 100% + border-radius 50% + display flex + align-items center + justify-content center + cursor pointer + + &:hover .tooltip + opacity 1 + +.tooltip + tooltip() + position absolute + pointer-events none + top 33px + left -10px + z-index 200 + width 80px + padding 5px + line-height normal + border-radius 2px + opacity 0 + transition 0.1s + +.tooltip:lang(ja) + @extend .tooltip + left -8px + width 70px + +.control-toggleModeButton + -webkit-user-drag none + user-select none + > div img + -webkit-user-drag none + user-select none + +body[data-theme="dark"] + .control-fullScreenButton + topBarButtonDark() + + .control-toggleModeButton + background-color #3A404C + .active + background-color #1EC38B + box-shadow 2px 0px 7px #444444 + +body[data-theme="solarized-dark"] + .control-toggleModeButton + background-color #002B36 + .active + background-color #1EC38B + box-shadow 2px 0px 7px #222222 + +apply-theme(theme) + body[data-theme={theme}] + .control-toggleModeButton + background-color get-theme-var(theme, 'borderColor') + .active + background-color get-theme-var(theme, 'active-color') + box-shadow 2px 0px 7px #222222 + +for theme in 'dracula' + apply-theme(theme) + +for theme in $themes + apply-theme(theme) diff --git a/browser/main/Detail/ToggleModeButton.js b/browser/main/Detail/ToggleModeButton.js index fcbaab34..9d3d61f2 100644 --- a/browser/main/Detail/ToggleModeButton.js +++ b/browser/main/Detail/ToggleModeButton.js @@ -8,11 +8,11 @@ const ToggleModeButton = ({ onClick, editorType }) => (
-
onClick('SPLIT')}> - +
onClick('SPLIT')}> +
-
onClick('EDITOR_PREVIEW')}> - +
onClick('EDITOR_PREVIEW')}> +
{i18n.__('Toggle Mode')}
@@ -20,7 +20,7 @@ const ToggleModeButton = ({ ToggleModeButton.propTypes = { onClick: PropTypes.func.isRequired, - editorType: PropTypes.string.Required + editorType: PropTypes.string.isRequired } export default CSSModules(ToggleModeButton, styles) diff --git a/browser/main/Detail/ToggleModeButton.styl b/browser/main/Detail/ToggleModeButton.styl index 481e6747..a9579fcd 100644 --- a/browser/main/Detail/ToggleModeButton.styl +++ b/browser/main/Detail/ToggleModeButton.styl @@ -26,6 +26,13 @@ &:hover .tooltip opacity 1 +.control-toggleModeButton + -webkit-user-drag none + user-select none + > div img + -webkit-user-drag none + user-select none + .tooltip tooltip() position absolute @@ -74,4 +81,4 @@ for theme in 'dracula' apply-theme(theme) for theme in $themes - apply-theme(theme) \ No newline at end of file + apply-theme(theme) diff --git a/browser/main/Detail/TrashButton.js b/browser/main/Detail/TrashButton.js index d26be66e..8ca27ce9 100644 --- a/browser/main/Detail/TrashButton.js +++ b/browser/main/Detail/TrashButton.js @@ -10,7 +10,7 @@ const TrashButton = ({ ) diff --git a/browser/main/Detail/index.js b/browser/main/Detail/index.js index b6b6ef14..95b9d73d 100644 --- a/browser/main/Detail/index.js +++ b/browser/main/Detail/index.js @@ -10,6 +10,7 @@ import StatusBar from '../StatusBar' import i18n from 'browser/lib/i18n' import debounceRender from 'react-debounce-render' import searchFromNotes from 'browser/lib/search' +import queryString from 'query-string' const OSX = global.process.platform === 'darwin' @@ -36,11 +37,11 @@ class Detail extends React.Component { } render () { - const { location, data, params, config } = this.props + const { location, data, match: { params }, config } = this.props + const noteKey = location.search !== '' && queryString.parse(location.search).key let note = null - if (location.query.key != null) { - const noteKey = location.query.key + if (location.search !== '') { const allNotes = data.noteMap.map(note => note) const trashedNotes = data.trashedSet.toJS().map(uniqueKey => data.noteMap.get(uniqueKey)) let displayedNotes = allNotes @@ -49,16 +50,14 @@ class Detail extends React.Component { const searchStr = params.searchword displayedNotes = searchStr === undefined || searchStr === '' ? allNotes : searchFromNotes(allNotes, searchStr) - } - - if (location.pathname.match(/\/tags/)) { + } else if (location.pathname.match(/^\/tags/)) { const listOfTags = params.tagname.split(' ') displayedNotes = data.noteMap.map(note => note).filter(note => listOfTags.every(tag => note.tags.includes(tag)) ) } - if (location.pathname.match(/\/trashed/)) { + if (location.pathname.match(/^\/trashed/)) { displayedNotes = trashedNotes } else { displayedNotes = _.differenceWith(displayedNotes, trashedNotes, (note, trashed) => note.key === trashed.key) diff --git a/browser/main/DevTools/index.dev.js b/browser/main/DevTools/index.dev.js new file mode 100644 index 00000000..77d3eccd --- /dev/null +++ b/browser/main/DevTools/index.dev.js @@ -0,0 +1,16 @@ +import React from 'react' +import { createDevTools } from 'redux-devtools' +import LogMonitor from 'redux-devtools-log-monitor' +import DockMonitor from 'redux-devtools-dock-monitor' + +const DevTools = createDevTools( + + + +) + +export default DevTools diff --git a/browser/main/DevTools/index.js b/browser/main/DevTools/index.js new file mode 100644 index 00000000..d39d5fbb --- /dev/null +++ b/browser/main/DevTools/index.js @@ -0,0 +1,8 @@ +/* eslint-disable no-undef */ +if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line global-require + module.exports = require('./index.dev').default +} else { + // eslint-disable-next-line global-require + module.exports = require('./index.prod').default +} diff --git a/browser/main/DevTools/index.prod.js b/browser/main/DevTools/index.prod.js new file mode 100644 index 00000000..762cae2c --- /dev/null +++ b/browser/main/DevTools/index.prod.js @@ -0,0 +1,6 @@ +import React from 'react' + +const DevTools = () =>
+DevTools.instrument = () => {} + +export default DevTools diff --git a/browser/main/Main.js b/browser/main/Main.js index 8d346f4a..2e706893 100644 --- a/browser/main/Main.js +++ b/browser/main/Main.js @@ -12,12 +12,13 @@ import _ from 'lodash' import ConfigManager from 'browser/main/lib/ConfigManager' import mobileAnalytics from 'browser/main/lib/AwsMobileAnalyticsConfig' import eventEmitter from 'browser/main/lib/eventEmitter' -import { hashHistory } from 'react-router' -import store from 'browser/main/store' +import { store } from 'browser/main/store' import i18n from 'browser/lib/i18n' import { getLocales } from 'browser/lib/Languages' import applyShortcuts from 'browser/main/lib/shortcutManager' import uiThemes from 'browser/lib/ui-themes' +import { push } from 'connected-react-router' + const path = require('path') const electron = require('electron') const { remote } = electron @@ -103,7 +104,7 @@ class Main extends React.Component { { name: 'example.js', mode: 'javascript', - content: "var boostnote = document.getElementById('enjoy').innerHTML\n\nconsole.log(boostnote)", + content: "var boostnote = document.getElementById('hello').innerHTML\n\nconsole.log(boostnote)", linesHighlighted: [] } ] @@ -133,7 +134,7 @@ class Main extends React.Component { .then(() => data.storage) }) .then(storage => { - hashHistory.push('/storages/' + storage.key) + store.dispatch(push('/storages/' + storage.key)) }) .catch(err => { throw err @@ -168,6 +169,7 @@ class Main extends React.Component { } }) + // eslint-disable-next-line no-undef delete CodeMirror.keyMap.emacs['Ctrl-V'] eventEmitter.on('editor:fullscreen', this.toggleFullScreen) @@ -310,7 +312,7 @@ class Main extends React.Component { onMouseUp={e => this.handleMouseUp(e)} > {!config.isSideNavFolded && @@ -340,7 +342,7 @@ class Main extends React.Component { 'dispatch', 'config', 'data', - 'params', + 'match', 'location' ])} /> @@ -350,7 +352,7 @@ class Main extends React.Component { 'dispatch', 'data', 'config', - 'params', + 'match', 'location' ])} /> @@ -372,7 +374,7 @@ class Main extends React.Component { 'dispatch', 'data', 'config', - 'params', + 'match', 'location' ])} ignorePreviewPointerEvents={this.state.isRightSliderFocused} diff --git a/browser/main/NewNoteButton/index.js b/browser/main/NewNoteButton/index.js index c34443be..27e2baa5 100644 --- a/browser/main/NewNoteButton/index.js +++ b/browser/main/NewNoteButton/index.js @@ -21,23 +21,20 @@ class NewNoteButton extends React.Component { this.state = { } - this.newNoteHandler = () => { - this.handleNewNoteButtonClick() - } + this.handleNewNoteButtonClick = this.handleNewNoteButtonClick.bind(this) } componentDidMount () { - eventEmitter.on('top:new-note', this.newNoteHandler) + eventEmitter.on('top:new-note', this.handleNewNoteButtonClick) } componentWillUnmount () { - eventEmitter.off('top:new-note', this.newNoteHandler) + eventEmitter.off('top:new-note', this.handleNewNoteButtonClick) } handleNewNoteButtonClick (e) { - const { location, params, dispatch, config } = this.props + const { location, dispatch, match: { params }, config } = this.props const { storage, folder } = this.resolveTargetFolder() - if (config.ui.defaultNote === 'MARKDOWN_NOTE') { createMarkdownNote(storage.key, folder.key, dispatch, location, params, config) } else if (config.ui.defaultNote === 'SNIPPET_NOTE') { @@ -55,9 +52,8 @@ class NewNoteButton extends React.Component { } resolveTargetFolder () { - const { data, params } = this.props + const { data, match: { params } } = this.props let storage = data.storageMap.get(params.storageKey) - // Find first storage if (storage == null) { for (const kv of data.storageMap) { @@ -93,8 +89,8 @@ class NewNoteButton extends React.Component { >
@@ -1116,6 +1163,7 @@ class NoteList extends React.Component { tabIndex='-1' onKeyDown={(e) => this.handleNoteListKeyDown(e)} onKeyUp={this.handleNoteListKeyUp} + onBlur={this.handleNoteListBlur} > {noteList}
diff --git a/browser/main/SideNav/PreferenceButton.js b/browser/main/SideNav/PreferenceButton.js index 187171f4..187bc41a 100644 --- a/browser/main/SideNav/PreferenceButton.js +++ b/browser/main/SideNav/PreferenceButton.js @@ -8,7 +8,7 @@ const PreferenceButton = ({ onClick }) => ( ) diff --git a/browser/main/SideNav/StorageItem.js b/browser/main/SideNav/StorageItem.js index e336f3ce..5cd4a491 100644 --- a/browser/main/SideNav/StorageItem.js +++ b/browser/main/SideNav/StorageItem.js @@ -2,7 +2,6 @@ import PropTypes from 'prop-types' import React from 'react' import CSSModules from 'browser/lib/CSSModules' import styles from './StorageItem.styl' -import { hashHistory } from 'react-router' import modal from 'browser/main/lib/modal' import CreateFolderModal from 'browser/main/modals/CreateFolderModal' import RenameFolderModal from 'browser/main/modals/RenameFolderModal' @@ -12,6 +11,7 @@ import _ from 'lodash' import { SortableElement } from 'react-sortable-hoc' import i18n from 'browser/lib/i18n' import context from 'browser/lib/context' +import { push } from 'connected-react-router' const { remote } = require('electron') const { dialog } = remote @@ -134,14 +134,14 @@ class StorageItem extends React.Component { } handleHeaderInfoClick (e) { - const { storage } = this.props - hashHistory.push('/storages/' + storage.key) + const { storage, dispatch } = this.props + dispatch(push('/storages/' + storage.key)) } handleFolderButtonClick (folderKey) { return (e) => { - const { storage } = this.props - hashHistory.push('/storages/' + storage.key + '/folders/' + folderKey) + const { storage, dispatch } = this.props + dispatch(push('/storages/' + storage.key + '/folders/' + folderKey)) } } @@ -362,14 +362,14 @@ class StorageItem extends React.Component { }
{this.state.isOpen && -
+
{folderList}
} diff --git a/browser/main/SideNav/index.js b/browser/main/SideNav/index.js index 640bedbf..241d4151 100644 --- a/browser/main/SideNav/index.js +++ b/browser/main/SideNav/index.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types' import React from 'react' +import { push } from 'connected-react-router' import CSSModules from 'browser/lib/CSSModules' import dataApi from 'browser/main/lib/dataApi' import styles from './SideNav.styl' @@ -21,9 +22,10 @@ import context from 'browser/lib/context' import { remote } from 'electron' import { confirmDeleteNote } from 'browser/lib/confirmDeleteNote' import ColorPicker from 'browser/components/ColorPicker' +import { every, sortBy } from 'lodash' function matchActiveTags (tags, activeTags) { - return _.every(activeTags, v => tags.indexOf(v) >= 0) + return every(activeTags, v => tags.indexOf(v) >= 0) } class SideNav extends React.Component { @@ -55,14 +57,14 @@ class SideNav extends React.Component { deleteTag (tag) { const selectedButton = remote.dialog.showMessageBox(remote.getCurrentWindow(), { - ype: 'warning', + type: 'warning', message: i18n.__('Confirm tag deletion'), detail: i18n.__('This will permanently remove this tag.'), buttons: [i18n.__('Confirm'), i18n.__('Cancel')] }) if (selectedButton === 0) { - const { data, dispatch, location, params } = this.props + const { data, dispatch, location, match: { params } } = this.props const notes = data.noteMap .map(note => note) @@ -92,7 +94,7 @@ class SideNav extends React.Component { if (index !== -1) { tags.splice(index, 1) - this.context.router.push(`/tags/${tags.map(tag => encodeURIComponent(tag)).join(' ')}`) + dispatch(push(`/tags/${tags.map(tag => encodeURIComponent(tag)).join(' ')}`)) } } }) @@ -104,13 +106,13 @@ class SideNav extends React.Component { } handleHomeButtonClick (e) { - const { router } = this.context - router.push('/home') + const { dispatch } = this.props + dispatch(push('/home')) } handleStarredButtonClick (e) { - const { router } = this.context - router.push('/starred') + const { dispatch } = this.props + dispatch(push('/starred')) } handleTagContextMenu (e, tag) { @@ -190,18 +192,18 @@ class SideNav extends React.Component { } handleTrashedButtonClick (e) { - const { router } = this.context - router.push('/trashed') + const { dispatch } = this.props + dispatch(push('/trashed')) } handleSwitchFoldersButtonClick () { - const { router } = this.context - router.push('/home') + const { dispatch } = this.props + dispatch(push('/home')) } handleSwitchTagsButtonClick () { - const { router } = this.context - router.push('/alltags') + const { dispatch } = this.props + dispatch(push('/alltags')) } onSortEnd (storage) { @@ -270,6 +272,7 @@ class SideNav extends React.Component {
{this.tagListComponent(data)}
+
) } @@ -282,7 +285,7 @@ class SideNav extends React.Component { const { colorPicker } = this.state const activeTags = this.getActiveTags(location.pathname) const relatedTags = this.getRelatedTags(activeTags, data.noteMap) - let tagList = _.sortBy(data.tagNoteMap.map( + let tagList = sortBy(data.tagNoteMap.map( (tag, name) => ({ name, size: tag.size, related: relatedTags.has(name) }) ).filter( tag => tag.size > 0 @@ -295,7 +298,7 @@ class SideNav extends React.Component { }) } if (config.sortTagsBy === 'COUNTER') { - tagList = _.sortBy(tagList, item => (0 - item.size)) + tagList = sortBy(tagList, item => (0 - item.size)) } if (config.ui.showOnlyRelatedTags && (relatedTags.size > 0)) { tagList = tagList.filter( @@ -348,8 +351,8 @@ class SideNav extends React.Component { } handleClickTagListItem (name) { - const { router } = this.context - router.push(`/tags/${encodeURIComponent(name)}`) + const { dispatch } = this.props + dispatch(push(`/tags/${encodeURIComponent(name)}`)) } handleSortTagsByChange (e) { @@ -367,8 +370,7 @@ class SideNav extends React.Component { } handleClickNarrowToTag (tag) { - const { router } = this.context - const { location } = this.props + const { dispatch, location } = this.props const listOfTags = this.getActiveTags(location.pathname) const indexOfTag = listOfTags.indexOf(tag) if (indexOfTag > -1) { @@ -376,7 +378,7 @@ class SideNav extends React.Component { } else { listOfTags.push(tag) } - router.push(`/tags/${encodeURIComponent(listOfTags.join(' '))}`) + dispatch(push(`/tags/${encodeURIComponent(listOfTags.join(' '))}`)) } emptyTrash (entries) { @@ -440,7 +442,7 @@ class SideNav extends React.Component { const style = {} if (!isFolded) style.width = this.props.width - const isTagActive = location.pathname.match(/tag/) + const isTagActive = /tag/.test(location.pathname) return (
{ this.handleOnSearchFocus() } this.codeInitHandler = this.handleCodeInit.bind(this) + this.handleKeyDown = this.handleKeyDown.bind(this) + this.handleSearchFocus = this.handleSearchFocus.bind(this) + this.handleSearchBlur = this.handleSearchBlur.bind(this) + this.handleSearchChange = this.handleSearchChange.bind(this) + this.handleSearchClearButton = this.handleSearchClearButton.bind(this) - this.updateKeyword = debounce(this.updateKeyword, 1000 / 60, { + this.debouncedUpdateKeyword = debounce((keyword) => { + dispatch(push(`/searched/${encodeURIComponent(keyword)}`)) + this.setState({ + search: keyword + }) + ee.emit('top:search', keyword) + }, 1000 / 60, { maxWait: 1000 / 8 }) } componentDidMount () { - const { params } = this.props - const searchWord = params.searchword + const { match: { params } } = this.props + const searchWord = params && params.searchword if (searchWord !== undefined) { this.setState({ search: searchWord, @@ -51,22 +63,22 @@ class TopBar extends React.Component { } handleSearchClearButton (e) { - const { router } = this.context + const { dispatch } = this.props this.setState({ search: '', isSearching: false }) this.refs.search.childNodes[0].blur - router.push('/searched') + dispatch(push('/searched')) e.preventDefault() + this.debouncedUpdateKeyword('') } handleKeyDown (e) { - // reset states - this.setState({ - isAlphabet: false, - isIME: false - }) + // Re-apply search field on ENTER key + if (e.keyCode === 13) { + this.debouncedUpdateKeyword(e.target.value) + } // Clear search on ESC if (e.keyCode === 27) { @@ -84,51 +96,11 @@ class TopBar extends React.Component { ee.emit('list:prior') e.preventDefault() } - - // When the key is an alphabet, del, enter or ctr - if (e.keyCode <= 90 || e.keyCode >= 186 && e.keyCode <= 222) { - this.setState({ - isAlphabet: true - }) - // When the key is an IME input (Japanese, Chinese) - } else if (e.keyCode === 229) { - this.setState({ - isIME: true - }) - } - } - - handleKeyUp (e) { - // reset states - this.setState({ - isConfirmTranslation: false - }) - - // When the key is translation confirmation (Enter, Space) - if (this.state.isIME && (e.keyCode === 32 || e.keyCode === 13)) { - this.setState({ - isConfirmTranslation: true - }) - const keyword = this.refs.searchInput.value - this.updateKeyword(keyword) - } } handleSearchChange (e) { - if (this.state.isAlphabet || this.state.isConfirmTranslation) { - const keyword = this.refs.searchInput.value - this.updateKeyword(keyword) - } else { - e.preventDefault() - } - } - - updateKeyword (keyword) { - this.context.router.push(`/searched/${encodeURIComponent(keyword)}`) - this.setState({ - search: keyword - }) - ee.emit('top:search', keyword) + const keyword = e.target.value + this.debouncedUpdateKeyword(keyword) } handleSearchFocus (e) { @@ -136,6 +108,7 @@ class TopBar extends React.Component { isSearching: true }) } + handleSearchBlur (e) { e.stopPropagation() @@ -165,7 +138,7 @@ class TopBar extends React.Component { } handleCodeInit () { - ee.emit('top:search', this.refs.searchInput.value) + ee.emit('top:search', this.refs.searchInput.value || '') } render () { @@ -178,24 +151,23 @@ class TopBar extends React.Component {
this.handleSearchFocus(e)} - onBlur={(e) => this.handleSearchBlur(e)} + onFocus={this.handleSearchFocus} + onBlur={this.handleSearchBlur} tabIndex='-1' ref='search' > - this.handleSearchChange(e)} - onKeyDown={(e) => this.handleKeyDown(e)} - onKeyUp={(e) => this.handleKeyUp(e)} + onInputChange={this.handleSearchChange} + onKeyDown={this.handleKeyDown} placeholder={i18n.__('Search')} type='text' className='searchInput' /> {this.state.search !== '' &&
diff --git a/browser/main/global.styl b/browser/main/global.styl index b0da56f5..3f513c78 100644 --- a/browser/main/global.styl +++ b/browser/main/global.styl @@ -159,4 +159,4 @@ 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 +@import '../styles/Detail/TagSelect.styl' diff --git a/browser/main/index.js b/browser/main/index.js index 6e8bdcc5..b3a909e5 100644 --- a/browser/main/index.js +++ b/browser/main/index.js @@ -1,11 +1,13 @@ import { Provider } from 'react-redux' import Main from './Main' -import store from './store' -import React from 'react' +import { store, history } from './store' +import React, { Fragment } from 'react' import ReactDOM from 'react-dom' require('!!style!css!stylus?sourceMap!./global.styl') -import { Router, Route, IndexRoute, IndexRedirect, hashHistory } from 'react-router' -import { syncHistoryWithStore } from 'react-router-redux' +import { Route, Switch, Redirect } from 'react-router-dom' +import { ConnectedRouter } from 'connected-react-router' +import DevTools from './DevTools' + require('./lib/ipcClient') require('../lib/customMeta') import i18n from 'browser/lib/i18n' @@ -77,7 +79,6 @@ document.addEventListener('click', function (e) { }) const el = document.getElementById('content') -const history = syncHistoryWithStore(hashHistory, store) function notify (...args) { return new window.Notification(...args) @@ -98,29 +99,24 @@ function updateApp () { ReactDOM.render(( - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + {/* storages */} + + + + + + + ), el, function () { const loadingCover = document.getElementById('loadingCover') diff --git a/browser/main/lib/ConfigManager.js b/browser/main/lib/ConfigManager.js index e8e11b9e..26fda02b 100644 --- a/browser/main/lib/ConfigManager.js +++ b/browser/main/lib/ConfigManager.js @@ -9,9 +9,14 @@ const win = global.process.platform === 'win32' const electron = require('electron') const { ipcRenderer } = electron const consts = require('browser/lib/consts') +const electronConfig = new (require('electron-config'))() let isInitialized = false +const DEFAULT_MARKDOWN_LINT_CONFIG = `{ + "default": true +}` + export const DEFAULT_CONFIG = { zoom: 1, isSideNavFolded: false, @@ -23,11 +28,17 @@ export const DEFAULT_CONFIG = { sortTagsBy: 'ALPHABETICAL', // 'ALPHABETICAL', 'COUNTER' listStyle: 'DEFAULT', // 'DEFAULT', 'SMALL' amaEnabled: true, + autoUpdateEnabled: true, hotkey: { toggleMain: OSX ? 'Command + Alt + L' : 'Super + Alt + E', toggleMode: OSX ? 'Command + Alt + M' : 'Ctrl + M', + toggleDirection: OSX ? 'Command + Alt + Right' : 'Ctrl + Right', deleteNote: OSX ? 'Command + Shift + Backspace' : 'Ctrl + Shift + Backspace', pasteSmartly: OSX ? 'Command + Shift + V' : 'Ctrl + Shift + V', + prettifyMarkdown: OSX ? 'Command + Shift + F' : 'Ctrl + Shift + F', + sortLines: OSX ? 'Command + Shift + S' : 'Ctrl + Shift + S', + insertDate: OSX ? 'Command + /' : 'Ctrl + /', + insertDateTime: OSX ? 'Command + Alt + /' : 'Ctrl + Shift + /', toggleMenuBar: 'Alt' }, ui: { @@ -45,10 +56,11 @@ export const DEFAULT_CONFIG = { fontFamily: win ? 'Consolas' : 'Monaco', indentType: 'space', indentSize: '2', + lineWrapping: true, enableRulers: false, rulers: [80, 120], displayLineNumbers: true, - matchingPairs: '()[]{}\'\'""$$**``', + matchingPairs: '()[]{}\'\'""$$**``~~__', matchingTriples: '```"""\'\'\'', explodingPairs: '[]{}``$$', switchPreview: 'BLUR', // 'BLUR', 'DBL_CLICK', 'RIGHTCLICK' @@ -60,7 +72,16 @@ export const DEFAULT_CONFIG = { enableFrontMatterTitle: true, frontMatterTitleField: 'title', spellcheck: false, - enableSmartPaste: false + enableSmartPaste: false, + enableMarkdownLint: false, + customMarkdownLintConfig: DEFAULT_MARKDOWN_LINT_CONFIG, + prettierConfig: ` { + "trailingComma": "es5", + "tabWidth": 2, + "semi": false, + "singleQuote": true + }`, + deleteUnusedAttachments: true }, preview: { fontSize: '14', @@ -78,8 +99,10 @@ export const DEFAULT_CONFIG = { breaks: true, smartArrows: false, allowCustomCSS: false, - customCSS: '', + + customCSS: '/* Drop Your Custom CSS Code Here */', sanitize: 'STRICT', // 'STRICT', 'ALLOW_STYLES', 'NONE' + mermaidHTMLLabel: false, lineThroughCheckbox: true }, blog: { @@ -103,7 +126,6 @@ function validate (config) { } function _save (config) { - console.log(config) window.localStorage.setItem('config', JSON.stringify(config)) } @@ -123,6 +145,8 @@ function get () { _save(config) } + config.autoUpdateEnabled = electronConfig.get('autoUpdateEnabled', config.autoUpdateEnabled) + if (!isInitialized) { isInitialized = true let editorTheme = document.getElementById('editorTheme') @@ -133,16 +157,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' } } @@ -151,7 +171,13 @@ function get () { function set (updates) { const currentConfig = get() - const newConfig = Object.assign({}, DEFAULT_CONFIG, currentConfig, updates) + + const arrangedUpdates = updates + if (updates.preview !== undefined && updates.preview.customCSS === '') { + arrangedUpdates.preview.customCSS = DEFAULT_CONFIG.preview.customCSS + } + + const newConfig = Object.assign({}, DEFAULT_CONFIG, currentConfig, arrangedUpdates) if (!validate(newConfig)) throw new Error('INVALID CONFIG') _save(newConfig) @@ -170,18 +196,15 @@ function set (updates) { editorTheme.setAttribute('rel', 'stylesheet') document.head.appendChild(editorTheme) } - const newTheme = consts.THEMES.some((theme) => theme === newConfig.editor.theme) - ? newConfig.editor.theme - : 'default' - if (newTheme !== 'default') { - if (newTheme.startsWith('solarized')) { - editorTheme.setAttribute('href', '../node_modules/codemirror/theme/solarized.css') - } else { - editorTheme.setAttribute('href', '../node_modules/codemirror/theme/' + newTheme + '.css') - } + const newTheme = consts.THEMES.find(theme => theme.name === newConfig.editor.theme) + + if (newTheme) { + editorTheme.setAttribute('href', newTheme.path) } + electronConfig.set('autoUpdateEnabled', newConfig.autoUpdateEnabled) + ipcRenderer.send('config-renew', { config: get() }) diff --git a/browser/main/lib/dataApi/attachmentManagement.js b/browser/main/lib/dataApi/attachmentManagement.js index 6fa3b51f..971ae812 100644 --- a/browser/main/lib/dataApi/attachmentManagement.js +++ b/browser/main/lib/dataApi/attachmentManagement.js @@ -8,6 +8,7 @@ const escapeStringRegexp = require('escape-string-regexp') const sander = require('sander') const url = require('url') import i18n from 'browser/lib/i18n' +import { isString } from 'lodash' const STORAGE_FOLDER_PLACEHOLDER = ':storage' const DESTINATION_FOLDER = 'attachments' @@ -19,7 +20,7 @@ const PATH_SEPARATORS = escapeStringRegexp(path.posix.sep) + escapeStringRegexp( * @returns {Promise} Image element created */ function getImage (file) { - if (_.isString(file)) { + if (isString(file)) { return new Promise(resolve => { const img = new Image() img.onload = () => resolve(img) @@ -85,7 +86,7 @@ function getOrientation (file) { return view.getUint16(offset + (i * 12) + 8, little) } } - } else if ((marker & 0xFF00) !== 0xFF00) { // If not start with 0xFF, not a Marker + } else if ((marker & 0xFF00) !== 0xFF00) { // If not start with 0xFF, not a Marker. break } else { offset += view.getUint16(offset, false) @@ -241,6 +242,10 @@ function migrateAttachments (markdownContent, storagePath, noteKey) { * @returns {String} postprocessed HTML in which all :storage references are mapped to the actual paths. */ function fixLocalURLS (renderedHTML, storagePath) { + const encodedWin32SeparatorRegex = /%5C/g + const storageRegex = new RegExp('/?' + STORAGE_FOLDER_PLACEHOLDER, 'g') + const storageUrl = 'file:///' + path.join(storagePath, DESTINATION_FOLDER).replace(/\\/g, '/') + /* A :storage reference is like `:storage/3b6f8bd6-4edd-4b15-96e0-eadc4475b564/f939b2c3.jpg`. @@ -250,8 +255,7 @@ function fixLocalURLS (renderedHTML, storagePath) { - `(?:\\\/|%5C)` match the path seperator. `\\\/` for posix systems and `%5C` for windows. */ return renderedHTML.replace(new RegExp('/?' + STORAGE_FOLDER_PLACEHOLDER + '(?:(?:\\\/|%5C)[-.\\w]+)+', 'g'), function (match) { - var encodedPathSeparators = new RegExp(mdurl.encode(path.win32.sep) + '|' + mdurl.encode(path.posix.sep), 'g') - return match.replace(encodedPathSeparators, path.sep).replace(new RegExp('/?' + STORAGE_FOLDER_PLACEHOLDER, 'g'), 'file:///' + path.join(storagePath, DESTINATION_FOLDER)) + return match.replace(encodedWin32SeparatorRegex, '/').replace(storageRegex, storageUrl) }) } @@ -278,27 +282,40 @@ function handleAttachmentDrop (codeEditor, storageKey, noteKey, dropEvent) { let promise if (dropEvent.dataTransfer.files.length > 0) { promise = Promise.all(Array.from(dropEvent.dataTransfer.files).map(file => { - if (file.type.startsWith('image')) { - if (file.type === 'image/gif' || file.type === 'image/svg+xml') { - return copyAttachment(file.path, storageKey, noteKey).then(fileName => ({ + 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(file.path), + title: path.basename(filePath), isImage: true })) } else { - return fixRotate(file) - .then(data => copyAttachment({type: 'base64', data: data, sourceFilePath: file.path}, storageKey, noteKey) - .then(fileName => ({ + 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(file.path), + title: path.basename(filePath), isImage: true - })) + }) ) } } else { - return copyAttachment(file.path, storageKey, noteKey).then(fileName => ({ + return copyAttachment(filePath, storageKey, noteKey).then(fileName => ({ fileName, - title: path.basename(file.path), + title: path.basename(filePath), isImage: false })) } @@ -325,13 +342,18 @@ function handleAttachmentDrop (codeEditor, storageKey, noteKey, dropEvent) { canvas.height = image.height context.drawImage(image, 0, 0) - return copyAttachment({type: 'base64', data: canvas.toDataURL(), sourceFilePath: imageURL}, storageKey, noteKey) + return copyAttachment({ + type: 'base64', + data: canvas.toDataURL(), + sourceFilePath: imageURL + }, storageKey, noteKey) }) .then(fileName => ({ fileName, title: imageURL, isImage: true - }))]) + })) + ]) } promise.then(files => { @@ -449,6 +471,54 @@ function getAbsolutePathsOfAttachmentsInContent (markdownContent, storagePath) { return result } +/** + * @description Copies the attachments to the storage folder and returns the mardown content it should be replaced with + * @param {String} markDownContent content in which the attachment paths should be found + * @param {String} filepath The path of the file with attachments to import + * @param {String} storageKey Storage key of the destination storage + * @param {String} noteKey Key of the current note. Will be used as subfolder in :storage + */ +function importAttachments (markDownContent, filepath, storageKey, noteKey) { + return new Promise((resolve, reject) => { + const nameRegex = /(!\[.*?]\()(.+?\..+?)(\))/g + let attachPath = nameRegex.exec(markDownContent) + const promiseArray = [] + const attachmentPaths = [] + const groupIndex = 2 + + while (attachPath) { + let attachmentPath = attachPath[groupIndex] + attachmentPaths.push(attachmentPath) + attachmentPath = path.isAbsolute(attachmentPath) ? attachmentPath : path.join(path.dirname(filepath), attachmentPath) + promiseArray.push(this.copyAttachment(attachmentPath, storageKey, noteKey)) + attachPath = nameRegex.exec(markDownContent) + } + + let numResolvedPromises = 0 + + if (promiseArray.length === 0) { + resolve(markDownContent) + } + + for (let j = 0; j < promiseArray.length; j++) { + promiseArray[j] + .then((fileName) => { + const newPath = path.join(STORAGE_FOLDER_PLACEHOLDER, noteKey, fileName) + markDownContent = markDownContent.replace(attachmentPaths[j], newPath) + }) + .catch((e) => { + console.error('File does not exist in path: ' + attachmentPaths[j]) + }) + .finally(() => { + numResolvedPromises++ + if (numResolvedPromises === promiseArray.length) { + resolve(markDownContent) + } + }) + } + }) +} + /** * @description Moves the attachments of the current note to the new location. * Returns a modified version of the given content so that the links to the attachments point to the new note key. @@ -551,11 +621,79 @@ function deleteAttachmentsNotPresentInNote (markdownContent, storageKey, noteKey } }) }) - } else { - console.info('Attachment folder ("' + attachmentFolder + '") did not exist..') } } +/** + * @description Get all existing attachments related to a specific note + including their status (in use or not) and their path. Return null if there're no attachment related to note or specified parametters are invalid + * @param markdownContent markdownContent of the current note + * @param storageKey StorageKey of the current note + * @param noteKey NoteKey of the currentNote + * @return {Promise>} Promise returning the + list of attachments with their properties */ +function getAttachmentsPathAndStatus (markdownContent, storageKey, noteKey) { + if (storageKey == null || noteKey == null || markdownContent == null) { + return null + } + const targetStorage = findStorage.findStorage(storageKey) + const attachmentFolder = path.join(targetStorage.path, DESTINATION_FOLDER, noteKey) + const attachmentsInNote = getAttachmentsInMarkdownContent(markdownContent) + const attachmentsInNoteOnlyFileNames = [] + if (attachmentsInNote) { + for (let i = 0; i < attachmentsInNote.length; i++) { + attachmentsInNoteOnlyFileNames.push(attachmentsInNote[i].replace(new RegExp(STORAGE_FOLDER_PLACEHOLDER + escapeStringRegexp(path.sep) + noteKey + escapeStringRegexp(path.sep), 'g'), '')) + } + } + if (fs.existsSync(attachmentFolder)) { + return new Promise((resolve, reject) => { + fs.readdir(attachmentFolder, (err, files) => { + if (err) { + console.error('Error reading directory "' + attachmentFolder + '". Error:') + console.error(err) + reject(err) + return + } + const attachments = [] + for (const file of files) { + const absolutePathOfFile = path.join(targetStorage.path, DESTINATION_FOLDER, noteKey, file) + if (!attachmentsInNoteOnlyFileNames.includes(file)) { + attachments.push({ path: absolutePathOfFile, isInUse: false }) + } else { + attachments.push({ path: absolutePathOfFile, isInUse: true }) + } + } + resolve(attachments) + }) + }) + } else { + return null + } +} + +/** + * @description Remove all specified attachment paths + * @param attachments attachment paths + * @return {Promise} Promise after all attachments are removed */ +function removeAttachmentsByPaths (attachments) { + const promises = [] + for (const attachment of attachments) { + const promise = new Promise((resolve, reject) => { + fs.unlink(attachment, (err) => { + if (err) { + console.error('Could not delete "%s"', attachment) + console.error(err) + reject(err) + return + } + resolve() + }) + }) + promises.push(promise) + } + return Promise.all(promises) +} + /** * Clones the attachments of a given note. * Copies the attachments to their new destination and updates the content of the new note so that the attachment-links again point to the correct destination. @@ -656,9 +794,12 @@ module.exports = { handlePasteNativeImage, getAttachmentsInMarkdownContent, getAbsolutePathsOfAttachmentsInContent, + importAttachments, removeStorageAndNoteReferences, + removeAttachmentsByPaths, deleteAttachmentFolder, deleteAttachmentsNotPresentInNote, + getAttachmentsPathAndStatus, moveAttachments, cloneAttachments, isAttachmentLink, diff --git a/browser/main/lib/dataApi/createNoteFromUrl.js b/browser/main/lib/dataApi/createNoteFromUrl.js new file mode 100644 index 00000000..ead93f9e --- /dev/null +++ b/browser/main/lib/dataApi/createNoteFromUrl.js @@ -0,0 +1,86 @@ +const http = require('http') +const https = require('https') +const { createTurndownService } = require('../../../lib/turndown') +const createNote = require('./createNote') + +import { push } from 'connected-react-router' +import ee from 'browser/main/lib/eventEmitter' + +function validateUrl (str) { + if (/^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})).?)(?::\d{2,5})?(?:[/?#]\S*)?$/i.test(str)) { + return true + } else { + return false + } +} + +const ERROR_MESSAGES = { + ENOTFOUND: 'URL not found. Please check the URL, or your internet connection and try again.', + VALIDATION_ERROR: 'Please check if the URL follows this format: https://www.google.com', + UNEXPECTED: 'Unexpected error! Please check console for details!' +} + +function createNoteFromUrl (url, storage, folder, dispatch = null, location = null) { + return new Promise((resolve, reject) => { + const td = createTurndownService() + + if (!validateUrl(url)) { + reject({result: false, error: ERROR_MESSAGES.VALIDATION_ERROR}) + } + + const request = url.startsWith('https') ? https : http + + const req = request.request(url, (res) => { + let data = '' + + res.on('data', (chunk) => { + data += chunk + }) + + res.on('end', () => { + const markdownHTML = td.turndown(data) + + if (dispatch !== null) { + createNote(storage, { + type: 'MARKDOWN_NOTE', + folder: folder, + title: '', + content: markdownHTML + }) + .then((note) => { + const noteHash = note.key + dispatch({ + type: 'UPDATE_NOTE', + note: note + }) + dispatch(push({ + pathname: location.pathname, + query: {key: noteHash} + })) + ee.emit('list:jump', noteHash) + ee.emit('detail:focus') + resolve({result: true, error: null}) + }) + } else { + createNote(storage, { + type: 'MARKDOWN_NOTE', + folder: folder, + title: '', + content: markdownHTML + }).then((note) => { + resolve({result: true, note, error: null}) + }) + } + }) + }) + + req.on('error', (e) => { + console.error('error in parsing URL', e) + reject({result: false, error: ERROR_MESSAGES[e.code] || ERROR_MESSAGES.UNEXPECTED}) + }) + + req.end() + }) +} + +module.exports = createNoteFromUrl diff --git a/browser/main/lib/dataApi/deleteFolder.js b/browser/main/lib/dataApi/deleteFolder.js index 0c7486f5..5ccc1414 100644 --- a/browser/main/lib/dataApi/deleteFolder.js +++ b/browser/main/lib/dataApi/deleteFolder.js @@ -3,7 +3,6 @@ const path = require('path') const resolveStorageData = require('./resolveStorageData') const resolveStorageNotes = require('./resolveStorageNotes') const CSON = require('@rokt33r/season') -const sander = require('sander') const { findStorage } = require('browser/lib/findStorage') const deleteSingleNote = require('./deleteNote') diff --git a/browser/main/lib/dataApi/exportFolder.js b/browser/main/lib/dataApi/exportFolder.js index 771f77dc..8f15b147 100644 --- a/browser/main/lib/dataApi/exportFolder.js +++ b/browser/main/lib/dataApi/exportFolder.js @@ -43,19 +43,18 @@ function exportFolder (storageKey, folderKey, fileType, exportDir) { .then(function exportNotes (data) { const { storage, notes } = data - notes + return Promise.all(notes .filter(note => note.folder === folderKey && note.isTrashed === false && note.type === 'MARKDOWN_NOTE') - .forEach(note => { + .map(note => { const notePath = path.join(exportDir, `${filenamify(note.title, {replacement: '_'})}.${fileType}`) - exportNote(note.key, storage.path, note.content, notePath, null) + return exportNote(note.key, storage.path, note.content, notePath, null) }) - - return { + ).then(() => ({ storage, folderKey, fileType, exportDir - } + })) }) } diff --git a/browser/main/lib/dataApi/exportNote.js b/browser/main/lib/dataApi/exportNote.js index b358e548..42e1fa56 100755 --- a/browser/main/lib/dataApi/exportNote.js +++ b/browser/main/lib/dataApi/exportNote.js @@ -43,14 +43,17 @@ function exportNote (nodeKey, storageKey, noteContent, targetPath, outputFormatt ) if (outputFormatter) { - exportedData = outputFormatter(exportedData, exportTasks) + exportedData = outputFormatter(exportedData, exportTasks, targetPath) + } else { + exportedData = Promise.resolve(exportedData) } const tasks = prepareTasks(exportTasks, storagePath, path.dirname(targetPath)) return Promise.all(tasks.map((task) => copyFile(task.src, task.dst))) - .then(() => { - return saveToFile(exportedData, targetPath) + .then(() => exportedData) + .then(data => { + return saveToFile(data, targetPath) }).catch((err) => { rollbackExport(tasks) throw err diff --git a/browser/main/lib/dataApi/index.js b/browser/main/lib/dataApi/index.js index 92be6b93..6e88bbf9 100644 --- a/browser/main/lib/dataApi/index.js +++ b/browser/main/lib/dataApi/index.js @@ -11,6 +11,7 @@ const dataApi = { exportFolder: require('./exportFolder'), exportStorage: require('./exportStorage'), createNote: require('./createNote'), + createNoteFromUrl: require('./createNoteFromUrl'), updateNote: require('./updateNote'), deleteNote: require('./deleteNote'), moveNote: require('./moveNote'), diff --git a/browser/main/lib/dataApi/moveNote.js b/browser/main/lib/dataApi/moveNote.js index 2d306cdf..c38968cb 100644 --- a/browser/main/lib/dataApi/moveNote.js +++ b/browser/main/lib/dataApi/moveNote.js @@ -1,7 +1,6 @@ const resolveStorageData = require('./resolveStorageData') const _ = require('lodash') const path = require('path') -const fs = require('fs') const CSON = require('@rokt33r/season') const keygen = require('browser/lib/keygen') const sander = require('sander') diff --git a/browser/main/lib/modal.js b/browser/main/lib/modal.js index 7a7a9c8c..955cb5c8 100644 --- a/browser/main/lib/modal.js +++ b/browser/main/lib/modal.js @@ -1,7 +1,7 @@ import React from 'react' import { Provider } from 'react-redux' import ReactDOM from 'react-dom' -import store from '../store' +import { store } from '../store' class ModalBase extends React.Component { constructor (props) { diff --git a/browser/main/lib/shortcut.js b/browser/main/lib/shortcut.js index 3165606a..98e26e06 100644 --- a/browser/main/lib/shortcut.js +++ b/browser/main/lib/shortcut.js @@ -4,6 +4,9 @@ module.exports = { 'toggleMode': () => { ee.emit('topbar:togglemodebutton') }, + 'toggleDirection': () => { + ee.emit('topbar:toggledirectionbutton') + }, 'deleteNote': () => { ee.emit('hotkey:deletenote') }, diff --git a/browser/main/modals/CreateFolderModal.js b/browser/main/modals/CreateFolderModal.js index b061b0f3..b48d6e42 100644 --- a/browser/main/modals/CreateFolderModal.js +++ b/browser/main/modals/CreateFolderModal.js @@ -3,7 +3,7 @@ import React from 'react' import CSSModules from 'browser/lib/CSSModules' import styles from './CreateFolderModal.styl' import dataApi from 'browser/main/lib/dataApi' -import store from 'browser/main/store' +import { store } from 'browser/main/store' import consts from 'browser/lib/consts' import ModalEscButton from 'browser/components/ModalEscButton' import AwsMobileAnalyticsConfig from 'browser/main/lib/AwsMobileAnalyticsConfig' diff --git a/browser/main/modals/CreateMarkdownFromURLModal.js b/browser/main/modals/CreateMarkdownFromURLModal.js new file mode 100644 index 00000000..31988059 --- /dev/null +++ b/browser/main/modals/CreateMarkdownFromURLModal.js @@ -0,0 +1,118 @@ +import PropTypes from 'prop-types' +import React from 'react' +import CSSModules from 'browser/lib/CSSModules' +import styles from './CreateMarkdownFromURLModal.styl' +import dataApi from 'browser/main/lib/dataApi' +import ModalEscButton from 'browser/components/ModalEscButton' +import i18n from 'browser/lib/i18n' + +class CreateMarkdownFromURLModal extends React.Component { + constructor (props) { + super(props) + + this.state = { + name: '', + showerror: false, + errormessage: '' + } + } + + componentDidMount () { + this.refs.name.focus() + this.refs.name.select() + } + + handleCloseButtonClick (e) { + this.props.close() + } + + handleChange (e) { + this.setState({ + name: this.refs.name.value + }) + } + + handleKeyDown (e) { + if (e.keyCode === 27) { + this.props.close() + } + } + + handleInputKeyDown (e) { + switch (e.keyCode) { + case 13: + this.confirm() + } + } + + handleConfirmButtonClick (e) { + this.confirm() + } + + showError (message) { + this.setState({ + showerror: true, + errormessage: message + }) + } + + hideError () { + this.setState({ + showerror: false, + errormessage: '' + }) + } + + confirm () { + this.hideError() + const { storage, folder, dispatch, location } = this.props + + dataApi.createNoteFromUrl(this.state.name, storage, folder, dispatch, location).then((result) => { + this.props.close() + }).catch((result) => { + this.showError(result.error) + }) + } + + render () { + return ( +
this.handleKeyDown(e)} + > +
+
{i18n.__('Import Markdown From URL')}
+
+ this.handleCloseButtonClick(e)} /> +
+
+
{i18n.__('Insert URL Here')}
+ this.handleChange(e)} + onKeyDown={(e) => this.handleInputKeyDown(e)} + /> +
+ +
{this.state.errormessage}
+
+
+ ) + } +} + +CreateMarkdownFromURLModal.propTypes = { + storage: PropTypes.string, + folder: PropTypes.string, + dispatch: PropTypes.func, + location: PropTypes.shape({ + pathname: PropTypes.string + }) +} + +export default CSSModules(CreateMarkdownFromURLModal, styles) diff --git a/browser/main/modals/CreateMarkdownFromURLModal.styl b/browser/main/modals/CreateMarkdownFromURLModal.styl new file mode 100644 index 00000000..5e59e465 --- /dev/null +++ b/browser/main/modals/CreateMarkdownFromURLModal.styl @@ -0,0 +1,160 @@ +.root + modal() + width 500px + height 270px + overflow hidden + position relative + +.header + height 80px + margin-bottom 10px + margin-top 20px + font-size 18px + line-height 50px + background-color $ui-backgroundColor + color $ui-text-color + +.title + font-size 36px + font-weight 600 + +.control-folder-label + text-align left + font-size 14px + color $ui-text-color + +.control-folder-input + display block + height 40px + width 490px + padding 0 5px + margin 10px 0 + border 1px solid $ui-input--create-folder-modal + border-radius 2px + background-color transparent + outline none + vertical-align middle + font-size 16px + &:disabled + background-color $ui-input--disabled-backgroundColor + &:focus, &:active + border-color $ui-active-color + +.control-confirmButton + display block + height 35px + width 140px + border none + border-radius 2px + padding 0 25px + margin 20px auto + font-size 14px + colorPrimaryButton() + +body[data-theme="dark"] + .root + modalDark() + width 500px + height 270px + overflow hidden + position relative + + .header + background-color transparent + border-color $ui-dark-borderColor + color $ui-dark-text-color + + .control-folder-label + color $ui-dark-text-color + + .control-folder-input + border 1px solid $ui-input--create-folder-modal + color white + + .description + color $ui-inactive-text-color + + .control-confirmButton + colorDarkPrimaryButton() + +body[data-theme="solarized-dark"] + .root + modalSolarizedDark() + width 500px + height 270px + overflow hidden + position relative + + .header + background-color transparent + border-color $ui-dark-borderColor + color $ui-solarized-dark-text-color + + .control-folder-label + color $ui-solarized-dark-text-color + + .control-folder-input + border 1px solid $ui-input--create-folder-modal + color white + + .description + color $ui-inactive-text-color + + .control-confirmButton + colorSolarizedDarkPrimaryButton() + +.error + text-align center + color #F44336 + +body[data-theme="monokai"] + .root + modalMonokai() + width 500px + height 270px + overflow hidden + position relative + + .header + background-color transparent + border-color $ui-dark-borderColor + color $ui-monokai-text-color + + .control-folder-label + color $ui-monokai-text-color + + .control-folder-input + border 1px solid $ui-input--create-folder-modal + color white + + .description + color $ui-inactive-text-color + + .control-confirmButton + colorMonokaiPrimaryButton() + +body[data-theme="dracula"] + .root + modalDracula() + width 500px + height 270px + overflow hidden + position relative + + .header + background-color transparent + border-color $ui-dracula-borderColor + color $ui-dracula-text-color + + .control-folder-label + color $ui-dracula-text-color + + .control-folder-input + border 1px solid $ui-input--create-folder-modal + color white + + .description + color $ui-inactive-text-color + + .control-confirmButton + colorDraculaPrimaryButton() diff --git a/browser/main/modals/NewNoteModal.js b/browser/main/modals/NewNoteModal.js index a190602c..476fa252 100644 --- a/browser/main/modals/NewNoteModal.js +++ b/browser/main/modals/NewNoteModal.js @@ -3,12 +3,15 @@ import CSSModules from 'browser/lib/CSSModules' import styles from './NewNoteModal.styl' import ModalEscButton from 'browser/components/ModalEscButton' import i18n from 'browser/lib/i18n' +import { openModal } from 'browser/main/lib/modal' +import CreateMarkdownFromURLModal from '../modals/CreateMarkdownFromURLModal' import { createMarkdownNote, createSnippetNote } from 'browser/lib/newNote' +import queryString from 'query-string' class NewNoteModal extends React.Component { constructor (props) { super(props) - + this.lock = false this.state = {} } @@ -20,13 +23,29 @@ class NewNoteModal extends React.Component { this.props.close() } - handleMarkdownNoteButtonClick (e) { - const { storage, folder, dispatch, location, params, config } = this.props - createMarkdownNote(storage, folder, dispatch, location, params, config).then(() => { - setTimeout(this.props.close, 200) + handleCreateMarkdownFromUrlClick (e) { + this.props.close() + + const { storage, folder, dispatch, location } = this.props + openModal(CreateMarkdownFromURLModal, { + storage: storage, + folder: folder, + dispatch, + location }) } + handleMarkdownNoteButtonClick (e) { + const { storage, folder, dispatch, location, config } = this.props + const params = location.search !== '' && queryString.parse(location.search) + if (!this.lock) { + this.lock = true + createMarkdownNote(storage, folder, dispatch, location, params, config).then(() => { + setTimeout(this.props.close, 200) + }) + } + } + handleMarkdownNoteButtonKeyDown (e) { if (e.keyCode === 9) { e.preventDefault() @@ -35,10 +54,14 @@ class NewNoteModal extends React.Component { } handleSnippetNoteButtonClick (e) { - const { storage, folder, dispatch, location, params, config } = this.props - createSnippetNote(storage, folder, dispatch, location, params, config).then(() => { - setTimeout(this.props.close, 200) - }) + const { storage, folder, dispatch, location, config } = this.props + const params = location.search !== '' && queryString.parse(location.search) + if (!this.lock) { + this.lock = true + createSnippetNote(storage, folder, dispatch, location, params, config).then(() => { + setTimeout(this.props.close, 200) + }) + } } handleSnippetNoteButtonKeyDown (e) { @@ -106,10 +129,8 @@ class NewNoteModal extends React.Component {
-
- {i18n.__('Tab to switch format')} -
- +
{i18n.__('Tab to switch format')}
+
this.handleCreateMarkdownFromUrlClick(e)}>Or, create a new markdown note from a URL
) } diff --git a/browser/main/modals/NewNoteModal.styl b/browser/main/modals/NewNoteModal.styl index c82ac9a9..a9be5a22 100644 --- a/browser/main/modals/NewNoteModal.styl +++ b/browser/main/modals/NewNoteModal.styl @@ -19,6 +19,7 @@ .control padding 25px 0px text-align center + display: flex .control-button width 240px @@ -47,6 +48,12 @@ text-align center margin-bottom 25px +.from-url + color $ui-inactive-text-color + text-align center + margin-bottom 25px + cursor pointer + apply-theme(theme) body[data-theme={theme}] .root @@ -69,4 +76,4 @@ for theme in 'dark' 'solarized-dark' 'dracula' apply-theme(theme) for theme in $themes - apply-theme(theme) \ No newline at end of file + apply-theme(theme) diff --git a/browser/main/modals/PreferencesModal/Blog.js b/browser/main/modals/PreferencesModal/Blog.js index 2c93fb29..4d59bea1 100644 --- a/browser/main/modals/PreferencesModal/Blog.js +++ b/browser/main/modals/PreferencesModal/Blog.js @@ -2,7 +2,7 @@ import React from 'react' import CSSModules from 'browser/lib/CSSModules' import styles from './ConfigTab.styl' import ConfigManager from 'browser/main/lib/ConfigManager' -import store from 'browser/main/store' +import { store } from 'browser/main/store' import PropTypes from 'prop-types' import _ from 'lodash' import i18n from 'browser/lib/i18n' diff --git a/browser/main/modals/PreferencesModal/ConfigTab.styl b/browser/main/modals/PreferencesModal/ConfigTab.styl index 0d9881cd..4e671982 100644 --- a/browser/main/modals/PreferencesModal/ConfigTab.styl +++ b/browser/main/modals/PreferencesModal/ConfigTab.styl @@ -18,6 +18,14 @@ margin-bottom 15px margin-top 30px +.group-header--sub + @extend .group-header + margin-bottom 10px + +.group-header2--sub + @extend .group-header2 + margin-bottom 10px + .group-section margin-bottom 20px display flex @@ -138,10 +146,12 @@ body[data-theme="dark"] color $ui-dark-text-color .group-header + .group-header--sub color $ui-dark-text-color border-color $ui-dark-borderColor .group-header2 + .group-header2--sub color $ui-dark-text-color .group-section-control-input @@ -192,4 +202,4 @@ for theme in 'solarized-dark' 'dracula' apply-theme(theme) for theme in $themes - apply-theme(theme) \ No newline at end of file + apply-theme(theme) diff --git a/browser/main/modals/PreferencesModal/Crowdfunding.js b/browser/main/modals/PreferencesModal/Crowdfunding.js index f94ee5ca..56bb6e34 100644 --- a/browser/main/modals/PreferencesModal/Crowdfunding.js +++ b/browser/main/modals/PreferencesModal/Crowdfunding.js @@ -22,18 +22,16 @@ class Crowdfunding extends React.Component { render () { return (
-
{i18n.__('Crowdfunding')}
+
{i18n.__('Crowdfunding')}

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


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

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

-
-

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

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

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

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

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

-
-

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

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

diff --git a/browser/main/modals/PreferencesModal/Crowdfunding.styl b/browser/main/modals/PreferencesModal/Crowdfunding.styl index eb4f1289..4725aa23 100644 --- a/browser/main/modals/PreferencesModal/Crowdfunding.styl +++ b/browser/main/modals/PreferencesModal/Crowdfunding.styl @@ -1,14 +1,8 @@ -@import('./Tab') +@import('./ConfigTab') -.root - padding 15px - white-space pre - line-height 1.4 - color alpha($ui-text-color, 90%) - width 100% - font-size 14px p font-size 16px + line-height 1.4 .cf-link height 35px diff --git a/browser/main/modals/PreferencesModal/FolderItem.js b/browser/main/modals/PreferencesModal/FolderItem.js index dc9082b9..648db4e6 100644 --- a/browser/main/modals/PreferencesModal/FolderItem.js +++ b/browser/main/modals/PreferencesModal/FolderItem.js @@ -4,7 +4,7 @@ import CSSModules from 'browser/lib/CSSModules' import ReactDOM from 'react-dom' import styles from './FolderItem.styl' import dataApi from 'browser/main/lib/dataApi' -import store from 'browser/main/store' +import { store } from 'browser/main/store' import { SketchPicker } from 'react-color' import { SortableElement, SortableHandle } from 'react-sortable-hoc' import i18n from 'browser/lib/i18n' @@ -225,7 +225,7 @@ class FolderItem extends React.Component {
- {folder.name} + {folder.name} ({folder.key})
@@ -288,10 +288,10 @@ class Handle extends React.Component { class SortableFolderItemComponent extends React.Component { render () { - const StyledHandle = CSSModules(Handle, this.props.styles) + const StyledHandle = CSSModules(Handle, styles) const DragHandle = SortableHandle(StyledHandle) - const StyledFolderItem = CSSModules(FolderItem, this.props.styles) + const StyledFolderItem = CSSModules(FolderItem, styles) return (
diff --git a/browser/main/modals/PreferencesModal/FolderList.js b/browser/main/modals/PreferencesModal/FolderList.js index e7cc6f94..674026c5 100644 --- a/browser/main/modals/PreferencesModal/FolderList.js +++ b/browser/main/modals/PreferencesModal/FolderList.js @@ -3,7 +3,7 @@ import React from 'react' import CSSModules from 'browser/lib/CSSModules' import dataApi from 'browser/main/lib/dataApi' import styles from './FolderList.styl' -import store from 'browser/main/store' +import { store } from 'browser/main/store' import FolderItem from './FolderItem' import { SortableContainer } from 'react-sortable-hoc' import i18n from 'browser/lib/i18n' @@ -22,7 +22,7 @@ class FolderList extends React.Component { }) return ( -
+
{folderList.length > 0 ? folderList :
{i18n.__('No Folders')}
diff --git a/browser/main/modals/PreferencesModal/HotkeyTab.js b/browser/main/modals/PreferencesModal/HotkeyTab.js index 218a68f6..00fea4f0 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' @@ -30,7 +30,8 @@ class HotkeyTab extends React.Component { this.handleSettingError = (err) => { if ( this.state.config.hotkey.toggleMain === '' || - this.state.config.hotkey.toggleMode === '' + this.state.config.hotkey.toggleMode === '' || + this.state.config.hotkey.toggleDirection === '' ) { this.setState({keymapAlert: { type: 'success', @@ -76,13 +77,17 @@ class HotkeyTab extends React.Component { handleHotkeyChange (e) { const { config } = this.state - config.hotkey = { + config.hotkey = Object.assign({}, config.hotkey, { toggleMain: this.refs.toggleMain.value, toggleMode: this.refs.toggleMode.value, + toggleDirection: this.refs.toggleDirection.value, deleteNote: this.refs.deleteNote.value, pasteSmartly: this.refs.pasteSmartly.value, - toggleMenuBar: this.refs.toggleMenuBar.value - } + prettifyMarkdown: this.refs.prettifyMarkdown.value, + toggleMenuBar: this.refs.toggleMenuBar.value, + insertDate: this.refs.insertDate.value, + insertDateTime: this.refs.insertDateTime.value + }) this.setState({ config }) @@ -151,6 +156,17 @@ class HotkeyTab extends React.Component { />
+
+
{i18n.__('Toggle Direction')}
+
+ this.handleHotkeyChange(e)} + ref='toggleDirection' + value={config.hotkey.toggleDirection} + type='text' + /> +
+
{i18n.__('Delete Note')}
@@ -173,6 +189,38 @@ class HotkeyTab extends React.Component { />
+
+
{i18n.__('Prettify Markdown')}
+
+ this.handleHotkeyChange(e)} + ref='prettifyMarkdown' + value={config.hotkey.prettifyMarkdown} + type='text' /> +
+
+
+
{i18n.__('Insert Current Date')}
+
+ +
+
+
+
{i18n.__('Insert Current Date and Time')}
+
+ +
+
+
{i18n.__('Attachment storage')}
+

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

+

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

+

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

+
) } diff --git a/browser/main/modals/PreferencesModal/StoragesTab.styl b/browser/main/modals/PreferencesModal/StoragesTab.styl index bed3ff72..285e9c60 100644 --- a/browser/main/modals/PreferencesModal/StoragesTab.styl +++ b/browser/main/modals/PreferencesModal/StoragesTab.styl @@ -1,8 +1,4 @@ -@import('./Tab') - -.root - padding 15px - color $ui-text-color +@import('./ConfigTab') .list margin-bottom 15px @@ -37,6 +33,17 @@ colorDefaultButton() font-size $tab--button-font-size border-radius 2px +.list-attachment-label + margin-bottom 10px + color $ui-text-color +.list-attachement-clear-button + height 30px + border none + border-top-right-radius 2px + border-bottom-right-radius 2px + colorPrimaryButton() + vertical-align middle + padding 0 20px .addStorage margin-bottom 15px @@ -158,6 +165,8 @@ body[data-theme="dark"] .addStorage-body-control-cancelButton colorDarkDefaultButton() border-color $ui-dark-borderColor + .list-attachement-clear-button + colorDarkPrimaryButton() apply-theme(theme) body[data-theme={theme}] @@ -197,9 +206,11 @@ apply-theme(theme) .addStorage-body-control-cancelButton colorDarkDefaultButton() border-color get-theme-var(theme, 'borderColor') + .list-attachement-clear-button + colorThemedPrimaryButton(theme) for theme in 'solarized-dark' 'dracula' apply-theme(theme) for theme in $themes - apply-theme(theme) \ No newline at end of file + apply-theme(theme) diff --git a/browser/main/modals/PreferencesModal/UiTab.js b/browser/main/modals/PreferencesModal/UiTab.js index b52b1bdb..c54ed098 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' @@ -31,7 +31,13 @@ class UiTab extends React.Component { componentDidMount () { CodeMirror.autoLoadMode(this.codeMirrorInstance.getCodeMirror(), 'javascript') CodeMirror.autoLoadMode(this.customCSSCM.getCodeMirror(), 'css') + CodeMirror.autoLoadMode(this.customMarkdownLintConfigCM.getCodeMirror(), 'javascript') + CodeMirror.autoLoadMode(this.prettierConfigCM.getCodeMirror(), 'javascript') + // Set CM editor Sizes this.customCSSCM.getCodeMirror().setSize('400px', '400px') + this.prettierConfigCM.getCodeMirror().setSize('400px', '400px') + this.customMarkdownLintConfigCM.getCodeMirror().setSize('400px', '200px') + this.handleSettingDone = () => { this.setState({UiAlert: { type: 'success', @@ -90,6 +96,7 @@ class UiTab extends React.Component { enableRulers: this.refs.enableEditorRulers.value === 'true', rulers: this.refs.editorRulers.value.replace(/[^0-9,]/g, '').split(','), displayLineNumbers: this.refs.editorDisplayLineNumbers.checked, + lineWrapping: this.refs.editorLineWrapping.checked, switchPreview: this.refs.editorSwitchPreview.value, keyMap: this.refs.editorKeyMap.value, snippetDefaultLanguage: this.refs.editorSnippetDefaultLanguage.value, @@ -102,7 +109,11 @@ class UiTab extends React.Component { matchingTriples: this.refs.matchingTriples.value, explodingPairs: this.refs.explodingPairs.value, spellcheck: this.refs.spellcheck.checked, - enableSmartPaste: this.refs.enableSmartPaste.checked + enableSmartPaste: this.refs.enableSmartPaste.checked, + enableMarkdownLint: this.refs.enableMarkdownLint.checked, + customMarkdownLintConfig: this.customMarkdownLintConfigCM.getCodeMirror().getValue(), + prettierConfig: this.prettierConfigCM.getCodeMirror().getValue(), + deleteUnusedAttachments: this.refs.deleteUnusedAttachments.checked }, preview: { fontSize: this.refs.previewFontSize.value, @@ -120,6 +131,7 @@ class UiTab extends React.Component { breaks: this.refs.previewBreaks.checked, smartArrows: this.refs.previewSmartArrows.checked, sanitize: this.refs.previewSanitize.value, + mermaidHTMLLabel: this.refs.previewMermaidHTMLLabel.checked, allowCustomCSS: this.refs.previewAllowCustomCSS.checked, lineThroughCheckbox: this.refs.lineThroughCheckbox.checked, customCSS: this.customCSSCM.getCodeMirror().getValue() @@ -129,8 +141,13 @@ class UiTab extends React.Component { const newCodemirrorTheme = this.refs.editorTheme.value if (newCodemirrorTheme !== codemirrorTheme) { - checkHighLight.setAttribute('href', `../node_modules/codemirror/theme/${newCodemirrorTheme.split(' ')[0]}.css`) + const theme = consts.THEMES.find(theme => theme.name === newCodemirrorTheme) + + if (theme) { + checkHighLight.setAttribute('href', theme.path) + } } + this.setState({ config: newConfig, codemirrorTheme: newCodemirrorTheme }, () => { const {ui, editor, preview} = this.props.config this.currentConfig = {ui, editor, preview} @@ -364,7 +381,7 @@ class UiTab extends React.Component { > { themes.map((theme) => { - return () + return () }) } @@ -501,7 +518,7 @@ class UiTab extends React.Component { ref='editorSnippetDefaultLanguage' onChange={(e) => this.handleUIChange(e)} > - + { _.sortBy(CodeMirror.modeInfo.map(mode => mode.name)).map(name => ()) } @@ -545,6 +562,17 @@ class UiTab extends React.Component {
+
+ +
+
+
+ +
@@ -641,6 +679,34 @@ class UiTab extends React.Component { />
+
+
+ {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')}
@@ -679,7 +745,7 @@ class UiTab extends React.Component { > { themes.map((theme) => { - return () + return () }) } @@ -772,6 +838,16 @@ class UiTab extends React.Component {
+
+ +
{i18n.__('LaTeX Inline Open Delimiter')} @@ -863,7 +939,27 @@ class UiTab extends React.Component {
- +
+
+ {i18n.__('Prettier Config')} +
+
+
+ this.handleUIChange(e)} + ref={e => (this.prettierConfigCM = e)} + value={config.editor.prettierConfig} + options={{ + lineNumbers: true, + mode: 'application/json', + lint: true, + theme: codemirrorTheme + }} /> +
+
+