diff --git a/.editorconfig b/.editorconfig index a4730cbf..8c5bd614 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# EditorConfig is awesome: http://EditorConfig.org +# EditorConfig is awesome: https://EditorConfig.org # top-most EditorConfig file root = true diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..9d1cc4ec --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,41 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "type": "node", + "request": "launch", + "name": "BoostNote Main", + "protocol": "inspector", + "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron", + "runtimeArgs": [ + "--remote-debugging-port=9223", + "--hot", + "${workspaceFolder}/index.js" + ], + "windows": { + "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd" + } + }, + { + "type": "chrome", + "request": "attach", + "name": "BoostNote Renderer", + "port": 9223, + "webRoot": "${workspaceFolder}", + "sourceMapPathOverrides": { + "webpack:///./~/*": "${webRoot}/node_modules/*", + "webpack:///*": "${webRoot}/*" + } + } + ], + "compounds": [ + { + "name": "BostNote All", + "configurations": ["BoostNote Main", "BoostNote Renderer"] + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..c6664225 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,27 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "Build Boostnote", + "group": "build", + "type": "npm", + "script": "watch", + "isBackground": true, + "presentation": { + "reveal": "always", + }, + "problemMatcher": { + "pattern":[ + { + "regexp": "^([^\\\\s].*)\\\\((\\\\d+,\\\\d+)\\\\):\\\\s*(.*)$", + "file": 1, + "location": 2, + "message": 3 + } + ] + } + } + ] +} \ No newline at end of file diff --git a/Backers.md b/Backers.md deleted file mode 100644 index 18d221bf..00000000 --- a/Backers.md +++ /dev/null @@ -1,72 +0,0 @@ -

Sponsors & Backers

- -Boostnote is an open source project. It's an independent project with its ongoing development made possible entirely thanks to the support by these awesome backers. If you'd like to join them, please consider: - -- [Become a backer or sponsor on Open Collective.](https://opencollective.com/boostnoteio) - ---- - -## Backers via OpenCollective - -### [Gold Sponsors / $1,000 per month](https://opencollective.com/boostnoteio/order/2259) -- Get your logo on our Readme.md on GitHub and the frontpage of https://boostnote.io/. - -### [Silver Sponsors / $250 per month](https://opencollective.com/boostnoteio/order/2257) -- Get your logo on our Readme.md on GitHub and the frontpage of https://boostnote.io/. - -### [Bronze Sponsors / $50 per month](https://opencollective.com/boostnoteio/order/2258) -- Get your name and Url (or E-mail) on Readme.md on GitHub. - -### [Backers3 / $10 per month](https://opencollective.com/boostnoteio/order/2176) -- [Ralph03](https://opencollective.com/ralph03) - -- [Nikolas Dan](https://opencollective.com/nikolas-dan) - -### [Backers2 / $5 per month](https://opencollective.com/boostnoteio/order/2175) -- [Yeojong Kim](https://twitter.com/yeojoy) - -- [Scotia Draven](https://opencollective.com/scotia-draven) - -- [A. J. Vargas](https://opencollective.com/aj-vargas) - -### [Backers1](https://opencollective.com/boostnoteio/order/2563) and One-time sponsors -- Ryosuke Tamura - $30 - -- tatoosh11 - $10 - -- Alexander Borovkov - $10 - -- spoonhoop - $5 - -- Drew Williams - $2 - -- Andy Shaw - $2 - -- mysafesky -$2 - ---- - -## Backers via Bountysource -https://salt.bountysource.com/teams/boostnote - -- Kuzz - $65 - -- Intense Raiden - $45 - -- ravy22 - $25 - -- trentpolack - $20 - -- hikariru - $10 - -- kolchan11 - $10 - -- RonWalker22 - $10 - -- hocchuc - $5 - -- Adam - $5 - -- Steve - $5 - -- evmin - $5 diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..58df576a --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,36 @@ + +## Description + + +## Issue fixed + + + +## Type of changes + +- :white_circle: Bug fix (Change that fixed an issue) +- :white_circle: Breaking change (Change that can cause existing functionality to change) +- :white_circle: Improvement (Change that improves the code. Maybe performance or development improvement) +- :white_circle: Feature (Change that adds new functionality) +- :white_circle: Documentation change (Change that modifies documentation. Maybe typo fixes) + +## Checklist: + +- :white_circle: My code follows [the project code style](docs/code_style.md) +- :white_circle: I have written test for my code and it has been tested +- :white_circle: All existing tests have been passed +- :white_circle: I have attached a screenshot/video to visualize my change if possible diff --git a/browser/components/CodeEditor.js b/browser/components/CodeEditor.js index 41d71622..aa380e38 100644 --- a/browser/components/CodeEditor.js +++ b/browser/components/CodeEditor.js @@ -11,9 +11,12 @@ import eventEmitter from 'browser/main/lib/eventEmitter' import iconv from 'iconv-lite' import crypto from 'crypto' import consts from 'browser/lib/consts' +import styles from '../components/CodeEditor.styl' import fs from 'fs' -const { ipcRenderer } = require('electron') +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' @@ -22,6 +25,10 @@ CodeMirror.modeURL = '../node_modules/codemirror/mode/%N/%N.js' const buildCMRulers = (rulers, enableRulers) => (enableRulers ? rulers.map(ruler => ({ column: ruler })) : []) +function translateHotkey (hotkey) { + return hotkey.replace(/\s*\+\s*/g, '-').replace(/Command/g, 'Cmd').replace(/Control/g, 'Ctrl') +} + export default class CodeEditor extends React.Component { constructor (props) { super(props) @@ -30,7 +37,8 @@ export default class CodeEditor extends React.Component { leading: false, trailing: true }) - this.changeHandler = e => this.handleChange(e) + this.changeHandler = (editor, changeObject) => this.handleChange(editor, changeObject) + this.highlightHandler = (editor, changeObject) => this.handleHighlight(editor, changeObject) this.focusHandler = () => { ipcRenderer.send('editor:focused', true) } @@ -53,7 +61,11 @@ export default class CodeEditor extends React.Component { noteKey ) } - this.pasteHandler = (editor, e) => this.handlePaste(editor, e) + this.pasteHandler = (editor, e) => { + e.preventDefault() + + this.handlePaste(editor, false) + } this.loadStyleHandler = e => { this.editor.refresh() } @@ -62,7 +74,19 @@ export default class CodeEditor extends React.Component { this.scrollToLineHandeler = this.scrollToLine.bind(this) this.formatTable = () => this.handleFormatTable() + + if (props.switchPreview !== 'RIGHTCLICK') { + this.contextMenuHandler = function (editor, event) { + const menu = buildEditorContextMenu(editor, event) + if (menu != null) { + setTimeout(() => menu.popup(remote.getCurrentWindow()), 30) + } + } + } + this.editorActivityHandler = () => this.handleEditorActivity() + + this.turndownService = new TurndownService() } handleSearch (msg) { @@ -109,42 +133,9 @@ export default class CodeEditor extends React.Component { } } - updateTableEditorState () { - const active = this.tableEditor.cursorIsInTable(this.tableEditorOptions) - if (active) { - if (this.extraKeysMode !== 'editor') { - this.extraKeysMode = 'editor' - this.editor.setOption('extraKeys', this.editorKeyMap) - } - } else { - if (this.extraKeysMode !== 'default') { - this.extraKeysMode = 'default' - this.editor.setOption('extraKeys', this.defaultKeyMap) - this.tableEditor.resetSmartCursor() - } - } - } - - componentDidMount () { - const { rulers, enableRulers } = this.props + updateDefaultKeyMap () { + const { hotkey } = this.props const expandSnippet = this.expandSnippet.bind(this) - 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' - ) - } this.defaultKeyMap = CodeMirror.normalizeKeyMap({ Tab: function (cm) { @@ -187,6 +178,9 @@ export default class CodeEditor extends React.Component { } } }, + 'Cmd-Left': function (cm) { + cm.execCommand('goLineLeft') + }, 'Cmd-T': function (cm) { // Do nothing }, @@ -196,13 +190,56 @@ export default class CodeEditor extends React.Component { document.execCommand('copy') } return CodeMirror.Pass + }, + [translateHotkey(hotkey.pasteSmartly)]: cm => { + this.handlePaste(cm, true) } }) + } + + updateTableEditorState () { + const active = this.tableEditor.cursorIsInTable(this.tableEditorOptions) + if (active) { + if (this.extraKeysMode !== 'editor') { + this.extraKeysMode = 'editor' + this.editor.setOption('extraKeys', this.editorKeyMap) + } + } else { + if (this.extraKeysMode !== 'default') { + this.extraKeysMode = 'default' + this.editor.setOption('extraKeys', this.defaultKeyMap) + this.tableEditor.resetSmartCursor() + } + } + } + + componentDidMount () { + const { rulers, enableRulers } = 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' + ) + } + + this.updateDefaultKeyMap() this.value = this.props.value this.editor = CodeMirror(this.refs.root, { rulers: buildCMRulers(rulers, enableRulers), value: this.props.value, + linesHighlighted: this.props.linesHighlighted, lineNumbers: this.props.displayLineNumbers, lineWrapping: true, theme: this.props.theme, @@ -229,7 +266,11 @@ export default class CodeEditor extends React.Component { this.editor.on('focus', this.focusHandler) this.editor.on('blur', this.blurHandler) this.editor.on('change', this.changeHandler) + this.editor.on('gutterClick', this.highlightHandler) this.editor.on('paste', this.pasteHandler) + if (this.props.switchPreview !== 'RIGHTCLICK') { + this.editor.on('contextmenu', this.contextMenuHandler) + } eventEmitter.on('top:search', this.searchHandler) eventEmitter.emit('code:init') @@ -246,6 +287,10 @@ export default class CodeEditor extends React.Component { this.textEditorInterface = new TextEditorInterface(this.editor) this.tableEditor = new TableEditor(this.textEditorInterface) + if (this.props.spellCheck) { + this.editor.addPanel(this.createSpellCheckPanel(), {position: 'bottom'}) + } + eventEmitter.on('code:format-table', this.formatTable) this.tableEditorOptions = options({ @@ -300,6 +345,8 @@ export default class CodeEditor extends React.Component { this.setState({ clientWidth: this.refs.root.clientWidth }) + + this.initialHighlighting() } expandSnippet (line, cursor, cm, snippets) { @@ -315,22 +362,28 @@ export default class CodeEditor extends React.Component { const snippetLines = snippets[i].content.split('\n') let cursorLineNumber = 0 let cursorLinePosition = 0 + + let cursorIndex for (let j = 0; j < snippetLines.length; j++) { - const cursorIndex = snippetLines[j].indexOf(templateCursorString) + cursorIndex = snippetLines[j].indexOf(templateCursorString) + if (cursorIndex !== -1) { cursorLineNumber = j cursorLinePosition = cursorIndex - cm.replaceRange( - snippets[i].content.replace(templateCursorString, ''), - wordBeforeCursor.range.from, - wordBeforeCursor.range.to - ) - cm.setCursor({ - line: cursor.line + cursorLineNumber, - ch: cursorLinePosition - }) + + 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, @@ -387,9 +440,11 @@ export default class CodeEditor extends React.Component { this.editor.off('paste', this.pasteHandler) eventEmitter.off('top:search', this.searchHandler) this.editor.off('scroll', this.scrollHandler) + this.editor.off('contextmenu', this.contextMenuHandler) const editorTheme = document.getElementById('editorTheme') editorTheme.removeEventListener('load', this.loadStyleHandler) + spellcheck.setLanguage(null, spellcheck.SPELLCHECK_DISABLED) eventEmitter.off('code:format-table', this.formatTable) } @@ -449,6 +504,14 @@ export default class CodeEditor extends React.Component { this.editor.setOption('extraKeys', this.defaultKeyMap) } + if (prevProps.hotkey !== this.props.hotkey) { + this.updateDefaultKeyMap() + + if (this.extraKeysMode === 'default') { + this.editor.setOption('extraKeys', this.defaultKeyMap) + } + } + if (this.state.clientWidth !== this.refs.root.clientWidth) { this.setState({ clientWidth: this.refs.root.clientWidth @@ -457,6 +520,16 @@ export default class CodeEditor extends React.Component { needRefresh = true } + if (prevProps.spellCheck !== this.props.spellCheck) { + if (this.props.spellCheck === false) { + spellcheck.setLanguage(this.editor, spellcheck.SPELLCHECK_DISABLED) + let elem = document.getElementById('editor-bottom-panel') + elem.parentNode.removeChild(elem) + } else { + this.editor.addPanel(this.createSpellCheckPanel(), {position: 'bottom'}) + } + } + if (needRefresh) { this.editor.refresh() } @@ -470,13 +543,98 @@ export default class CodeEditor extends React.Component { CodeMirror.autoLoadMode(this.editor, syntax.mode) } - handleChange (e) { - this.value = this.editor.getValue() + handleChange (editor, changeObject) { + spellcheck.handleChange(editor, changeObject) + + this.updateHighlight(editor, changeObject) + + this.value = editor.getValue() if (this.props.onChange) { - this.props.onChange(e) + this.props.onChange(editor) } } + incrementLines (start, linesAdded, linesRemoved, editor) { + let highlightedLines = editor.options.linesHighlighted + + const totalHighlightedLines = highlightedLines.length + + let offset = linesAdded - linesRemoved + + // Store new items to be added as we're changing the lines + let newLines = [] + + let i = totalHighlightedLines + + while (i--) { + const lineNumber = highlightedLines[i] + + // Interval that will need to be updated + // Between start and (start + offset) remove highlight + if (lineNumber >= start) { + highlightedLines.splice(highlightedLines.indexOf(lineNumber), 1) + + // Lines that need to be relocated + if (lineNumber >= (start + linesRemoved)) { + newLines.push(lineNumber + offset) + } + } + } + + // Adding relocated lines + highlightedLines.push(...newLines) + + if (this.props.onChange) { + this.props.onChange(editor) + } + } + + handleHighlight (editor, changeObject) { + const lines = editor.options.linesHighlighted + + if (!lines.includes(changeObject)) { + lines.push(changeObject) + editor.addLineClass(changeObject, 'text', 'CodeMirror-activeline-background') + } else { + lines.splice(lines.indexOf(changeObject), 1) + editor.removeLineClass(changeObject, 'text', 'CodeMirror-activeline-background') + } + if (this.props.onChange) { + this.props.onChange(editor) + } + } + + updateHighlight (editor, changeObject) { + const linesAdded = changeObject.text.length - 1 + const linesRemoved = changeObject.removed.length - 1 + + // If no lines added or removed return + if (linesAdded === 0 && linesRemoved === 0) { + return + } + + let start = changeObject.from.line + + switch (changeObject.origin) { + case '+insert", "undo': + start += 1 + break + + case 'paste': + case '+delete': + case '+input': + if (changeObject.to.ch !== 0 || changeObject.from.ch !== 0) { + start += 1 + } + break + + default: + return + } + + this.incrementLines(start, linesAdded, linesRemoved, editor) + } + moveCursorTo (row, col) {} scrollToLine (event, num) { @@ -501,6 +659,7 @@ export default class CodeEditor extends React.Component { this.value = this.props.value this.editor.setValue(this.props.value) this.editor.clearHistory() + this.restartHighlighting() this.editor.on('change', this.changeHandler) this.editor.refresh() } @@ -526,15 +685,14 @@ export default class CodeEditor extends React.Component { this.editor.replaceSelection(imageMd) } - handlePaste (editor, e) { - const clipboardData = e.clipboardData - const { storageKey, noteKey } = this.props - const dataTransferItem = clipboardData.items[0] - const pastedTxt = clipboardData.getData('text') + handlePaste (editor, forceSmartPaste) { + const { storageKey, noteKey, fetchUrlTitle, enableSmartPaste } = this.props + const isURL = str => { const matcher = /^(?:\w+:)?\/\/([^\s\.]+\.\S{2}|localhost[\:?\d]*)\S*$/ return matcher.test(str) } + const isInLinkTag = editor => { const startCursor = editor.getCursor('start') const prevChar = editor.getRange( @@ -549,30 +707,73 @@ export default class CodeEditor extends React.Component { return prevChar === '](' && nextChar === ')' } - const pastedHtml = clipboardData.getData('text/html') - if (pastedHtml !== '') { - this.handlePasteHtml(e, editor, pastedHtml) - } else if (dataTransferItem.type.match('image')) { - attachmentManagement.handlePastImageEvent( - this, - storageKey, - noteKey, - dataTransferItem - ) - } else if ( - this.props.fetchUrlTitle && - isURL(pastedTxt) && - !isInLinkTag(editor) - ) { - this.handlePasteUrl(e, editor, pastedTxt) + const isInFencedCodeBlock = editor => { + const cursor = editor.getCursor() + + let token = editor.getTokenAt(cursor) + if (token.state.fencedState) { + return true + } + + let line = line = cursor.line - 1 + while (line >= 0) { + token = editor.getTokenAt({ + ch: 3, + line + }) + + if (token.start === token.end) { + --line + } else if (token.type === 'comment') { + if (line > 0) { + token = editor.getTokenAt({ + ch: 3, + line: line - 1 + }) + + return token.type !== 'comment' + } else { + return true + } + } else { + return false + } + } + + return false } - if (attachmentManagement.isAttachmentLink(pastedTxt)) { + + const pastedTxt = clipboard.readText() + + if (isInFencedCodeBlock(editor)) { + this.handlePasteText(editor, pastedTxt) + } else if (fetchUrlTitle && isURL(pastedTxt) && !isInLinkTag(editor)) { + this.handlePasteUrl(editor, pastedTxt) + } else if (enableSmartPaste || forceSmartPaste) { + const image = clipboard.readImage() + if (!image.isEmpty()) { + attachmentManagement.handlePastNativeImage( + this, + storageKey, + noteKey, + image + ) + } else { + const pastedHtml = clipboard.readHTML() + if (pastedHtml.length > 0) { + this.handlePasteHtml(editor, pastedHtml) + } else { + this.handlePasteText(editor, pastedTxt) + } + } + } else if (attachmentManagement.isAttachmentLink(pastedTxt)) { attachmentManagement .handleAttachmentLinkPaste(storageKey, noteKey, pastedTxt) .then(modifiedText => { this.editor.replaceSelection(modifiedText) }) - e.preventDefault() + } else { + this.handlePasteText(editor, pastedTxt) } } @@ -582,8 +783,7 @@ export default class CodeEditor extends React.Component { } } - handlePasteUrl (e, editor, pastedTxt) { - e.preventDefault() + handlePasteUrl (editor, pastedTxt) { const taggedUrl = `<${pastedTxt}>` editor.replaceSelection(taggedUrl) @@ -622,12 +822,15 @@ export default class CodeEditor extends React.Component { }) } - handlePasteHtml (e, editor, pastedHtml) { - e.preventDefault() + handlePasteHtml (editor, pastedHtml) { const markdown = this.turndownService.turndown(pastedHtml) editor.replaceSelection(markdown) } + handlePasteText (editor, pastedTxt) { + editor.replaceSelection(pastedTxt) + } + mapNormalResponse (response, pastedTxt) { return this.decodeResponse(response).then(body => { return new Promise((resolve, reject) => { @@ -648,6 +851,29 @@ export default class CodeEditor extends React.Component { }) } + initialHighlighting () { + if (this.editor.options.linesHighlighted == null) { + return + } + + const totalHighlightedLines = this.editor.options.linesHighlighted.length + const totalAvailableLines = this.editor.lineCount() + + for (let i = 0; i < totalHighlightedLines; i++) { + const lineNumber = this.editor.options.linesHighlighted[i] + if (lineNumber > totalAvailableLines) { + // make sure that we skip the invalid lines althrough this case should not be happened. + continue + } + this.editor.addLineClass(lineNumber, 'text', 'CodeMirror-activeline-background') + } + } + + restartHighlighting () { + this.editor.options.linesHighlighted = this.props.linesHighlighted + this.initialHighlighting() + } + mapImageResponse (response, pastedTxt) { return new Promise((resolve, reject) => { try { @@ -710,6 +936,25 @@ export default class CodeEditor extends React.Component { /> ) } + + createSpellCheckPanel () { + const panel = document.createElement('div') + panel.className = 'panel bottom' + panel.id = 'editor-bottom-panel' + const dropdown = document.createElement('select') + dropdown.title = 'Spellcheck' + dropdown.className = styles['spellcheck-select'] + dropdown.addEventListener('change', (e) => spellcheck.setLanguage(this.editor, dropdown.value)) + const options = spellcheck.getAvailableDictionaries() + for (const op of options) { + const option = document.createElement('option') + option.value = op.value + option.innerHTML = op.label + dropdown.appendChild(option) + } + panel.appendChild(dropdown) + return panel + } } CodeEditor.propTypes = { @@ -720,7 +965,8 @@ CodeEditor.propTypes = { className: PropTypes.string, onBlur: PropTypes.func, onChange: PropTypes.func, - readOnly: PropTypes.bool + readOnly: PropTypes.bool, + spellCheck: PropTypes.bool } CodeEditor.defaultProps = { @@ -730,5 +976,6 @@ CodeEditor.defaultProps = { fontSize: 14, fontFamily: 'Monaco, Consolas', indentSize: 4, - indentType: 'space' + indentType: 'space', + spellCheck: false } diff --git a/browser/components/CodeEditor.styl b/browser/components/CodeEditor.styl new file mode 100644 index 00000000..7a254935 --- /dev/null +++ b/browser/components/CodeEditor.styl @@ -0,0 +1,6 @@ +.codeEditor-typo + text-decoration underline wavy red + +.spellcheck-select + border: none + text-decoration underline wavy red diff --git a/browser/components/MarkdownEditor.js b/browser/components/MarkdownEditor.js index 9c8a06d6..db0c4374 100644 --- a/browser/components/MarkdownEditor.js +++ b/browser/components/MarkdownEditor.js @@ -7,6 +7,7 @@ import MarkdownPreview from 'browser/components/MarkdownPreview' import eventEmitter from 'browser/main/lib/eventEmitter' import { findStorage } from 'browser/lib/findStorage' import ConfigManager from 'browser/main/lib/ConfigManager' +import attachmentManagement from 'browser/main/lib/dataApi/attachmentManagement' class MarkdownEditor extends React.Component { constructor (props) { @@ -147,8 +148,10 @@ class MarkdownEditor extends React.Component { e.preventDefault() e.stopPropagation() const idMatch = /checkbox-([0-9]+)/ - const checkedMatch = /\[x\]/i - const uncheckedMatch = /\[ \]/ + const checkedMatch = /^\s*[\+\-\*] \[x\]/i + const uncheckedMatch = /^\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 @@ -157,10 +160,10 @@ class MarkdownEditor extends React.Component { const targetLine = lines[lineIndex] if (targetLine.match(checkedMatch)) { - lines[lineIndex] = targetLine.replace(checkedMatch, '[ ]') + lines[lineIndex] = targetLine.replace(checkReplace, '[ ]') } if (targetLine.match(uncheckedMatch)) { - lines[lineIndex] = targetLine.replace(uncheckedMatch, '[x]') + lines[lineIndex] = targetLine.replace(uncheckReplace, '[x]') } this.refs.code.setValue(lines.join('\n')) } @@ -219,6 +222,28 @@ class MarkdownEditor extends React.Component { this.refs.code.editor.replaceSelection(`${mdElement}${this.refs.code.editor.getSelection()}${mdElement}`) } + handleDropImage (dropEvent) { + dropEvent.preventDefault() + const { storageKey, noteKey } = this.props + + this.setState({ + status: 'CODE' + }, () => { + this.refs.code.focus() + + this.refs.code.editor.execCommand('goDocEnd') + this.refs.code.editor.execCommand('goLineEnd') + this.refs.code.editor.execCommand('newlineAndIndent') + + attachmentManagement.handleAttachmentDrop( + this.refs.code, + storageKey, + noteKey, + dropEvent + ) + }) + } + handleKeyUp (e) { const keyPressed = this.state.keyPressed keyPressed.delete(e.keyCode) @@ -230,7 +255,7 @@ class MarkdownEditor extends React.Component { } render () { - const {className, value, config, storageKey, noteKey} = this.props + const {className, value, config, storageKey, noteKey, linesHighlighted} = this.props let editorFontSize = parseInt(config.editor.fontSize, 10) if (!(editorFontSize > 0 && editorFontSize < 101)) editorFontSize = 14 @@ -273,8 +298,13 @@ class MarkdownEditor extends React.Component { noteKey={noteKey} fetchUrlTitle={config.editor.fetchUrlTitle} enableTableEditor={config.editor.enableTableEditor} + linesHighlighted={linesHighlighted} onChange={(e) => this.handleChange(e)} onBlur={(e) => this.handleBlur(e)} + spellCheck={config.editor.spellcheck} + enableSmartPaste={config.editor.enableSmartPaste} + hotkey={config.hotkey} + switchPreview={config.editor.switchPreview} /> this.handleDropImage(e)} /> ) diff --git a/browser/components/MarkdownPreview.js b/browser/components/MarkdownPreview.js index afab9a8d..43fb5f93 100755 --- a/browser/components/MarkdownPreview.js +++ b/browser/components/MarkdownPreview.js @@ -21,6 +21,9 @@ import yaml from 'js-yaml' import context from 'browser/lib/context' import i18n from 'browser/lib/i18n' import fs from 'fs' +import { render } from 'react-dom' +import Carousel from 'react-image-carousel' +import ConfigManager from '../main/lib/ConfigManager' const { remote, shell } = require('electron') const attachmentManagement = require('../main/lib/dataApi/attachmentManagement') @@ -39,7 +42,8 @@ const appPath = fileUrl( ) const CSS_FILES = [ `${appPath}/node_modules/katex/dist/katex.min.css`, - `${appPath}/node_modules/codemirror/lib/codemirror.css` + `${appPath}/node_modules/codemirror/lib/codemirror.css`, + `${appPath}/node_modules/react-image-carousel/lib/css/main.min.css` ] function buildStyle ( @@ -207,7 +211,7 @@ export default class MarkdownPreview extends React.Component { this.printHandler = () => this.handlePrint() this.resizeHandler = _.throttle(this.handleResize.bind(this), 100) - this.linkClickHandler = this.handlelinkClick.bind(this) + this.linkClickHandler = this.handleLinkClick.bind(this) this.initMarkdown = this.initMarkdown.bind(this) this.initMarkdown() } @@ -264,6 +268,10 @@ export default class MarkdownPreview extends React.Component { } handleMouseDown (e) { + const config = ConfigManager.get() + 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': @@ -287,26 +295,7 @@ export default class MarkdownPreview extends React.Component { } handleSaveAsMd () { - this.exportAsDocument('md', (noteContent, exportTasks) => { - let result = noteContent - if (this.props && this.props.storagePath && this.props.noteKey) { - const attachmentsAbsolutePaths = attachmentManagement.getAbsolutePathsOfAttachmentsInContent( - noteContent, - this.props.storagePath - ) - attachmentsAbsolutePaths.forEach(attachment => { - exportTasks.push({ - src: attachment, - dst: attachmentManagement.DESTINATION_FOLDER - }) - }) - result = attachmentManagement.removeStorageAndNoteReferences( - noteContent, - this.props.noteKey - ) - } - return result - }) + this.exportAsDocument('md') } handleSaveAsHtml () { @@ -335,11 +324,6 @@ export default class MarkdownPreview extends React.Component { ) let body = this.markdown.render(noteContent) const files = [this.GetCodeThemeLink(codeBlockTheme), ...CSS_FILES] - const attachmentsAbsolutePaths = attachmentManagement.getAbsolutePathsOfAttachmentsInContent( - noteContent, - this.props.storagePath - ) - files.forEach(file => { if (global.process.platform === 'win32') { file = file.replace('file:///', '') @@ -351,16 +335,6 @@ export default class MarkdownPreview extends React.Component { dst: 'css' }) }) - attachmentsAbsolutePaths.forEach(attachment => { - exportTasks.push({ - src: attachment, - dst: attachmentManagement.DESTINATION_FOLDER - }) - }) - body = attachmentManagement.removeStorageAndNoteReferences( - body, - this.props.noteKey - ) let styles = '' files.forEach(file => { @@ -393,8 +367,9 @@ export default class MarkdownPreview extends React.Component { if (filename) { const content = this.props.value const storage = this.props.storagePath + const nodeKey = this.props.noteKey - exportNote(storage, content, filename, contentFormatter) + exportNote(nodeKey, storage, content, filename, contentFormatter) .then(res => { dialog.showMessageBox(remote.getCurrentWindow(), { type: 'info', @@ -439,6 +414,8 @@ export default class MarkdownPreview extends React.Component { } componentDidMount () { + const { onDrop } = this.props + this.refs.root.setAttribute('sandbox', 'allow-scripts') this.refs.root.contentWindow.document.body.addEventListener( 'contextmenu', @@ -476,7 +453,7 @@ export default class MarkdownPreview extends React.Component { ) this.refs.root.contentWindow.document.addEventListener( 'drop', - this.preventImageDroppedHandler + onDrop || this.preventImageDroppedHandler ) this.refs.root.contentWindow.document.addEventListener( 'dragover', @@ -497,6 +474,8 @@ export default class MarkdownPreview extends React.Component { } componentWillUnmount () { + const { onDrop } = this.props + this.refs.root.contentWindow.document.body.removeEventListener( 'contextmenu', this.contextMenuHandler @@ -515,7 +494,7 @@ export default class MarkdownPreview extends React.Component { ) this.refs.root.contentWindow.document.removeEventListener( 'drop', - this.preventImageDroppedHandler + onDrop || this.preventImageDroppedHandler ) this.refs.root.contentWindow.document.removeEventListener( 'dragover', @@ -804,6 +783,34 @@ export default class MarkdownPreview extends React.Component { mermaidRender(el, htmlTextHelper.decodeEntities(el.innerHTML), theme) } ) + + _.forEach( + this.refs.root.contentWindow.document.querySelectorAll('.gallery'), + el => { + const images = el.innerHTML.split(/\n/g).filter(i => i.length > 0) + el.innerHTML = '' + + const height = el.attributes.getNamedItem('data-height') + if (height && height.value !== 'undefined') { + el.style.height = height.value + 'vh' + } + + let autoplay = el.attributes.getNamedItem('data-autoplay') + if (autoplay && autoplay.value !== 'undefined') { + autoplay = parseInt(autoplay.value, 10) || 0 + } else { + autoplay = 0 + } + + render( + , + el + ) + } + ) } handleResize () { @@ -855,7 +862,7 @@ export default class MarkdownPreview extends React.Component { return new window.Notification(title, options) } - handlelinkClick (e) { + handleLinkClick (e) { e.preventDefault() e.stopPropagation() diff --git a/browser/components/MarkdownSplitEditor.js b/browser/components/MarkdownSplitEditor.js index ca2d3108..f8f8b366 100644 --- a/browser/components/MarkdownSplitEditor.js +++ b/browser/components/MarkdownSplitEditor.js @@ -24,9 +24,9 @@ class MarkdownSplitEditor extends React.Component { this.refs.code.setValue(value) } - handleOnChange () { + handleOnChange (e) { this.value = this.refs.code.value - this.props.onChange() + this.props.onChange(e) } handleScroll (e) { @@ -78,8 +78,10 @@ class MarkdownSplitEditor extends React.Component { e.preventDefault() e.stopPropagation() const idMatch = /checkbox-([0-9]+)/ - const checkedMatch = /\[x\]/i - const uncheckedMatch = /\[ \]/ + const checkedMatch = /^\s*[\+\-\*] \[x\]/i + const uncheckedMatch = /^\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 @@ -88,10 +90,10 @@ class MarkdownSplitEditor extends React.Component { const targetLine = lines[lineIndex] if (targetLine.match(checkedMatch)) { - lines[lineIndex] = targetLine.replace(checkedMatch, '[ ]') + lines[lineIndex] = targetLine.replace(checkReplace, '[ ]') } if (targetLine.match(uncheckedMatch)) { - lines[lineIndex] = targetLine.replace(uncheckedMatch, '[x]') + lines[lineIndex] = targetLine.replace(uncheckReplace, '[x]') } this.refs.code.setValue(lines.join('\n')) } @@ -134,7 +136,7 @@ class MarkdownSplitEditor extends React.Component { } render () { - const {config, value, storageKey, noteKey} = this.props + const {config, value, storageKey, noteKey, linesHighlighted} = this.props const storage = findStorage(storageKey) let editorFontSize = parseInt(config.editor.fontSize, 10) if (!(editorFontSize > 0 && editorFontSize < 101)) editorFontSize = 14 @@ -167,8 +169,13 @@ class MarkdownSplitEditor extends React.Component { enableTableEditor={config.editor.enableTableEditor} storageKey={storageKey} noteKey={noteKey} - onChange={this.handleOnChange.bind(this)} + linesHighlighted={linesHighlighted} + onChange={(e) => this.handleOnChange(e)} onScroll={this.handleScroll.bind(this)} + spellCheck={config.editor.spellcheck} + enableSmartPaste={config.editor.enableSmartPaste} + hotkey={config.hotkey} + switchPreview={config.editor.switchPreview} />
this.handleMouseDown(e)} >
diff --git a/browser/components/SnippetTab.styl b/browser/components/SnippetTab.styl index a31b8594..d101f318 100644 --- a/browser/components/SnippetTab.styl +++ b/browser/components/SnippetTab.styl @@ -3,19 +3,30 @@ flex 1 min-width 70px overflow hidden + border-left 1px solid $ui-borderColor + border-top 1px solid $ui-borderColor &:hover + background-color alpha($ui-button--active-backgroundColor, 20%) .deleteButton - color $ui-inactive-text-color - &:hover - background-color darken($ui-backgroundColor, 15%) - &:active - color white - background-color $ui-active-color + color: $ui-text-color + visibility visible + transition 0.15s + .button + color: $ui-text-color + transition 0.15s .root--active @extend .root min-width 100px - border-bottom $ui-border + background-color alpha($ui-button--active-backgroundColor, 60%) + .deleteButton + visibility visible + color: $ui-text-color + transition 0.15s + .button + font-weight bold + color: $ui-text-color + transition 0.15s .button width 100% @@ -27,8 +38,7 @@ background-color transparent transition 0.15s border-left 4px solid transparent - &:hover - background-color $ui-button--hover-backgroundColor + color $ui-inactive-text-color .deleteButton position absolute @@ -42,6 +52,7 @@ color $ui-inactive-text-color background-color transparent border-radius 2px + visibility hidden .input height 29px @@ -50,76 +61,66 @@ width 100% outline none +body[data-theme="default"], body[data-theme="white"] + .root--active + &:hover + background-color alpha($ui-button--active-backgroundColor, 60%) + body[data-theme="dark"] .root - color $ui-dark-text-color border-color $ui-dark-borderColor + border-top 1px solid $ui-dark-borderColor &:hover - background-color $ui-dark-button--hover-backgroundColor + background-color alpha($ui-dark-button--active-backgroundColor, 20%) + transition 0.15s + .button + color $ui-dark-text-color + transition 0.15s .deleteButton - color $ui-dark-inactive-text-color - &:hover - background-color darken($ui-dark-button--hover-backgroundColor, 15%) - &:active - color $ui-dark-text-color - background-color $ui-dark-button--active-backgroundColor + color $ui-dark-text-color + transition 0.15s .root--active - color $ui-dark-text-color - border-color $ui-dark-borderColor - &:hover - background-color $ui-dark-button--hover-backgroundColor - .deleteButton - color $ui-dark-inactive-text-color - &:hover - background-color darken($ui-dark-button--hover-backgroundColor, 15%) - &:active - color $ui-dark-text-color - background-color $ui-dark-button--active-backgroundColor + background-color $ui-dark-button--active-backgroundColor + border-left 1px solid $ui-dark-borderColor + border-top 1px solid $ui-dark-borderColor + .button + color $ui-dark-text-color + .deleteButton + color $ui-dark-text-color .button border none - color $ui-dark-text-color background-color transparent transition color background-color 0.15s border-left 4px solid transparent - &:hover - color $ui-dark-text-color - background-color $ui-dark-button--hover-backgroundColor .input - background-color $ui-dark-button--hover-backgroundColor + background-color $ui-dark-button--active-backgroundColor color $ui-dark-text-color - - .deleteButton - color alpha($ui-dark-text-color, 30%) + transition 0.15s body[data-theme="solarized-dark"] .root - color $ui-solarized-dark-text-color - border-color $ui-dark-borderColor - &:hover - background-color $ui-solarized-dark-noteDetail-backgroundColor - .deleteButton - color $ui-solarized-dark-text-color - &:hover - background-color darken($ui-solarized-dark-noteDetail-backgroundColor, 15%) - &:active - color $ui-solarized-dark-text-color - background-color $ui-dark-button--active-backgroundColor - - .root--active - color $ui-solarized-dark-text-color border-color $ui-solarized-dark-borderColor &:hover background-color $ui-solarized-dark-noteDetail-backgroundColor + transition 0.15s .deleteButton - color $ui-solarized-dark-text-color - &:hover - background-color darken($ui-solarized-dark-noteDetail-backgroundColor, 15%) - &:active - color $ui-solarized-dark-text-color - background-color $ui-dark-button--active-backgroundColor + color $ui-solarized-dark-button--active-color + transition 0.15s + .button + color $ui-solarized-dark-button--active-color + transition 0.15s + + .root--active + color $ui-solarized-dark-button--active-color + background-color $ui-solarized-dark-button-backgroundColor + border-color $ui-solarized-dark-borderColor + .deleteButton + color $ui-solarized-dark-button--active-color + .button + color $ui-solarized-dark-button--active-color .button border none @@ -127,101 +128,75 @@ body[data-theme="solarized-dark"] background-color transparent transition color background-color 0.15s border-left 4px solid transparent - &:hover - color $ui-solarized-dark-text-color - background-color $ui-solarized-dark-noteDetail-backgroundColor .input background-color $ui-solarized-dark-noteDetail-backgroundColor - color $ui-solarized-dark-text-color - - .deleteButton - color alpha($ui-solarized-dark-text-color, 30%) + color $ui-solarized-dark-button--active-color + transition 0.15s body[data-theme="monokai"] .root - color $ui-monokai-text-color - border-color $ui-dark-borderColor - &:hover - background-color $ui-monokai-noteDetail-backgroundColor - .deleteButton - color $ui-monokai-text-color - &:hover - background-color darken($ui-monokai-noteDetail-backgroundColor, 15%) - &:active - color $ui-monokai-text-color - background-color $ui-dark-button--active-backgroundColor - - .root--active - color $ui-monokai-text-color border-color $ui-monokai-borderColor &:hover background-color $ui-monokai-noteDetail-backgroundColor + transition 0.15s .deleteButton color $ui-monokai-text-color - &:hover - background-color darken($ui-monokai-noteDetail-backgroundColor, 15%) - &:active - color $ui-monokai-text-color - background-color $ui-dark-button--active-backgroundColor + transition 0.15s + .button + color $ui-monokai-text-color + transition 0.15s + .root--active + color $ui-monokai-active-color + background-color $ui-monokai-button-backgroundColor + border-color $ui-monokai-borderColor + .deleteButton + color $ui-monokai-text-color + .button + color $ui-monokai-active-color + .button border none - color $ui-monokai-text-color + color $ui-inactive-text-color background-color transparent transition color background-color 0.15s border-left 4px solid transparent - &:hover - color $ui-monokai-text-color - background-color $ui-monokai-noteDetail-backgroundColor .input background-color $ui-monokai-noteDetail-backgroundColor color $ui-monokai-text-color - - .deleteButton - color alpha($ui-monokai-text-color, 30%) + transition 0.15s body[data-theme="dracula"] .root - color $ui-dracula-text-color - border-color $ui-dark-borderColor - &:hover - background-color $ui-dracula-noteDetail-backgroundColor - .deleteButton - color $ui-dracula-text-color - &:hover - background-color darken($ui-dracula-noteDetail-backgroundColor, 15%) - &:active - color $ui-dracula-text-color - background-color $ui-dark-button--active-backgroundColor - - .root--active - color $ui-dracula-text-color border-color $ui-dracula-borderColor &:hover background-color $ui-dracula-noteDetail-backgroundColor + transition 0.15s .deleteButton color $ui-dracula-text-color - &:hover - background-color darken($ui-dracula-noteDetail-backgroundColor, 15%) - &:active - color $ui-dracula-text-color - background-color $ui-dark-button--active-backgroundColor + transition 0.15s + .button + color $ui-dracula-text-color + transition 0.15s + + .root--active + color $ui-dracula-text-color + background-color $ui-dracula-button-backgroundColor + border-color $ui-dracula-borderColor + .deleteButton + color $ui-dracula-text-color + .button + color $ui-dracula-active-color .button border none - color $ui-dracula-text-color + color $ui-inactive-text-color background-color transparent transition color background-color 0.15s border-left 4px solid transparent - &:hover - color $ui-dracula-text-color - background-color $ui-dracula-noteDetail-backgroundColor .input background-color $ui-dracula-noteDetail-backgroundColor - color $ui-dracula-text-color - - .deleteButton - color alpha($ui-dracula-text-color, 30%) \ No newline at end of file + color $ui-dracula-text-color \ No newline at end of file diff --git a/browser/components/markdown.styl b/browser/components/markdown.styl index 7b8911d7..eea688aa 100644 --- a/browser/components/markdown.styl +++ b/browser/components/markdown.styl @@ -55,11 +55,12 @@ body line-height 1.6 overflow-x hidden background-color $ui-noteDetail-backgroundColor + // do not allow display line breaks + .katex-display > .katex + white-space nowrap + // allow inline line breaks .katex - font 400 1.2em 'KaTeX_Main' - line-height 1.2em white-space initial - text-indent 0 .katex .mfrac>.vlist>span:nth-child(2) top 0 !important .katex-error @@ -183,6 +184,10 @@ ul display list-item &.taskListItem list-style none + &>input + margin-left -1.6em + &>p + margin-left -1.8em p margin 0 &>li>ul, &>li>ol @@ -419,6 +424,26 @@ pre.fence svg[ratio] width 100% + .gallery + width 100% + height 50vh + + .carousel + .carousel-main img + min-width auto + max-width 100% + min-height auto + max-height 100% + + .carousel-footer::-webkit-scrollbar-corner + background-color transparent + + .carousel-main, .carousel-footer + background-color $ui-noteDetail-backgroundColor + .prev, .next + color $ui-text-color + background-color $ui-tag-backgroundColor + themeDarkBackground = darken(#21252B, 10%) themeDarkText = #f9f9f9 themeDarkBorder = lighten(themeDarkBackground, 20%) @@ -478,6 +503,14 @@ body[data-theme="dark"] border-color themeDarkBorder background-color themeDarkPreview + pre.fence + .gallery + .carousel-main, .carousel-footer + background-color $ui-dark-noteDetail-backgroundColor + .prev, .next + color $ui-dark-text-color + background-color $ui-dark-tag-backgroundColor + themeSolarizedDarkTableOdd = $ui-solarized-dark-noteDetail-backgroundColor themeSolarizedDarkTableEven = darken($ui-solarized-dark-noteDetail-backgroundColor, 10%) themeSolarizedDarkTableHead = themeSolarizedDarkTableEven @@ -513,6 +546,14 @@ body[data-theme="solarized-dark"] border-color themeDarkBorder background-color $ui-solarized-dark-noteDetail-backgroundColor + pre.fence + .gallery + .carousel-main, .carousel-footer + background-color $ui-solarized-dark-noteDetail-backgroundColor + .prev, .next + color $ui-solarized-dark-button--active-color + background-color $ui-solarized-dark-button-backgroundColor + themeMonokaiTableOdd = $ui-monokai-noteDetail-backgroundColor themeMonokaiTableEven = darken($ui-monokai-noteDetail-backgroundColor, 10%) themeMonokaiTableHead = themeMonokaiTableEven @@ -541,6 +582,7 @@ body[data-theme="monokai"] border-right solid 1px themeMonokaiTableBorder kbd background-color themeDarkBackground + dl border-color themeDarkBorder background-color themeMonokaiTableHead @@ -550,6 +592,14 @@ body[data-theme="monokai"] border-color themeDarkBorder background-color $ui-monokai-noteDetail-backgroundColor + pre.fence + .gallery + .carousel-main, .carousel-footer + background-color $ui-monokai-noteDetail-backgroundColor + .prev, .next + color $ui-monokai-button--active-color + background-color $ui-monokai-button-backgroundColor + themeDraculaTableOdd = $ui-dracula-noteDetail-backgroundColor themeDraculaTableEven = darken($ui-dracula-noteDetail-backgroundColor, 10%) themeDraculaTableHead = themeDraculaTableEven @@ -578,6 +628,7 @@ body[data-theme="dracula"] border-right solid 1px themeDraculaTableBorder kbd background-color themeDarkBackground + dl border-color themeDarkBorder background-color themeDraculaTableHead @@ -586,3 +637,11 @@ body[data-theme="dracula"] dd border-color themeDarkBorder background-color $ui-dracula-noteDetail-backgroundColor + + pre.fence + .gallery + .carousel-main, .carousel-footer + background-color $ui-dracula-noteDetail-backgroundColor + .prev, .next + color $ui-dracula-button--active-color + background-color $ui-dracula-button-backgroundColor diff --git a/browser/components/render/MermaidRender.js b/browser/components/render/MermaidRender.js index c2380504..03c6eda6 100644 --- a/browser/components/render/MermaidRender.js +++ b/browser/components/render/MermaidRender.js @@ -26,7 +26,7 @@ function render (element, content, theme) { if (isPredefined) { element.style.height = height.value + 'vh' } - let isDarkTheme = theme === 'dark' || theme === 'solarized-dark' || theme === 'monokai' || theme === 'dracula' + const isDarkTheme = theme === 'dark' || theme === 'solarized-dark' || theme === 'monokai' || theme === 'dracula' mermaidAPI.initialize({ theme: isDarkTheme ? 'dark' : 'default', themeCSS: isDarkTheme ? darkThemeStyling : '', diff --git a/browser/lib/Languages.js b/browser/lib/Languages.js index 1a798fdf..8c3747a9 100644 --- a/browser/lib/Languages.js +++ b/browser/lib/Languages.js @@ -48,9 +48,13 @@ const languages = [ locale: 'pl' }, { - name: 'Portuguese', + name: 'Portuguese (PT-BR)', locale: 'pt-BR' }, + { + name: 'Portuguese (PT-PT)', + locale: 'pt-PT' + }, { name: 'Russian', locale: 'ru' diff --git a/browser/lib/contextMenuBuilder.js b/browser/lib/contextMenuBuilder.js new file mode 100644 index 00000000..cf92f52e --- /dev/null +++ b/browser/lib/contextMenuBuilder.js @@ -0,0 +1,65 @@ +const {remote} = require('electron') +const {Menu} = remote.require('electron') +const spellcheck = require('./spellcheck') + +/** + * Creates the context menu that is shown when there is a right click in the editor of a (not-snippet) note. + * If the word is does not contains a spelling error (determined by the 'error style'), no suggestions for corrections are requested + * => they are not visible in the context menu + * @param editor CodeMirror editor + * @param {MouseEvent} event that has triggered the creation of the context menu + * @returns {Electron.Menu} The created electron context menu + */ +const buildEditorContextMenu = function (editor, event) { + if (editor == null || event == null || event.pageX == null || event.pageY == null) { + return null + } + const cursor = editor.coordsChar({left: event.pageX, top: event.pageY}) + const wordRange = editor.findWordAt(cursor) + const word = editor.getRange(wordRange.anchor, wordRange.head) + const existingMarks = editor.findMarks(wordRange.anchor, wordRange.head) || [] + let isMisspelled = false + for (const mark of existingMarks) { + if (mark.className === spellcheck.getCSSClassName()) { + isMisspelled = true + break + } + } + let suggestion = [] + if (isMisspelled) { + suggestion = spellcheck.getSpellingSuggestion(word) + } + + const selection = { + isMisspelled: isMisspelled, + spellingSuggestions: suggestion + } + const template = [{ + role: 'cut' + }, { + role: 'copy' + }, { + role: 'paste' + }, { + role: 'selectall' + }] + + if (selection.isMisspelled) { + const suggestions = selection.spellingSuggestions + template.unshift.apply(template, suggestions.map(function (suggestion) { + return { + label: suggestion, + click: function (suggestion) { + if (editor != null) { + editor.replaceRange(suggestion.label, wordRange.anchor, wordRange.head) + } + } + } + }).concat({ + type: 'separator' + })) + } + return Menu.buildFromTemplate(template) +} + +module.exports = buildEditorContextMenu diff --git a/browser/lib/markdown-it-fence.js b/browser/lib/markdown-it-fence.js index fd1c759d..f2f7e999 100644 --- a/browser/lib/markdown-it-fence.js +++ b/browser/lib/markdown-it-fence.js @@ -3,7 +3,7 @@ module.exports = function (md, renderers, defaultRenderer) { const paramsRE = /^[ \t]*([\w+#-]+)?(?:\(((?:\s*\w[-\w]*(?:=(?:'(?:.*?[^\\])?'|"(?:.*?[^\\])?"|(?:[^'"][^\s]*)))?)*)\))?(?::([^:]*)(?::(\d+))?)?\s*$/ - function fence (state, startLine, endLine) { + function fence (state, startLine, endLine, silent) { let pos = state.bMarks[startLine] + state.tShift[startLine] let max = state.eMarks[startLine] @@ -12,7 +12,7 @@ module.exports = function (md, renderers, defaultRenderer) { } const marker = state.src.charCodeAt(pos) - if (!(marker === 96 || marker === 126)) { + if (marker !== 0x7E/* ~ */ && marker !== 0x60 /* ` */) { return false } @@ -27,6 +27,10 @@ module.exports = function (md, renderers, defaultRenderer) { const markup = state.src.slice(mem, pos) const params = state.src.slice(pos, max) + if (silent) { + return true + } + let nextLine = startLine let haveEndMarker = false diff --git a/browser/lib/markdown-toc-generator.js b/browser/lib/markdown-toc-generator.js index 716be83a..af1c833f 100644 --- a/browser/lib/markdown-toc-generator.js +++ b/browser/lib/markdown-toc-generator.js @@ -2,44 +2,27 @@ * @fileoverview Markdown table of contents generator */ +import { EOL } from 'os' import toc from 'markdown-toc' -import diacritics from 'diacritics-map' -import stripColor from 'strip-color' +import mdlink from 'markdown-link' +import slugify from './slugify' -const EOL = require('os').EOL +const hasProp = Object.prototype.hasOwnProperty /** - * @caseSensitiveSlugify Custom slugify function - * Same implementation that the original used by markdown-toc (node_modules/markdown-toc/lib/utils.js), - * but keeps original case to properly handle https://github.com/BoostIO/Boostnote/issues/2067 + * From @enyaxu/markdown-it-anchor */ -function caseSensitiveSlugify (str) { - function replaceDiacritics (str) { - return str.replace(/[À-ž]/g, function (ch) { - return diacritics[ch] || ch - }) - } +function uniqueSlug (slug, slugs, opts) { + let uniq = slug + let i = opts.uniqueSlugStartIndex + while (hasProp.call(slugs, uniq)) uniq = `${slug}-${i++}` + slugs[uniq] = true + return uniq +} - function getTitle (str) { - if (/^\[[^\]]+\]\(/.test(str)) { - var m = /^\[([^\]]+)\]/.exec(str) - if (m) return m[1] - } - return str - } - - str = getTitle(str) - str = stripColor(str) - // str = str.toLowerCase() //let's be case sensitive - - // `.split()` is often (but not always) faster than `.replace()` - str = str.split(' ').join('-') - str = str.split(/\t/).join('--') - str = str.split(/<\/?[^>]+>/).join('') - str = str.split(/[|$&`~=\\\/@+*!?({[\]})<>=.,;:'"^]/).join('') - str = str.split(/[。?!,、;:“”【】()〔〕[]﹃﹄“ ”‘’﹁﹂—…-~《》〈〉「」]/).join('') - str = replaceDiacritics(str) - return str +function linkify (token) { + token.content = mdlink(token.content, '#' + token.slug) + return token } const TOC_MARKER_START = '' @@ -84,8 +67,23 @@ export function generateInEditor (editor) { * @returns generatedTOC String containing generated TOC */ export function generate (markdownText) { - const generatedToc = toc(markdownText, {slugify: caseSensitiveSlugify}) - return TOC_MARKER_START + EOL + EOL + generatedToc.content + EOL + EOL + TOC_MARKER_END + const slugs = {} + const opts = { + uniqueSlugStartIndex: 1 + } + + const result = toc(markdownText, { + slugify: title => { + return uniqueSlug(slugify(title), slugs, opts) + }, + linkify: false + }) + + const md = toc.bullets(result.json.map(linkify), { + highest: result.highest + }) + + return TOC_MARKER_START + EOL + EOL + md + EOL + EOL + TOC_MARKER_END } function wrapTocWithEol (toc, editor) { diff --git a/browser/lib/markdown.js b/browser/lib/markdown.js index 13ef758a..0ea15ba9 100644 --- a/browser/lib/markdown.js +++ b/browser/lib/markdown.js @@ -7,7 +7,6 @@ import _ from 'lodash' import ConfigManager from 'browser/main/lib/ConfigManager' import katex from 'katex' import { lastFindInArray } from './utils' -import ee from 'browser/main/lib/eventEmitter' function createGutter (str, firstLineNumber) { if (Number.isNaN(firstLineNumber)) firstLineNumber = 1 @@ -118,13 +117,8 @@ class Markdown { this.md.use(require('markdown-it-imsize')) this.md.use(require('markdown-it-footnote')) this.md.use(require('markdown-it-multimd-table')) - this.md.use(require('markdown-it-named-headers'), { - slugify: (header) => { - return encodeURI(header.trim() - .replace(/[\]\[\!\"\#\$\%\&\'\(\)\*\+\,\.\/\:\;\<\=\>\?\@\\\^\_\{\|\}\~]/g, '') - .replace(/\s+/g, '-')) - .replace(/\-+$/, '') - } + this.md.use(require('@enyaxu/markdown-it-anchor'), { + slugify: require('./slugify') }) this.md.use(require('markdown-it-kbd')) this.md.use(require('markdown-it-admonition'), {types: ['note', 'hint', 'attention', 'caution', 'danger', 'error']}) @@ -151,6 +145,21 @@ class Markdown {
${token.content}
` }, + gallery: token => { + const content = token.content.split('\n').slice(0, -1).map(line => { + const match = /!\[[^\]]*]\(([^\)]*)\)/.exec(line) + if (match) { + return match[1] + } else { + return line + } + }).join('\n') + + return `
+          ${token.fileName}
+          
+        
` + }, mermaid: token => { return `
           ${token.fileName}
diff --git a/browser/lib/newNote.js b/browser/lib/newNote.js
index bed69735..9511f847 100644
--- a/browser/lib/newNote.js
+++ b/browser/lib/newNote.js
@@ -3,15 +3,23 @@ import dataApi from 'browser/main/lib/dataApi'
 import ee from 'browser/main/lib/eventEmitter'
 import AwsMobileAnalyticsConfig from 'browser/main/lib/AwsMobileAnalyticsConfig'
 
-export function createMarkdownNote (storage, folder, dispatch, location) {
+export function createMarkdownNote (storage, folder, dispatch, location, params, config) {
   AwsMobileAnalyticsConfig.recordDynamicCustomEvent('ADD_MARKDOWN')
   AwsMobileAnalyticsConfig.recordDynamicCustomEvent('ADD_ALLNOTE')
+
+  let tags = []
+  if (config.ui.tagNewNoteWithFilteringTags && location.pathname.match(/\/tags/)) {
+    tags = params.tagname.split(' ')
+  }
+
   return dataApi
     .createNote(storage, {
       type: 'MARKDOWN_NOTE',
       folder: folder,
       title: '',
-      content: ''
+      tags,
+      content: '',
+      linesHighlighted: []
     })
     .then(note => {
       const noteHash = note.key
@@ -29,20 +37,28 @@ export function createMarkdownNote (storage, folder, dispatch, location) {
     })
 }
 
-export function createSnippetNote (storage, folder, dispatch, location, config) {
+export function createSnippetNote (storage, folder, dispatch, location, params, config) {
   AwsMobileAnalyticsConfig.recordDynamicCustomEvent('ADD_SNIPPET')
   AwsMobileAnalyticsConfig.recordDynamicCustomEvent('ADD_ALLNOTE')
+
+  let tags = []
+  if (config.ui.tagNewNoteWithFilteringTags && location.pathname.match(/\/tags/)) {
+    tags = params.tagname.split(' ')
+  }
+
   return dataApi
     .createNote(storage, {
       type: 'SNIPPET_NOTE',
       folder: folder,
       title: '',
+      tags,
       description: '',
       snippets: [
         {
           name: '',
           mode: config.editor.snippetDefaultLanguage || 'text',
-          content: ''
+          content: '',
+          linesHighlighted: []
         }
       ]
     })
diff --git a/browser/lib/slugify.js b/browser/lib/slugify.js
new file mode 100644
index 00000000..a3447a90
--- /dev/null
+++ b/browser/lib/slugify.js
@@ -0,0 +1,17 @@
+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()
+
+  slug = replaceDiacritics(slug)
+
+  slug = slug.replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')
+
+  return encodeURI(slug).replace(/\-+$/, '')
+}
diff --git a/browser/lib/spellcheck.js b/browser/lib/spellcheck.js
new file mode 100644
index 00000000..dd04e575
--- /dev/null
+++ b/browser/lib/spellcheck.js
@@ -0,0 +1,232 @@
+import styles from '../components/CodeEditor.styl'
+import i18n from 'browser/lib/i18n'
+
+const Typo = require('typo-js')
+const _ = require('lodash')
+
+const CSS_ERROR_CLASS = 'codeEditor-typo'
+const SPELLCHECK_DISABLED = 'NONE'
+const DICTIONARY_PATH = '../dictionaries'
+const MILLISECONDS_TILL_LIVECHECK = 500
+
+let dictionary = null
+let self
+
+function getAvailableDictionaries () {
+  return [
+    {label: i18n.__('Disabled'), value: SPELLCHECK_DISABLED},
+    {label: i18n.__('English'), value: 'en_GB'},
+    {label: i18n.__('German'), value: 'de_DE'},
+    {label: i18n.__('French'), value: 'fr_FR'}
+  ]
+}
+
+/**
+ * Only to be used in the tests :)
+ */
+function setDictionaryForTestsOnly (newDictionary) {
+  dictionary = newDictionary
+}
+
+/**
+ * @description Initializes the spellcheck. It removes all existing marks of the current editor.
+ * If a language was given (i.e. lang !== this.SPELLCHECK_DISABLED) it will load the stated dictionary and use it to check the whole document.
+ * @param {Codemirror} editor CodeMirror-Editor
+ * @param {String} lang on of the values from getAvailableDictionaries()-Method
+ */
+function setLanguage (editor, lang) {
+  self = this
+  dictionary = null
+
+  if (editor == null) {
+    return
+  }
+
+  const existingMarks = editor.getAllMarks() || []
+  for (const mark of existingMarks) {
+    mark.clear()
+  }
+  if (lang !== SPELLCHECK_DISABLED) {
+    dictionary = new Typo(lang, false, false, {
+      dictionaryPath: DICTIONARY_PATH,
+      asyncLoad: true,
+      loadedCallback: () =>
+        checkWholeDocument(editor)
+    })
+  }
+}
+
+/**
+ * Checks the whole content of the editor for typos
+ * @param {Codemirror} editor CodeMirror-Editor
+ */
+function checkWholeDocument (editor) {
+  const lastLine = editor.lineCount() - 1
+  const textOfLastLine = editor.getLine(lastLine) || ''
+  const lastChar = textOfLastLine.length
+  const from = {line: 0, ch: 0}
+  const to = {line: lastLine, ch: lastChar}
+  checkMultiLineRange(editor, from, to)
+}
+
+/**
+ * Checks the given range for typos
+ * @param {Codemirror} editor CodeMirror-Editor
+ * @param {line, ch} from starting position of the spellcheck
+ * @param {line, ch} to end position of the spellcheck
+ */
+function checkMultiLineRange (editor, from, to) {
+  function sortRange (pos1, pos2) {
+    if (pos1.line > pos2.line || (pos1.line === pos2.line && pos1.ch > pos2.ch)) {
+      return {from: pos2, to: pos1}
+    }
+    return {from: pos1, to: pos2}
+  }
+
+  const {from: smallerPos, to: higherPos} = sortRange(from, to)
+  for (let l = smallerPos.line; l <= higherPos.line; l++) {
+    const line = editor.getLine(l) || ''
+    let w = 0
+    if (l === smallerPos.line) {
+      w = smallerPos.ch
+    }
+    let wEnd = line.length
+    if (l === higherPos.line) {
+      wEnd = higherPos.ch
+    }
+    while (w <= wEnd) {
+      const wordRange = editor.findWordAt({line: l, ch: w})
+      self.checkWord(editor, wordRange)
+      w += (wordRange.head.ch - wordRange.anchor.ch) + 1
+    }
+  }
+}
+
+/**
+ * @description Checks whether a certain range of characters in the editor (i.e. a word) contains a typo.
+ * If so the ranged will be marked with the class CSS_ERROR_CLASS.
+ * Note: Due to performance considerations, only words with more then 3 signs are checked.
+ * @param {Codemirror} editor CodeMirror-Editor
+ * @param wordRange Object specifying the range that should be checked.
+ * Having the following structure: {anchor: {line: integer, ch: integer}, head: {line: integer, ch: integer}}
+ */
+function checkWord (editor, wordRange) {
+  const word = editor.getRange(wordRange.anchor, wordRange.head)
+  if (word == null || word.length <= 3) {
+    return
+  }
+  if (!dictionary.check(word)) {
+    editor.markText(wordRange.anchor, wordRange.head, {className: styles[CSS_ERROR_CLASS]})
+  }
+}
+
+/**
+ * Checks the changes recently made (aka live check)
+ * @param {Codemirror} editor CodeMirror-Editor
+ * @param fromChangeObject codeMirror changeObject describing the start of the editing
+ * @param toChangeObject codeMirror changeObject describing the end of the editing
+ */
+function checkChangeRange (editor, fromChangeObject, toChangeObject) {
+  /**
+   * Calculate the smallest respectively largest position as a start, resp. end, position and return it
+   * @param start CodeMirror change object
+   * @param end CodeMirror change object
+   * @returns {{start: {line: *, ch: *}, end: {line: *, ch: *}}}
+   */
+  function getStartAndEnd (start, end) {
+    const possiblePositions = [start.from, start.to, end.from, end.to]
+    let smallest = start.from
+    let biggest = end.to
+    for (const currentPos of possiblePositions) {
+      if (currentPos.line < smallest.line || (currentPos.line === smallest.line && currentPos.ch < smallest.ch)) {
+        smallest = currentPos
+      }
+      if (currentPos.line > biggest.line || (currentPos.line === biggest.line && currentPos.ch > biggest.ch)) {
+        biggest = currentPos
+      }
+    }
+    return {start: smallest, end: biggest}
+  }
+
+  if (dictionary === null || editor == null) { return }
+
+  try {
+    const {start, end} = getStartAndEnd(fromChangeObject, toChangeObject)
+
+    // Expand the range to include words after/before whitespaces
+    start.ch = Math.max(start.ch - 1, 0)
+    end.ch = end.ch + 1
+
+    // clean existing marks
+    const existingMarks = editor.findMarks(start, end) || []
+    for (const mark of existingMarks) {
+      mark.clear()
+    }
+
+    self.checkMultiLineRange(editor, start, end)
+  } catch (e) {
+    console.info('Error during the spell check. It might be due to problems figuring out the range of the new text..', e)
+  }
+}
+
+function saveLiveSpellCheckFrom (changeObject) {
+  liveSpellCheckFrom = changeObject
+}
+let liveSpellCheckFrom
+const debouncedSpellCheckLeading = _.debounce(saveLiveSpellCheckFrom, MILLISECONDS_TILL_LIVECHECK, {
+  'leading': true,
+  'trailing': false
+})
+const debouncedSpellCheck = _.debounce(checkChangeRange, MILLISECONDS_TILL_LIVECHECK, {
+  'leading': false,
+  'trailing': true
+})
+
+/**
+ * Handles a keystroke. Buffers the input and performs a live spell check after a certain time. Uses _debounce from lodash to buffer the input
+ * @param {Codemirror} editor CodeMirror-Editor
+ * @param changeObject codeMirror changeObject
+ */
+function handleChange (editor, changeObject) {
+  if (dictionary === null) {
+    return
+  }
+  debouncedSpellCheckLeading(changeObject)
+  debouncedSpellCheck(editor, liveSpellCheckFrom, changeObject)
+}
+
+/**
+ * Returns an array of spelling suggestions for the given (wrong written) word.
+ * Returns an empty array if the dictionary is null (=> spellcheck is disabled) or the given word was null
+ * @param word word to be checked
+ * @returns {String[]} Array of suggestions
+ */
+function getSpellingSuggestion (word) {
+  if (dictionary == null || word == null) {
+    return []
+  }
+  return dictionary.suggest(word)
+}
+
+/**
+ * Returns the name of the CSS class used for errors
+ */
+function getCSSClassName () {
+  return styles[CSS_ERROR_CLASS]
+}
+
+module.exports = {
+  DICTIONARY_PATH,
+  CSS_ERROR_CLASS,
+  SPELLCHECK_DISABLED,
+  getAvailableDictionaries,
+  setLanguage,
+  checkChangeRange,
+  handleChange,
+  getSpellingSuggestion,
+  checkWord,
+  checkMultiLineRange,
+  checkWholeDocument,
+  setDictionaryForTestsOnly,
+  getCSSClassName
+}
diff --git a/browser/main/Detail/InfoPanel.js b/browser/main/Detail/InfoPanel.js
index 4ce610fa..15535186 100644
--- a/browser/main/Detail/InfoPanel.js
+++ b/browser/main/Detail/InfoPanel.js
@@ -70,22 +70,22 @@ class InfoPanel extends React.Component {
         
- - - - diff --git a/browser/main/Detail/InfoPanelTrashed.js b/browser/main/Detail/InfoPanelTrashed.js index db64a284..d4c8045d 100644 --- a/browser/main/Detail/InfoPanelTrashed.js +++ b/browser/main/Detail/InfoPanelTrashed.js @@ -31,17 +31,17 @@ const InfoPanelTrashed = ({
- - - diff --git a/browser/main/Detail/MarkdownNoteDetail.js b/browser/main/Detail/MarkdownNoteDetail.js index 116fdec0..bc6cd499 100755 --- a/browser/main/Detail/MarkdownNoteDetail.js +++ b/browser/main/Detail/MarkdownNoteDetail.js @@ -39,12 +39,14 @@ class MarkdownNoteDetail extends React.Component { isMovingNote: false, note: Object.assign({ title: '', - content: '' + content: '', + linesHighlighted: [] }, props.note), isLockButtonShown: false, isLocked: false, editorType: props.config.editor.type } + this.dispatchTimer = null this.toggleLockButton = this.handleToggleLockButton.bind(this) @@ -71,7 +73,7 @@ class MarkdownNoteDetail extends React.Component { if (!this.state.isMovingNote && (isNewNote || hasDeletedTags)) { if (this.saveQueue != null) this.saveNow() this.setState({ - note: Object.assign({}, nextProps.note) + note: Object.assign({linesHighlighted: []}, nextProps.note) }, () => { this.refs.content.reload() if (this.refs.tags) this.refs.tags.reset() @@ -190,6 +192,36 @@ class MarkdownNoteDetail extends React.Component { ee.emit('export:save-html') } + handleKeyDown (e) { + switch (e.keyCode) { + // tab key + case 9: + if (e.ctrlKey && !e.shiftKey) { + e.preventDefault() + this.jumpNextTab() + } else if (e.ctrlKey && e.shiftKey) { + e.preventDefault() + this.jumpPrevTab() + } else if (!e.ctrlKey && !e.shiftKey && e.target === this.refs.description) { + e.preventDefault() + this.focusEditor() + } + break + // I key + case 73: + { + const isSuper = global.process.platform === 'darwin' + ? e.metaKey + : e.ctrlKey + if (isSuper) { + e.preventDefault() + this.handleInfoButtonClick(e) + } + } + break + } + } + handleTrashButtonClick (e) { const { note } = this.state const { isTrashed } = note @@ -331,6 +363,7 @@ class MarkdownNoteDetail extends React.Component { value={note.content} storageKey={note.storage} noteKey={note.key} + linesHighlighted={note.linesHighlighted} onChange={this.handleUpdateContent.bind(this)} ignorePreviewPointerEvents={ignorePreviewPointerEvents} /> @@ -341,6 +374,7 @@ class MarkdownNoteDetail extends React.Component { value={note.content} storageKey={note.storage} noteKey={note.key} + linesHighlighted={note.linesHighlighted} onChange={this.handleUpdateContent.bind(this)} ignorePreviewPointerEvents={ignorePreviewPointerEvents} /> @@ -458,6 +492,7 @@ class MarkdownNoteDetail extends React.Component {
this.handleKeyDown(e)} > {location.pathname === '/trashed' ? trashTopBar : detailTopBar} diff --git a/browser/main/Detail/SnippetNoteDetail.js b/browser/main/Detail/SnippetNoteDetail.js index afd81102..ebe61ba9 100644 --- a/browser/main/Detail/SnippetNoteDetail.js +++ b/browser/main/Detail/SnippetNoteDetail.js @@ -48,7 +48,7 @@ class SnippetNoteDetail extends React.Component { note: Object.assign({ description: '' }, props.note, { - snippets: props.note.snippets.map((snippet) => Object.assign({}, snippet)) + snippets: props.note.snippets.map((snippet) => Object.assign({linesHighlighted: []}, snippet)) }) } @@ -76,8 +76,9 @@ class SnippetNoteDetail extends React.Component { const nextNote = Object.assign({ description: '' }, nextProps.note, { - snippets: nextProps.note.snippets.map((snippet) => Object.assign({}, snippet)) + snippets: nextProps.note.snippets.map((snippet) => Object.assign({linesHighlighted: []}, snippet)) }) + this.setState({ snippetIndex: 0, note: nextNote @@ -410,6 +411,8 @@ class SnippetNoteDetail extends React.Component { return (e) => { const snippets = this.state.note.snippets.slice() snippets[index].content = this.refs['code-' + index].value + snippets[index].linesHighlighted = e.options.linesHighlighted + this.setState(state => ({note: Object.assign(state.note, {snippets: snippets})})) this.setState(state => ({ note: state.note @@ -434,6 +437,18 @@ class SnippetNoteDetail extends React.Component { this.focusEditor() } break + // I key + case 73: + { + const isSuper = global.process.platform === 'darwin' + ? e.metaKey + : e.ctrlKey + if (isSuper) { + e.preventDefault() + this.handleInfoButtonClick(e) + } + } + break // L key case 76: { @@ -590,7 +605,8 @@ class SnippetNoteDetail extends React.Component { note.snippets = note.snippets.concat([{ name: '', mode: config.editor.snippetDefaultLanguage || 'text', - content: '' + content: '', + linesHighlighted: [] }]) const snippetIndex = note.snippets.length - 1 @@ -633,11 +649,18 @@ class SnippetNoteDetail extends React.Component { if (infoPanel.style) infoPanel.style.display = infoPanel.style.display === 'none' ? 'inline' : 'none' } - showWarning () { + showWarning (e, msg) { + const warningMessage = (msg) => ({ + 'export-txt': 'Text export', + 'export-md': 'Markdown export', + 'export-html': 'HTML export', + 'print': 'Print' + })[msg] + dialog.showMessageBox(remote.getCurrentWindow(), { type: 'warning', message: i18n.__('Sorry!'), - detail: i18n.__('md/text import is available only a markdown note.'), + detail: i18n.__(warningMessage(msg) + ' is available only in markdown notes.'), buttons: [i18n.__('OK')] }) } @@ -673,10 +696,8 @@ class SnippetNoteDetail extends React.Component { const viewList = note.snippets.map((snippet, index) => { const isActive = this.state.snippetIndex === index - let syntax = CodeMirror.findModeByName(convertModeName(snippet.mode)) if (syntax == null) syntax = CodeMirror.findModeByName('Plain Text') - return
this.handleCodeChange(index)(e)} ref={'code-' + index} ignorePreviewPointerEvents={this.props.ignorePreviewPointerEvents} @@ -693,6 +715,7 @@ class SnippetNoteDetail extends React.Component { : this.handleCodeChange(index)(e)} ref={'code-' + index} + enableSmartPaste={config.editor.enableSmartPaste} + hotkey={config.hotkey} /> }
@@ -788,7 +813,9 @@ class SnippetNoteDetail extends React.Component { createdAt={formatDate(note.createdAt)} exportAsMd={this.showWarning} exportAsTxt={this.showWarning} + exportAsHtml={this.showWarning} type={note.type} + print={this.showWarning} />
diff --git a/browser/main/Detail/SnippetNoteDetail.styl b/browser/main/Detail/SnippetNoteDetail.styl index e3bb31c6..1af93645 100644 --- a/browser/main/Detail/SnippetNoteDetail.styl +++ b/browser/main/Detail/SnippetNoteDetail.styl @@ -31,7 +31,7 @@ .tabList absolute left right - top 55px + top 70px height 30px display flex background-color $ui-noteDetail-backgroundColor @@ -57,6 +57,9 @@ .tabList .tabButton navWhiteButtonColor() width 30px + border-left 1px solid $ui-borderColor + border-top 1px solid $ui-borderColor + border-right 1px solid $ui-borderColor .tabView absolute left right bottom @@ -98,17 +101,34 @@ opacity 0 transition 0.1s -body[data-theme="white"] +body[data-theme="white"], body[data-theme="default"] .root box-shadow $note-detail-box-shadow border none + .tabButton + &:hover + background-color alpha($ui-button--active-backgroundColor, 20%) + color $ui-text-color + transition 0.15s + body[data-theme="dark"] .root border-left 1px solid $ui-dark-borderColor background-color $ui-dark-noteDetail-backgroundColor box-shadow none + .tabList .tabButton + border-color $ui-dark-borderColor + &:hover + background-color alpha($ui-dark-button--active-backgroundColor, 20%) + + .tabButton + &:hover + background-color alpha($ui-dark-button--active-backgroundColor, 20%) + color $ui-dark-text-color + transition 0.15s + .body background-color $ui-dark-noteDetail-backgroundColor @@ -118,7 +138,6 @@ body[data-theme="dark"] border 1px solid $ui-dark-borderColor .tabList - background-color $ui-button--active-backgroundColor background-color $ui-dark-noteDetail-backgroundColor .tabList .list @@ -150,6 +169,15 @@ body[data-theme="solarized-dark"] color $ui-solarized-dark-text-color border 1px solid $ui-solarized-dark-borderColor + .tabList .tabButton + border-color $ui-solarized-dark-borderColor + + .tabButton + &:hover + color $ui-solarized-dark-button--active-color + background-color $ui-solarized-dark-noteDetail-backgroundColor + transition 0.15s + .tabList background-color $ui-solarized-dark-noteDetail-backgroundColor color $ui-solarized-dark-text-color @@ -167,6 +195,14 @@ body[data-theme="monokai"] color $ui-monokai-text-color border 1px solid $ui-monokai-borderColor + .tabList .tabButton + border-color $ui-monokai-borderColor + + .tabButton + &:hover + color $ui-monokai-text-color + background-color $ui-monokai-noteDetail-backgroundColor + .tabList background-color $ui-monokai-noteDetail-backgroundColor color $ui-monokai-text-color @@ -184,6 +220,14 @@ body[data-theme="dracula"] color $ui-dracula-text-color border 1px solid $ui-dracula-borderColor + .tabList .tabButton + border-color $ui-dracula-borderColor + + .tabButton + &:hover + color $ui-dracula-text-color + background-color $ui-dracula-noteDetail-backgroundColor + .tabList background-color $ui-dracula-noteDetail-backgroundColor color $ui-dracula-text-color \ No newline at end of file diff --git a/browser/main/Main.js b/browser/main/Main.js index e9d2c94d..556c5daf 100644 --- a/browser/main/Main.js +++ b/browser/main/Main.js @@ -96,12 +96,14 @@ class Main extends React.Component { { name: 'example.html', mode: 'html', - content: "\n\n

Enjoy Boostnote!

\n\n" + content: "\n\n

Enjoy Boostnote!

\n\n", + linesHighlighted: [] }, { name: 'example.js', mode: 'javascript', - content: "var boostnote = document.getElementById('enjoy').innerHTML\n\nconsole.log(boostnote)" + content: "var boostnote = document.getElementById('enjoy').innerHTML\n\nconsole.log(boostnote)", + linesHighlighted: [] } ] }) @@ -167,6 +169,8 @@ class Main extends React.Component { } }) + delete CodeMirror.keyMap.emacs['Ctrl-V'] + eventEmitter.on('editor:fullscreen', this.toggleFullScreen) } @@ -232,8 +236,8 @@ class Main extends React.Component { if (this.state.isRightSliderFocused) { const offset = this.refs.body.getBoundingClientRect().left let newListWidth = e.pageX - offset - if (newListWidth < 10) { - newListWidth = 10 + if (newListWidth < 180) { + newListWidth = 180 } else if (newListWidth > 600) { newListWidth = 600 } diff --git a/browser/main/NewNoteButton/index.js b/browser/main/NewNoteButton/index.js index e739a550..c34443be 100644 --- a/browser/main/NewNoteButton/index.js +++ b/browser/main/NewNoteButton/index.js @@ -35,19 +35,20 @@ class NewNoteButton extends React.Component { } handleNewNoteButtonClick (e) { - const { location, dispatch, config } = this.props + const { location, params, dispatch, config } = this.props const { storage, folder } = this.resolveTargetFolder() if (config.ui.defaultNote === 'MARKDOWN_NOTE') { - createMarkdownNote(storage.key, folder.key, dispatch, location) + createMarkdownNote(storage.key, folder.key, dispatch, location, params, config) } else if (config.ui.defaultNote === 'SNIPPET_NOTE') { - createSnippetNote(storage.key, folder.key, dispatch, location, config) + createSnippetNote(storage.key, folder.key, dispatch, location, params, config) } else { modal.open(NewNoteModal, { storage: storage.key, folder: folder.key, dispatch, location, + params, config }) } diff --git a/browser/main/NoteList/index.js b/browser/main/NoteList/index.js index 13117af1..dbc9cfd3 100644 --- a/browser/main/NoteList/index.js +++ b/browser/main/NoteList/index.js @@ -2,7 +2,6 @@ import PropTypes from 'prop-types' import React from 'react' import CSSModules from 'browser/lib/CSSModules' -import debounceRender from 'react-debounce-render' import styles from './NoteList.styl' import moment from 'moment' import _ from 'lodash' @@ -64,13 +63,14 @@ class NoteList extends React.Component { this.focusHandler = () => { this.refs.list.focus() } - this.alertIfSnippetHandler = () => { - this.alertIfSnippet() + this.alertIfSnippetHandler = (event, msg) => { + this.alertIfSnippet(msg) } this.importFromFileHandler = this.importFromFile.bind(this) this.jumpNoteByHash = this.jumpNoteByHashHandler.bind(this) this.handleNoteListKeyUp = this.handleNoteListKeyUp.bind(this) this.getNoteKeyFromTargetIndex = this.getNoteKeyFromTargetIndex.bind(this) + this.cloneNote = this.cloneNote.bind(this) this.deleteNote = this.deleteNote.bind(this) this.focusNote = this.focusNote.bind(this) this.pinToTop = this.pinToTop.bind(this) @@ -83,7 +83,9 @@ class NoteList extends React.Component { // TODO: not Selected noteKeys but SelectedNote(for reusing) this.state = { + ctrlKeyDown: false, shiftKeyDown: false, + prevShiftNoteIndex: -1, selectedNoteKeys: [] } @@ -94,6 +96,7 @@ class NoteList extends React.Component { this.refreshTimer = setInterval(() => this.forceUpdate(), 60 * 1000) ee.on('list:next', this.selectNextNoteHandler) ee.on('list:prior', this.selectPriorNoteHandler) + ee.on('list:clone', this.cloneNote) ee.on('list:focus', this.focusHandler) ee.on('list:isMarkdownNote', this.alertIfSnippetHandler) ee.on('import:file', this.importFromFileHandler) @@ -116,6 +119,7 @@ class NoteList extends React.Component { ee.off('list:next', this.selectNextNoteHandler) ee.off('list:prior', this.selectPriorNoteHandler) + ee.off('list:clone', this.cloneNote) ee.off('list:focus', this.focusHandler) ee.off('list:isMarkdownNote', this.alertIfSnippetHandler) ee.off('import:file', this.importFromFileHandler) @@ -171,16 +175,15 @@ class NoteList extends React.Component { } } - focusNote (selectedNoteKeys, noteKey) { + focusNote (selectedNoteKeys, noteKey, pathname) { const { router } = this.context - const { location } = this.props this.setState({ selectedNoteKeys }) router.push({ - pathname: location.pathname, + pathname, query: { key: noteKey } @@ -199,6 +202,7 @@ class NoteList extends React.Component { } let { selectedNoteKeys } = this.state const { shiftKeyDown } = this.state + const { location } = this.props let targetIndex = this.getTargetIndex() @@ -215,7 +219,7 @@ class NoteList extends React.Component { selectedNoteKeys.push(priorNoteKey) } - this.focusNote(selectedNoteKeys, priorNoteKey) + this.focusNote(selectedNoteKeys, priorNoteKey, location.pathname) ee.emit('list:moved') } @@ -226,6 +230,7 @@ class NoteList extends React.Component { } let { selectedNoteKeys } = this.state const { shiftKeyDown } = this.state + const { location } = this.props let targetIndex = this.getTargetIndex() const isTargetLastNote = targetIndex === this.notes.length - 1 @@ -248,7 +253,7 @@ class NoteList extends React.Component { selectedNoteKeys.push(nextNoteKey) } - this.focusNote(selectedNoteKeys, nextNoteKey) + this.focusNote(selectedNoteKeys, nextNoteKey, location.pathname) ee.emit('list:moved') } @@ -260,13 +265,13 @@ class NoteList extends React.Component { } const selectedNoteKeys = [noteHash] - this.focusNote(selectedNoteKeys, noteHash) + this.focusNote(selectedNoteKeys, noteHash, '/home') ee.emit('list:moved') } handleNoteListKeyDown (e) { - if (e.metaKey || e.ctrlKey) return true + if (e.metaKey) return true // A key if (e.keyCode === 65 && !e.shiftKey) { @@ -274,12 +279,6 @@ class NoteList extends React.Component { ee.emit('top:new-note') } - // D key - if (e.keyCode === 68) { - e.preventDefault() - this.deleteNote() - } - // E key if (e.keyCode === 69) { e.preventDefault() @@ -306,6 +305,8 @@ class NoteList extends React.Component { if (e.shiftKey) { this.setState({ shiftKeyDown: true }) + } else if (e.ctrlKey) { + this.setState({ ctrlKeyDown: true }) } } @@ -313,6 +314,10 @@ class NoteList extends React.Component { if (!e.shiftKey) { this.setState({ shiftKeyDown: false }) } + + if (!e.ctrlKey) { + this.setState({ ctrlKeyDown: false }) + } } getNotes () { @@ -389,25 +394,65 @@ class NoteList extends React.Component { return pinnedNotes.concat(unpinnedNotes) } + getNoteIndexByKey (noteKey) { + return this.notes.findIndex((note) => { + if (!note) return -1 + + return note.key === noteKey + }) + } + handleNoteClick (e, uniqueKey) { const { router } = this.context const { location } = this.props - let { selectedNoteKeys } = this.state - const { shiftKeyDown } = this.state + let { selectedNoteKeys, prevShiftNoteIndex } = this.state + const { ctrlKeyDown, shiftKeyDown } = this.state + const hasSelectedNoteKey = selectedNoteKeys.length > 0 - if (shiftKeyDown && selectedNoteKeys.includes(uniqueKey)) { + if (ctrlKeyDown && selectedNoteKeys.includes(uniqueKey)) { const newSelectedNoteKeys = selectedNoteKeys.filter((noteKey) => noteKey !== uniqueKey) this.setState({ selectedNoteKeys: newSelectedNoteKeys }) return } - if (!shiftKeyDown) { + if (!ctrlKeyDown && !shiftKeyDown) { selectedNoteKeys = [] } + + if (!shiftKeyDown) { + prevShiftNoteIndex = -1 + } + selectedNoteKeys.push(uniqueKey) + + if (shiftKeyDown && hasSelectedNoteKey) { + let firstShiftNoteIndex = this.getNoteIndexByKey(selectedNoteKeys[0]) + // Shift selection can either start from first note in the exisiting selectedNoteKeys + // or previous first shift note index + firstShiftNoteIndex = firstShiftNoteIndex > prevShiftNoteIndex + ? firstShiftNoteIndex : prevShiftNoteIndex + + const lastShiftNoteIndex = this.getNoteIndexByKey(uniqueKey) + + const startIndex = firstShiftNoteIndex < lastShiftNoteIndex + ? firstShiftNoteIndex : lastShiftNoteIndex + const endIndex = firstShiftNoteIndex > lastShiftNoteIndex + ? firstShiftNoteIndex : lastShiftNoteIndex + + selectedNoteKeys = [] + for (let i = startIndex; i <= endIndex; i++) { + selectedNoteKeys.push(this.notes[i].key) + } + + if (prevShiftNoteIndex < 0) { + prevShiftNoteIndex = firstShiftNoteIndex + } + } + this.setState({ - selectedNoteKeys + selectedNoteKeys, + prevShiftNoteIndex }) router.push({ @@ -446,14 +491,21 @@ class NoteList extends React.Component { }) } - alertIfSnippet () { + alertIfSnippet (msg) { + const warningMessage = (msg) => ({ + 'export-txt': 'Text export', + 'export-md': 'Markdown export', + 'export-html': 'HTML export', + 'print': 'Print' + })[msg] + const targetIndex = this.getTargetIndex() if (this.notes[targetIndex].type === 'SNIPPET_NOTE') { dialog.showMessageBox(remote.getCurrentWindow(), { type: 'warning', message: i18n.__('Sorry!'), - detail: i18n.__('md/text import is available only a markdown note.'), - buttons: [i18n.__('OK'), i18n.__('Cancel')] + detail: i18n.__(warningMessage(msg) + ' is available only in markdown notes.'), + buttons: [i18n.__('OK')] }) } } @@ -658,7 +710,8 @@ class NoteList extends React.Component { type: firstNote.type, folder: folder.key, title: firstNote.title + ' ' + i18n.__('copy'), - content: firstNote.content + content: firstNote.content, + linesHighlighted: firstNote.linesHighlighted }) .then((note) => { attachmentManagement.cloneAttachments(firstNote, note) @@ -1076,4 +1129,4 @@ NoteList.propTypes = { }) } -export default debounceRender(CSSModules(NoteList, styles)) +export default CSSModules(NoteList, styles) diff --git a/browser/main/SideNav/StorageItem.js b/browser/main/SideNav/StorageItem.js index d17314b3..e336f3ce 100644 --- a/browser/main/SideNav/StorageItem.js +++ b/browser/main/SideNav/StorageItem.js @@ -25,7 +25,8 @@ class StorageItem extends React.Component { const { storage } = this.props this.state = { - isOpen: !!storage.isOpen + isOpen: !!storage.isOpen, + draggedOver: null } } @@ -204,6 +205,20 @@ class StorageItem extends React.Component { folderKey: data.folderKey, fileType: data.fileType }) + return data + }) + .then(data => { + dialog.showMessageBox(remote.getCurrentWindow(), { + type: 'info', + message: 'Exported to "' + data.exportDir + '"' + }) + }) + .catch(err => { + dialog.showErrorBox( + 'Export error', + err ? err.message || err : 'Unexpected error during export' + ) + throw err }) } }) @@ -231,14 +246,20 @@ class StorageItem extends React.Component { } } - handleDragEnter (e) { - e.dataTransfer.setData('defaultColor', e.target.style.backgroundColor) - e.target.style.backgroundColor = 'rgba(129, 130, 131, 0.08)' + handleDragEnter (e, key) { + e.preventDefault() + if (this.state.draggedOver === key) { return } + this.setState({ + draggedOver: key + }) } handleDragLeave (e) { - e.target.style.opacity = '1' - e.target.style.backgroundColor = e.dataTransfer.getData('defaultColor') + e.preventDefault() + if (this.state.draggedOver === null) { return } + this.setState({ + draggedOver: null + }) } dropNote (storage, folder, dispatch, location, noteData) { @@ -263,8 +284,12 @@ class StorageItem extends React.Component { } handleDrop (e, storage, folder, dispatch, location) { - e.target.style.opacity = '1' - e.target.style.backgroundColor = e.dataTransfer.getData('defaultColor') + e.preventDefault() + if (this.state.draggedOver !== null) { + this.setState({ + draggedOver: null + }) + } const noteData = JSON.parse(e.dataTransfer.getData('note')) this.dropNote(storage, folder, dispatch, location, noteData) } @@ -274,7 +299,7 @@ class StorageItem extends React.Component { const { folderNoteMap, trashedSet } = data const SortableStorageItemChild = SortableElement(StorageItemChild) const folderList = storage.folders.map((folder, index) => { - let folderRegex = new RegExp(escapeStringRegexp(path.sep) + 'storages' + escapeStringRegexp(path.sep) + storage.key + escapeStringRegexp(path.sep) + 'folders' + escapeStringRegexp(path.sep) + folder.key) + const folderRegex = new RegExp(escapeStringRegexp(path.sep) + 'storages' + escapeStringRegexp(path.sep) + storage.key + escapeStringRegexp(path.sep) + 'folders' + escapeStringRegexp(path.sep) + folder.key) const isActive = !!(location.pathname.match(folderRegex)) const noteSet = folderNoteMap.get(storage.key + '-' + folder.key) @@ -291,16 +316,22 @@ class StorageItem extends React.Component { this.handleFolderButtonClick(folder.key)(e)} handleContextMenu={(e) => this.handleFolderButtonContextMenu(e, folder)} folderName={folder.name} folderColor={folder.color} isFolded={isFolded} noteCount={noteCount} - handleDrop={(e) => this.handleDrop(e, storage, folder, dispatch, location)} - handleDragEnter={this.handleDragEnter} - handleDragLeave={this.handleDragLeave} + handleDrop={(e) => { + this.handleDrop(e, storage, folder, dispatch, location) + }} + handleDragEnter={(e) => { + this.handleDragEnter(e, folder.key) + }} + handleDragLeave={(e) => { + this.handleDragLeave(e, folder) + }} /> ) }) diff --git a/browser/main/SideNav/index.js b/browser/main/SideNav/index.js index 3e18095e..b98d859d 100644 --- a/browser/main/SideNav/index.js +++ b/browser/main/SideNav/index.js @@ -19,6 +19,7 @@ import {SortableContainer} from 'react-sortable-hoc' import i18n from 'browser/lib/i18n' import context from 'browser/lib/context' import { remote } from 'electron' +import { confirmDeleteNote } from 'browser/lib/confirmDeleteNote' function matchActiveTags (tags, activeTags) { return _.every(activeTags, v => tags.indexOf(v) >= 0) @@ -269,7 +270,7 @@ class SideNav extends React.Component { const tags = pathSegments[pathSegments.length - 1] return (tags === 'alltags') ? [] - : tags.split(' ').map(tag => decodeURIComponent(tag)) + : decodeURIComponent(tags).split(' ') } handleClickTagListItem (name) { @@ -301,7 +302,7 @@ class SideNav extends React.Component { } else { listOfTags.push(tag) } - router.push(`/tags/${listOfTags.map(tag => encodeURIComponent(tag)).join(' ')}`) + router.push(`/tags/${encodeURIComponent(listOfTags.join(' '))}`) } emptyTrash (entries) { @@ -309,6 +310,8 @@ class SideNav extends React.Component { const deletionPromises = entries.map((note) => { return dataApi.deleteNote(note.storage, note.key) }) + const { confirmDeletion } = this.props.config.ui + if (!confirmDeleteNote(confirmDeletion, true)) return Promise.all(deletionPromises) .then((arrayOfStorageAndNoteKeys) => { arrayOfStorageAndNoteKeys.forEach(({ storageKey, noteKey }) => { diff --git a/browser/main/StatusBar/StatusBar.styl b/browser/main/StatusBar/StatusBar.styl index 83cf2088..23dec208 100644 --- a/browser/main/StatusBar/StatusBar.styl +++ b/browser/main/StatusBar/StatusBar.styl @@ -47,6 +47,14 @@ .update-icon color $brand-color +body[data-theme="default"] + .zoom + color $ui-text-color + +body[data-theme="white"] + .zoom + color $ui-text-color + body[data-theme="dark"] .root border-color $ui-dark-borderColor diff --git a/browser/main/StatusBar/index.js b/browser/main/StatusBar/index.js index 8b48e3d3..c99bf036 100644 --- a/browser/main/StatusBar/index.js +++ b/browser/main/StatusBar/index.js @@ -5,6 +5,7 @@ import styles from './StatusBar.styl' import ZoomManager from 'browser/main/lib/ZoomManager' import i18n from 'browser/lib/i18n' import context from 'browser/lib/context' +import EventEmitter from 'browser/main/lib/eventEmitter' const electron = require('electron') const { remote, ipcRenderer } = electron @@ -13,6 +14,26 @@ const { dialog } = remote const zoomOptions = [0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0] class StatusBar extends React.Component { + + constructor (props) { + super(props) + this.handleZoomInMenuItem = this.handleZoomInMenuItem.bind(this) + this.handleZoomOutMenuItem = this.handleZoomOutMenuItem.bind(this) + this.handleZoomResetMenuItem = this.handleZoomResetMenuItem.bind(this) + } + + componentDidMount () { + EventEmitter.on('status:zoomin', this.handleZoomInMenuItem) + EventEmitter.on('status:zoomout', this.handleZoomOutMenuItem) + EventEmitter.on('status:zoomreset', this.handleZoomResetMenuItem) + } + + componentWillUnmount () { + EventEmitter.off('status:zoomin', this.handleZoomInMenuItem) + EventEmitter.off('status:zoomout', this.handleZoomOutMenuItem) + EventEmitter.off('status:zoomreset', this.handleZoomResetMenuItem) + } + updateApp () { const index = dialog.showMessageBox(remote.getCurrentWindow(), { type: 'warning', @@ -48,6 +69,20 @@ class StatusBar extends React.Component { }) } + handleZoomInMenuItem () { + const zoomFactor = ZoomManager.getZoom() + 0.1 + this.handleZoomMenuItemClick(zoomFactor) + } + + handleZoomOutMenuItem () { + const zoomFactor = ZoomManager.getZoom() - 0.1 + this.handleZoomMenuItemClick(zoomFactor) + } + + handleZoomResetMenuItem () { + this.handleZoomMenuItemClick(1.0) + } + render () { const { config, status } = this.context diff --git a/browser/main/TopBar/index.js b/browser/main/TopBar/index.js index a5687ecb..91256daf 100644 --- a/browser/main/TopBar/index.js +++ b/browser/main/TopBar/index.js @@ -6,6 +6,7 @@ import _ from 'lodash' import ee from 'browser/main/lib/eventEmitter' import NewNoteButton from 'browser/main/NewNoteButton' import i18n from 'browser/lib/i18n' +import debounce from 'lodash/debounce' class TopBar extends React.Component { constructor (props) { @@ -25,6 +26,10 @@ class TopBar extends React.Component { } this.codeInitHandler = this.handleCodeInit.bind(this) + + this.updateKeyword = debounce(this.updateKeyword, 1000 / 60, { + maxWait: 1000 / 8 + }) } componentDidMount () { @@ -94,7 +99,6 @@ class TopBar extends React.Component { } handleKeyUp (e) { - const { router } = this.context // reset states this.setState({ isConfirmTranslation: false @@ -106,21 +110,21 @@ class TopBar extends React.Component { isConfirmTranslation: true }) const keyword = this.refs.searchInput.value - router.push(`/searched/${encodeURIComponent(keyword)}`) - this.setState({ - search: keyword - }) + this.updateKeyword(keyword) } } handleSearchChange (e) { - const { router } = this.context - const keyword = this.refs.searchInput.value if (this.state.isAlphabet || this.state.isConfirmTranslation) { - router.push(`/searched/${encodeURIComponent(keyword)}`) + 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 }) diff --git a/browser/main/lib/ConfigManager.js b/browser/main/lib/ConfigManager.js index 4cbe80a7..c2ff9f7a 100644 --- a/browser/main/lib/ConfigManager.js +++ b/browser/main/lib/ConfigManager.js @@ -25,7 +25,8 @@ export const DEFAULT_CONFIG = { hotkey: { toggleMain: OSX ? 'Command + Alt + L' : 'Super + Alt + E', toggleMode: OSX ? 'Command + Alt + M' : 'Ctrl + M', - deleteNote: OSX ? 'Command + Shift + Backspace' : 'Ctrl + Shift + Backspace' + deleteNote: OSX ? 'Command + Shift + Backspace' : 'Ctrl + Shift + Backspace', + pasteSmartly: OSX ? 'Command + Shift + V' : 'Ctrl + Shift + V' }, ui: { language: 'en', @@ -51,7 +52,9 @@ export const DEFAULT_CONFIG = { fetchUrlTitle: true, enableTableEditor: false, enableFrontMatterTitle: true, - frontMatterTitleField: 'title' + frontMatterTitleField: 'title', + spellcheck: false, + enableSmartPaste: false }, preview: { fontSize: '14', diff --git a/browser/main/lib/dataApi/attachmentManagement.js b/browser/main/lib/dataApi/attachmentManagement.js index c193eaf2..6a0315f7 100644 --- a/browser/main/lib/dataApi/attachmentManagement.js +++ b/browser/main/lib/dataApi/attachmentManagement.js @@ -227,7 +227,15 @@ function migrateAttachments (markdownContent, storagePath, noteKey) { * @returns {String} postprocessed HTML in which all :storage references are mapped to the actual paths. */ function fixLocalURLS (renderedHTML, storagePath) { - return renderedHTML.replace(new RegExp('/?' + STORAGE_FOLDER_PLACEHOLDER + '.*?"', 'g'), function (match) { + /* + A :storage reference is like `:storage/3b6f8bd6-4edd-4b15-96e0-eadc4475b564/f939b2c3.jpg`. + + - `STORAGE_FOLDER_PLACEHOLDER` will match `:storage` + - `(?:(?:\\\/|%5C)[\\w.]+)+` will match `/3b6f8bd6-4edd-4b15-96e0-eadc4475b564/f939b2c3.jpg` + - `(?:\\\/|%5C)[\\w.]+` will either match `/3b6f8bd6-4edd-4b15-96e0-eadc4475b564` or `/f939b2c3.jpg` + - `(?:\\\/|%5C)` match the path seperator. `\\\/` for posix systems and `%5C` for windows. + */ + return renderedHTML.replace(new RegExp('/?' + STORAGE_FOLDER_PLACEHOLDER + '(?:(?:\\\/|%5C)[\\w.]+)+', 'g'), function (match) { var encodedPathSeparators = new RegExp(mdurl.encode(path.win32.sep) + '|' + mdurl.encode(path.posix.sep), 'g') return match.replace(encodedPathSeparators, path.sep).replace(new RegExp('/?' + STORAGE_FOLDER_PLACEHOLDER, 'g'), 'file:///' + path.join(storagePath, DESTINATION_FOLDER)) }) @@ -316,6 +324,44 @@ function handlePastImageEvent (codeEditor, storageKey, noteKey, dataTransferItem reader.readAsDataURL(blob) } +/** + * @description Creates a new file in the storage folder belonging to the current note and inserts the correct markdown code + * @param {CodeEditor} codeEditor Markdown editor. Its insertAttachmentMd() method will be called to include the markdown code + * @param {String} storageKey Key of the current storage + * @param {String} noteKey Key of the current note + * @param {NativeImage} image The native image + */ +function handlePastNativeImage (codeEditor, storageKey, noteKey, image) { + if (!codeEditor) { + throw new Error('codeEditor has to be given') + } + if (!storageKey) { + throw new Error('storageKey has to be given') + } + + if (!noteKey) { + throw new Error('noteKey has to be given') + } + if (!image) { + throw new Error('image has to be given') + } + + const targetStorage = findStorage.findStorage(storageKey) + const destinationDir = path.join(targetStorage.path, DESTINATION_FOLDER, noteKey) + + createAttachmentDestinationFolder(targetStorage.path, noteKey) + + const imageName = `${uniqueSlug()}.png` + const imagePath = path.join(destinationDir, imageName) + + const binaryData = image.toPNG() + fs.writeFileSync(imagePath, binaryData, 'binary') + + const imageReferencePath = path.join(STORAGE_FOLDER_PLACEHOLDER, noteKey, imageName) + const imageMd = generateAttachmentMarkdown(imageName, imageReferencePath, true) + codeEditor.insertAttachmentMd(imageMd) +} + /** * @description Returns all attachment paths of the given markdown * @param {String} markdownContent content in which the attachment paths should be found @@ -539,6 +585,7 @@ module.exports = { generateAttachmentMarkdown, handleAttachmentDrop, handlePastImageEvent, + handlePastNativeImage, getAttachmentsInMarkdownContent, getAbsolutePathsOfAttachmentsInContent, removeStorageAndNoteReferences, diff --git a/browser/main/lib/dataApi/copyFile.js b/browser/main/lib/dataApi/copyFile.js index 2dc66309..6f23aae2 100755 --- a/browser/main/lib/dataApi/copyFile.js +++ b/browser/main/lib/dataApi/copyFile.js @@ -16,7 +16,7 @@ function copyFile (srcPath, dstPath) { const dstFolder = path.dirname(dstPath) if (!fs.existsSync(dstFolder)) fs.mkdirSync(dstFolder) - const input = fs.createReadStream(srcPath) + const input = fs.createReadStream(decodeURI(srcPath)) const output = fs.createWriteStream(dstPath) output.on('error', reject) diff --git a/browser/main/lib/dataApi/createNote.js b/browser/main/lib/dataApi/createNote.js index e5d44489..5bfa2457 100644 --- a/browser/main/lib/dataApi/createNote.js +++ b/browser/main/lib/dataApi/createNote.js @@ -16,6 +16,7 @@ function validateInput (input) { switch (input.type) { case 'MARKDOWN_NOTE': if (!_.isString(input.content)) input.content = '' + if (!_.isArray(input.linesHighlighted)) input.linesHighlighted = [] break case 'SNIPPET_NOTE': if (!_.isString(input.description)) input.description = '' @@ -23,7 +24,8 @@ function validateInput (input) { input.snippets = [{ name: '', mode: 'text', - content: '' + content: '', + linesHighlighted: [] }] } break diff --git a/browser/main/lib/dataApi/createSnippet.js b/browser/main/lib/dataApi/createSnippet.js index 5d189217..2e585c9f 100644 --- a/browser/main/lib/dataApi/createSnippet.js +++ b/browser/main/lib/dataApi/createSnippet.js @@ -9,7 +9,8 @@ function createSnippet (snippetFile) { id: crypto.randomBytes(16).toString('hex'), name: 'Unnamed snippet', prefix: [], - content: '' + content: '', + linesHighlighted: [] } fetchSnippet(null, snippetFile).then((snippets) => { snippets.push(newSnippet) diff --git a/browser/main/lib/dataApi/exportFolder.js b/browser/main/lib/dataApi/exportFolder.js index 3e998f15..771f77dc 100644 --- a/browser/main/lib/dataApi/exportFolder.js +++ b/browser/main/lib/dataApi/exportFolder.js @@ -1,9 +1,9 @@ import { findStorage } from 'browser/lib/findStorage' import resolveStorageData from './resolveStorageData' import resolveStorageNotes from './resolveStorageNotes' +import exportNote from './exportNote' import filenamify from 'filenamify' import * as path from 'path' -import * as fs from 'fs' /** * @param {String} storageKey @@ -45,9 +45,9 @@ function exportFolder (storageKey, folderKey, fileType, exportDir) { notes .filter(note => note.folder === folderKey && note.isTrashed === false && note.type === 'MARKDOWN_NOTE') - .forEach(snippet => { - const notePath = path.join(exportDir, `${filenamify(snippet.title, {replacement: '_'})}.${fileType}`) - fs.writeFileSync(notePath, snippet.content) + .forEach(note => { + const notePath = path.join(exportDir, `${filenamify(note.title, {replacement: '_'})}.${fileType}`) + exportNote(note.key, storage.path, note.content, notePath, null) }) return { diff --git a/browser/main/lib/dataApi/exportNote.js b/browser/main/lib/dataApi/exportNote.js index e4fec5f4..b358e548 100755 --- a/browser/main/lib/dataApi/exportNote.js +++ b/browser/main/lib/dataApi/exportNote.js @@ -4,27 +4,43 @@ import { findStorage } from 'browser/lib/findStorage' const fs = require('fs') const path = require('path') +const attachmentManagement = require('./attachmentManagement') + /** - * Export note together with images + * Export note together with attachments * - * If images is stored in the storage, creates 'images' subfolder in target directory - * and copies images to it. Changes links to images in the content of the note + * If attachments are stored in the storage, creates 'attachments' subfolder in target directory + * and copies attachments to it. Changes links to images in the content of the note * + * @param {String} nodeKey key of the node that should be exported * @param {String} storageKey or storage path * @param {String} noteContent Content to export * @param {String} targetPath Path to exported file * @param {function} outputFormatter * @return {Promise.<*[]>} */ -function exportNote (storageKey, noteContent, targetPath, outputFormatter) { +function exportNote (nodeKey, storageKey, noteContent, targetPath, outputFormatter) { const storagePath = path.isAbsolute(storageKey) ? storageKey : findStorage(storageKey).path const exportTasks = [] if (!storagePath) { throw new Error('Storage path is not found') } + const attachmentsAbsolutePaths = attachmentManagement.getAbsolutePathsOfAttachmentsInContent( + noteContent, + storagePath + ) + attachmentsAbsolutePaths.forEach(attachment => { + exportTasks.push({ + src: attachment, + dst: attachmentManagement.DESTINATION_FOLDER + }) + }) - let exportedData = noteContent + let exportedData = attachmentManagement.removeStorageAndNoteReferences( + noteContent, + nodeKey + ) if (outputFormatter) { exportedData = outputFormatter(exportedData, exportTasks) diff --git a/browser/main/lib/dataApi/migrateFromV5Storage.js b/browser/main/lib/dataApi/migrateFromV5Storage.js index b11e66e9..78d78746 100644 --- a/browser/main/lib/dataApi/migrateFromV5Storage.js +++ b/browser/main/lib/dataApi/migrateFromV5Storage.js @@ -69,7 +69,8 @@ function importAll (storage, data) { isStarred: false, title: article.title, content: '# ' + article.title + '\n\n' + article.content, - key: noteKey + key: noteKey, + linesHighlighted: article.linesHighlighted } notes.push(newNote) } else { @@ -87,7 +88,8 @@ function importAll (storage, data) { snippets: [{ name: article.mode, mode: article.mode, - content: article.content + content: article.content, + linesHighlighted: article.linesHighlighted }] } notes.push(newNote) diff --git a/browser/main/lib/dataApi/updateNote.js b/browser/main/lib/dataApi/updateNote.js index 147fbc06..ce9fabcf 100644 --- a/browser/main/lib/dataApi/updateNote.js +++ b/browser/main/lib/dataApi/updateNote.js @@ -39,6 +39,9 @@ function validateInput (input) { if (input.content != null) { if (!_.isString(input.content)) validatedInput.content = '' else validatedInput.content = input.content + + if (!_.isArray(input.linesHighlighted)) validatedInput.linesHighlighted = [] + else validatedInput.linesHighlighted = input.linesHighlighted } return validatedInput case 'SNIPPET_NOTE': @@ -51,7 +54,8 @@ function validateInput (input) { validatedInput.snippets = [{ name: '', mode: 'text', - content: '' + content: '', + linesHighlighted: [] }] } else { validatedInput.snippets = input.snippets @@ -96,12 +100,14 @@ function updateNote (storageKey, noteKey, input) { snippets: [{ name: '', mode: 'text', - content: '' + content: '', + linesHighlighted: [] }] } : { type: 'MARKDOWN_NOTE', - content: '' + content: '', + linesHighlighted: [] } noteData.title = '' if (storage.folders.length === 0) throw new Error('Failed to restore note: No folder exists.') diff --git a/browser/main/lib/dataApi/updateSnippet.js b/browser/main/lib/dataApi/updateSnippet.js index f2310b8e..f132d83f 100644 --- a/browser/main/lib/dataApi/updateSnippet.js +++ b/browser/main/lib/dataApi/updateSnippet.js @@ -12,7 +12,8 @@ function updateSnippet (snippet, snippetFile) { if ( currentSnippet.name === snippet.name && currentSnippet.prefix === snippet.prefix && - currentSnippet.content === snippet.content + currentSnippet.content === snippet.content && + currentSnippet.linesHighlighted === snippet.linesHighlighted ) { // if everything is the same then don't write to disk resolve(snippets) @@ -20,6 +21,7 @@ function updateSnippet (snippet, snippetFile) { currentSnippet.name = snippet.name currentSnippet.prefix = snippet.prefix currentSnippet.content = snippet.content + currentSnippet.linesHighlighted = (snippet.linesHighlighted) fs.writeFile(snippetFile || consts.SNIPPET_FILE, JSON.stringify(snippets, null, 4), (err) => { if (err) reject(err) resolve(snippets) diff --git a/browser/main/modals/NewNoteModal.js b/browser/main/modals/NewNoteModal.js index 8b16f2a2..a190602c 100644 --- a/browser/main/modals/NewNoteModal.js +++ b/browser/main/modals/NewNoteModal.js @@ -21,8 +21,8 @@ class NewNoteModal extends React.Component { } handleMarkdownNoteButtonClick (e) { - const { storage, folder, dispatch, location } = this.props - createMarkdownNote(storage, folder, dispatch, location).then(() => { + const { storage, folder, dispatch, location, params, config } = this.props + createMarkdownNote(storage, folder, dispatch, location, params, config).then(() => { setTimeout(this.props.close, 200) }) } @@ -35,8 +35,8 @@ class NewNoteModal extends React.Component { } handleSnippetNoteButtonClick (e) { - const { storage, folder, dispatch, location, config } = this.props - createSnippetNote(storage, folder, dispatch, location, config).then(() => { + const { storage, folder, dispatch, location, params, config } = this.props + createSnippetNote(storage, folder, dispatch, location, params, config).then(() => { setTimeout(this.props.close, 200) }) } diff --git a/browser/main/modals/PreferencesModal/HotkeyTab.js b/browser/main/modals/PreferencesModal/HotkeyTab.js index 7ad6f606..a0f6a739 100644 --- a/browser/main/modals/PreferencesModal/HotkeyTab.js +++ b/browser/main/modals/PreferencesModal/HotkeyTab.js @@ -79,7 +79,8 @@ class HotkeyTab extends React.Component { config.hotkey = { toggleMain: this.refs.toggleMain.value, toggleMode: this.refs.toggleMode.value, - deleteNote: this.refs.deleteNote.value + deleteNote: this.refs.deleteNote.value, + pasteSmartly: this.refs.pasteSmartly.value } this.setState({ config @@ -149,6 +150,17 @@ class HotkeyTab extends React.Component { />
+
+
{i18n.__('Paste Smartly')}
+
+ this.handleHotkeyChange(e)} + ref='pasteSmartly' + value={config.hotkey.pasteSmartly} + type='text' + /> +
+