diff --git a/browser/components/CodeEditor.js b/browser/components/CodeEditor.js index 29c3748b..1c9a3799 100644 --- a/browser/components/CodeEditor.js +++ b/browser/components/CodeEditor.js @@ -8,6 +8,8 @@ import copyImage from 'browser/main/lib/dataApi/copyImage' import { findStorage } from 'browser/lib/findStorage' import fs from 'fs' import eventEmitter from 'browser/main/lib/eventEmitter' +import iconv from 'iconv-lite' +const { ipcRenderer } = require('electron') CodeMirror.modeURL = '../node_modules/codemirror/mode/%N/%N.js' @@ -32,8 +34,13 @@ export default class CodeEditor extends React.Component { constructor (props) { super(props) + this.scrollHandler = _.debounce(this.handleScroll.bind(this), 100, {leading: false, trailing: true}) this.changeHandler = (e) => this.handleChange(e) + this.focusHandler = () => { + ipcRenderer.send('editor:focused', true) + } this.blurHandler = (editor, e) => { + ipcRenderer.send('editor:focused', false) if (e == null) return null let el = e.relatedTarget while (el != null) { @@ -81,7 +88,6 @@ export default class CodeEditor extends React.Component { } } }) - this.scrollHandler = _.debounce(this.handleScroll.bind(this), 100, {leading: false, trailing: true}) } componentDidMount () { @@ -139,6 +145,7 @@ export default class CodeEditor extends React.Component { this.setMode(this.props.mode) + this.editor.on('focus', this.focusHandler) this.editor.on('blur', this.blurHandler) this.editor.on('change', this.changeHandler) this.editor.on('paste', this.pasteHandler) @@ -162,6 +169,7 @@ export default class CodeEditor extends React.Component { } componentWillUnmount () { + this.editor.off('focus', this.focusHandler) this.editor.off('blur', this.blurHandler) this.editor.off('change', this.changeHandler) this.editor.off('paste', this.pasteHandler) @@ -317,7 +325,7 @@ export default class CodeEditor extends React.Component { fetch(pastedTxt, { method: 'get' }).then((response) => { - return (response.text()) + return this.decodeResponse(response) }).then((response) => { const parsedResponse = (new window.DOMParser()).parseFromString(response, 'text/html') const value = editor.getValue() @@ -335,6 +343,31 @@ export default class CodeEditor extends React.Component { }) } + decodeResponse (response) { + const headers = response.headers + const _charset = headers.has('content-type') + ? this.extractContentTypeCharset(headers.get('content-type')) + : undefined + return response.arrayBuffer().then((buff) => { + return new Promise((resolve, reject) => { + try { + const charset = _charset !== undefined && iconv.encodingExists(_charset) ? _charset : 'utf-8' + resolve(iconv.decode(new Buffer(buff), charset).toString()) + } catch (e) { + reject(e) + } + }) + }) + } + + extractContentTypeCharset (contentType) { + return contentType.split(';').filter((str) => { + return str.trim().toLowerCase().startsWith('charset') + }).map((str) => { + return str.replace(/['"]/g, '').split('=')[1] + })[0] + } + render () { const { className, fontSize } = this.props let fontFamily = this.props.fontFamily diff --git a/browser/components/MarkdownPreview.js b/browser/components/MarkdownPreview.js index ddda74bb..6f5c8093 100755 --- a/browser/components/MarkdownPreview.js +++ b/browser/components/MarkdownPreview.js @@ -223,6 +223,7 @@ export default class MarkdownPreview extends React.Component { return ` + ${styles} diff --git a/browser/components/NoteItem.js b/browser/components/NoteItem.js index 2c93dc18..253faa81 100644 --- a/browser/components/NoteItem.js +++ b/browser/components/NoteItem.js @@ -123,7 +123,11 @@ NoteItem.propTypes = { title: PropTypes.string.isrequired, tags: PropTypes.array, isStarred: PropTypes.bool.isRequired, - isTrashed: PropTypes.bool.isRequired + isTrashed: PropTypes.bool.isRequired, + blog: { + blogLink: PropTypes.string, + blogId: PropTypes.number + } }), handleNoteClick: PropTypes.func.isRequired, handleNoteContextMenu: PropTypes.func.isRequired, diff --git a/browser/lib/markdown-it-sanitize-html.js b/browser/lib/markdown-it-sanitize-html.js new file mode 100644 index 00000000..beec9566 --- /dev/null +++ b/browser/lib/markdown-it-sanitize-html.js @@ -0,0 +1,23 @@ +'use strict' + +import sanitizeHtml from 'sanitize-html' + +module.exports = function sanitizePlugin (md, options) { + options = options || {} + + md.core.ruler.after('linkify', 'sanitize_inline', state => { + for (let tokenIdx = 0; tokenIdx < state.tokens.length; tokenIdx++) { + if (state.tokens[tokenIdx].type === 'html_block') { + state.tokens[tokenIdx].content = sanitizeHtml(state.tokens[tokenIdx].content, options) + } + if (state.tokens[tokenIdx].type === 'inline') { + const inlineTokens = state.tokens[tokenIdx].children + for (let childIdx = 0; childIdx < inlineTokens.length; childIdx++) { + if (inlineTokens[childIdx].type === 'html_inline') { + inlineTokens[childIdx].content = sanitizeHtml(inlineTokens[childIdx].content, options) + } + } + } + } + }) +} diff --git a/browser/lib/markdown.js b/browser/lib/markdown.js index e75e13ee..d1f32640 100644 --- a/browser/lib/markdown.js +++ b/browser/lib/markdown.js @@ -1,4 +1,5 @@ import markdownit from 'markdown-it' +import sanitize from './markdown-it-sanitize-html' import emoji from 'markdown-it-emoji' import math from '@rokt33r/markdown-it-math' import _ from 'lodash' @@ -51,6 +52,18 @@ class Markdown { const updatedOptions = Object.assign(defaultOptions, options) this.md = markdownit(updatedOptions) + + // Sanitize use rinput before other plugins + this.md.use(sanitize, { + allowedTags: ['img', 'iframe'], + allowedAttributes: { + '*': ['alt', 'style'], + 'img': ['src', 'width', 'height'], + 'iframe': ['src', 'width', 'height', 'frameborder', 'allowfullscreen'] + }, + allowedIframeHostnames: ['www.youtube.com'] + }) + this.md.use(emoji, { shortcuts: {} }) diff --git a/browser/main/Detail/SnippetNoteDetail.js b/browser/main/Detail/SnippetNoteDetail.js index dbef37ca..6f8bbb3d 100644 --- a/browser/main/Detail/SnippetNoteDetail.js +++ b/browser/main/Detail/SnippetNoteDetail.js @@ -343,6 +343,7 @@ class SnippetNoteDetail extends React.Component { handleKeyDown (e) { switch (e.keyCode) { + // tab key case 9: if (e.ctrlKey && !e.shiftKey) { e.preventDefault() @@ -355,6 +356,7 @@ class SnippetNoteDetail extends React.Component { this.focusEditor() } break + // L key case 76: { const isSuper = global.process.platform === 'darwin' @@ -366,6 +368,7 @@ class SnippetNoteDetail extends React.Component { } } break + // T key case 84: { const isSuper = global.process.platform === 'darwin' diff --git a/browser/main/NoteList/index.js b/browser/main/NoteList/index.js index 8c3e8790..b1f2d092 100644 --- a/browser/main/NoteList/index.js +++ b/browser/main/NoteList/index.js @@ -13,10 +13,13 @@ import searchFromNotes from 'browser/lib/search' import fs from 'fs' import path from 'path' import { hashHistory } from 'react-router' +import copy from 'copy-to-clipboard' import AwsMobileAnalyticsConfig from 'browser/main/lib/AwsMobileAnalyticsConfig' +import markdown from '../../lib/markdown' const { remote } = require('electron') const { Menu, MenuItem, dialog } = remote +const WP_POST_PATH = '/wp/v2/posts' function sortByCreatedAt (a, b) { return new Date(b.createdAt) - new Date(a.createdAt) @@ -70,6 +73,7 @@ class NoteList extends React.Component { this.getNoteFolder = this.getNoteFolder.bind(this) this.getViewType = this.getViewType.bind(this) this.restoreNote = this.restoreNote.bind(this) + this.copyNoteLink = this.copyNoteLink.bind(this) // TODO: not Selected noteKeys but SelectedNote(for reusing) this.state = { @@ -257,27 +261,38 @@ class NoteList extends React.Component { handleNoteListKeyDown (e) { if (e.metaKey || e.ctrlKey) return true + // A key if (e.keyCode === 65 && !e.shiftKey) { e.preventDefault() ee.emit('top:new-note') } + // D key if (e.keyCode === 68) { e.preventDefault() this.deleteNote() } + // E key if (e.keyCode === 69) { e.preventDefault() ee.emit('detail:focus') } - if (e.keyCode === 38) { + // F or S key + if (e.keyCode === 70 || e.keyCode === 83) { + e.preventDefault() + ee.emit('top:focus-search') + } + + // UP or K key + if (e.keyCode === 38 || e.keyCode === 75) { e.preventDefault() this.selectPriorNote() } - if (e.keyCode === 40) { + // DOWN or J key + if (e.keyCode === 40 || e.keyCode === 74) { e.preventDefault() this.selectNextNote() } @@ -458,6 +473,10 @@ class NoteList extends React.Component { const deleteLabel = 'Delete Note' const cloneNote = 'Clone Note' const restoreNote = 'Restore Note' + const copyNoteLink = 'Copy Note Link' + const publishLabel = 'Publish Blog' + const updateLabel = 'Update Blog' + const openBlogLabel = 'Open Blog' const menu = new Menu() if (!location.pathname.match(/\/starred|\/trash/)) { @@ -482,6 +501,28 @@ class NoteList extends React.Component { label: cloneNote, click: this.cloneNote.bind(this) })) + menu.append(new MenuItem({ + label: copyNoteLink, + click: this.copyNoteLink(note) + })) + if (note.type === 'MARKDOWN_NOTE') { + if (note.blog && note.blog.blogLink && note.blog.blogId) { + menu.append(new MenuItem({ + label: updateLabel, + click: this.publishMarkdown.bind(this) + })) + menu.append(new MenuItem({ + label: openBlogLabel, + click: () => this.openBlog.bind(this)(note) + })) + } else { + menu.append(new MenuItem({ + label: publishLabel, + click: this.publishMarkdown.bind(this) + })) + } + } + menu.popup() } @@ -630,6 +671,117 @@ class NoteList extends React.Component { }) } + copyNoteLink (note) { + const noteLink = `[${note.title}](${note.storage}-${note.key})` + return copy(noteLink) + } + + save (note) { + const { dispatch } = this.props + dataApi + .updateNote(note.storage, note.key, note) + .then((note) => { + dispatch({ + type: 'UPDATE_NOTE', + note: note + }) + }) + } + + publishMarkdown () { + if (this.pendingPublish) { + clearTimeout(this.pendingPublish) + } + this.pendingPublish = setTimeout(() => { + this.publishMarkdownNow() + }, 1000) + } + + publishMarkdownNow () { + const {selectedNoteKeys} = this.state + const notes = this.notes.map((note) => Object.assign({}, note)) + const selectedNotes = findNotesByKeys(notes, selectedNoteKeys) + const firstNote = selectedNotes[0] + const config = ConfigManager.get() + const {address, token, authMethod, username, password} = config.blog + let authToken = '' + if (authMethod === 'USER') { + authToken = `Basic ${window.btoa(`${username}:${password}`)}` + } else { + authToken = `Bearer ${token}` + } + const contentToRender = firstNote.content.replace(`# ${firstNote.title}`, '') + var data = { + title: firstNote.title, + content: markdown.render(contentToRender), + status: 'publish' + } + + let url = '' + let method = '' + if (firstNote.blog && firstNote.blog.blogId) { + url = `${address}${WP_POST_PATH}/${firstNote.blog.blogId}` + method = 'PUT' + } else { + url = `${address}${WP_POST_PATH}` + method = 'POST' + } + // eslint-disable-next-line no-undef + fetch(url, { + method: method, + body: JSON.stringify(data), + headers: { + 'Authorization': authToken, + 'Content-Type': 'application/json' + } + }).then(res => res.json()) + .then(response => { + if (_.isNil(response.link) || _.isNil(response.id)) { + return Promise.reject() + } + firstNote.blog = { + blogLink: response.link, + blogId: response.id + } + this.save(firstNote) + this.confirmPublish(firstNote) + }) + .catch((error) => { + console.error(error) + this.confirmPublishError() + }) + } + + confirmPublishError () { + const { remote } = electron + const { dialog } = remote + const alertError = { + type: 'warning', + message: 'Publish Failed', + detail: 'Check and update your blog setting and try again.', + buttons: ['Confirm'] + } + dialog.showMessageBox(remote.getCurrentWindow(), alertError) + } + + confirmPublish (note) { + const buttonIndex = dialog.showMessageBox(remote.getCurrentWindow(), { + type: 'warning', + message: 'Publish Succeeded', + detail: `${note.title} is published at ${note.blog.blogLink}`, + buttons: ['Confirm', 'Open Blog'] + }) + + if (buttonIndex === 1) { + this.openBlog(note) + } + } + + openBlog (note) { + const { shell } = electron + shell.openExternal(note.blog.blogLink) + } + importFromFile () { const options = { filters: [ diff --git a/browser/main/SideNav/StorageItem.js b/browser/main/SideNav/StorageItem.js index 5d7e6005..0df7a98e 100644 --- a/browser/main/SideNav/StorageItem.js +++ b/browser/main/SideNav/StorageItem.js @@ -191,33 +191,16 @@ class StorageItem extends React.Component { dropNote (storage, folder, dispatch, location, noteData) { noteData = noteData.filter((note) => folder.key !== note.folder) if (noteData.length === 0) return - const newNoteData = noteData.map((note) => Object.assign({}, note, {storage: storage, folder: folder.key})) Promise.all( - newNoteData.map((note) => dataApi.createNote(storage.key, note)) + noteData.map((note) => dataApi.moveNote(note.storage, note.key, storage.key, folder.key)) ) .then((createdNoteData) => { - createdNoteData.forEach((note) => { + createdNoteData.forEach((newNote) => { dispatch({ - type: 'UPDATE_NOTE', - note: note - }) - }) - }) - .catch((err) => { - console.error(`error on create notes: ${err}`) - }) - .then(() => { - return Promise.all( - noteData.map((note) => dataApi.deleteNote(note.storage, note.key)) - ) - }) - .then((deletedNoteData) => { - deletedNoteData.forEach((note) => { - dispatch({ - type: 'DELETE_NOTE', - storageKey: note.storageKey, - noteKey: note.noteKey + type: 'MOVE_NOTE', + originNote: noteData.find((note) => note.content === newNote.content), + note: newNote }) }) }) diff --git a/browser/main/StatusBar/StatusBar.styl b/browser/main/StatusBar/StatusBar.styl index e055dc0d..9f189fec 100644 --- a/browser/main/StatusBar/StatusBar.styl +++ b/browser/main/StatusBar/StatusBar.styl @@ -21,20 +21,19 @@ color white .zoom - display none - // navButtonColor() - // color rgba(0,0,0,.54) - // height 20px - // display flex - // padding 0 - // align-items center - // background-color transparent - // &:hover - // color $ui-active-color - // &:active - // color $ui-active-color - // span - // margin-left 5px + navButtonColor() + color rgba(0,0,0,.54) + height 20px + display flex + padding 0 + align-items center + background-color transparent + &:hover + color $ui-active-color + &:active + color $ui-active-color + span + margin-left 5px .update navButtonColor() diff --git a/browser/main/TopBar/TopBar.styl b/browser/main/TopBar/TopBar.styl index eb0fc12f..0956571f 100644 --- a/browser/main/TopBar/TopBar.styl +++ b/browser/main/TopBar/TopBar.styl @@ -40,6 +40,32 @@ $control-height = 34px padding-bottom 2px background-color $ui-noteList-backgroundColor +.control-search-input-clear + height 16px + width 16px + position absolute + right 40px + top 10px + z-index 300 + border none + background-color transparent + color #999 + &:hover .control-search-input-clear-tooltip + opacity 1 + +.control-search-input-clear-tooltip + tooltip() + position fixed + pointer-events none + top 50px + left 433px + z-index 200 + padding 5px + line-height normal + border-radius 2px + opacity 0 + transition 0.1s + .control-search-optionList position fixed z-index 200 @@ -207,4 +233,4 @@ body[data-theme="solarized-dark"] background-color $ui-solarized-dark-noteList-backgroundColor input background-color $ui-solarized-dark-noteList-backgroundColor - color $ui-solarized-dark-text-color \ No newline at end of file + color $ui-solarized-dark-text-color diff --git a/browser/main/TopBar/index.js b/browser/main/TopBar/index.js index 6b4a118e..246ef614 100644 --- a/browser/main/TopBar/index.js +++ b/browser/main/TopBar/index.js @@ -36,6 +36,17 @@ class TopBar extends React.Component { ee.off('code:init', this.codeInitHandler) } + handleSearchClearButton (e) { + const { router } = this.context + this.setState({ + search: '', + isSearching: false + }) + this.refs.search.childNodes[0].blur + router.push('/searched') + e.preventDefault() + } + handleKeyDown (e) { // reset states this.setState({ @@ -43,6 +54,23 @@ class TopBar extends React.Component { isIME: false }) + // Clear search on ESC + if (e.keyCode === 27) { + return this.handleSearchClearButton(e) + } + + // Next note on DOWN key + if (e.keyCode === 40) { + ee.emit('list:next') + e.preventDefault() + } + + // Prev note on UP key + if (e.keyCode === 38) { + ee.emit('list:prior') + e.preventDefault() + } + // When the key is an alphabet, del, enter or ctr if (e.keyCode <= 90 || e.keyCode >= 186 && e.keyCode <= 222) { this.setState({ @@ -114,10 +142,12 @@ class TopBar extends React.Component { } handleOnSearchFocus () { + const el = this.refs.search.childNodes[0] if (this.state.isSearching) { - this.refs.search.childNodes[0].blur() + el.blur() } else { - this.refs.search.childNodes[0].focus() + el.focus() + el.setSelectionRange(0, el.value.length) } } @@ -150,15 +180,15 @@ class TopBar extends React.Component { type='text' className='searchInput' /> + {this.state.search !== '' && + + } - {this.state.search > 0 && - - } - {location.pathname === '/trashed' ? '' diff --git a/browser/main/lib/ConfigManager.js b/browser/main/lib/ConfigManager.js index 7080105c..1c4d955b 100644 --- a/browser/main/lib/ConfigManager.js +++ b/browser/main/lib/ConfigManager.js @@ -50,6 +50,14 @@ export const DEFAULT_CONFIG = { latexBlockClose: '$$', scrollPastEnd: false, smartQuotes: true + }, + blog: { + type: 'wordpress', // Available value: wordpress, add more types in the future plz + address: 'http://wordpress.com/wp-json', + authMethod: 'JWT', // Available value: JWT, USER + token: '', + username: '', + password: '' } } @@ -152,6 +160,7 @@ function set (updates) { function assignConfigValues (originalConfig, rcConfig) { const config = Object.assign({}, DEFAULT_CONFIG, originalConfig, rcConfig) config.hotkey = Object.assign({}, DEFAULT_CONFIG.hotkey, originalConfig.hotkey, rcConfig.hotkey) + config.blog = Object.assign({}, DEFAULT_CONFIG.blog, originalConfig.blog, rcConfig.blog) config.ui = Object.assign({}, DEFAULT_CONFIG.ui, originalConfig.ui, rcConfig.ui) config.editor = Object.assign({}, DEFAULT_CONFIG.editor, originalConfig.editor, rcConfig.editor) config.preview = Object.assign({}, DEFAULT_CONFIG.preview, originalConfig.preview, rcConfig.preview) diff --git a/browser/main/lib/dataApi/copyImage.js b/browser/main/lib/dataApi/copyImage.js index ae79f8fb..6a79b8b7 100644 --- a/browser/main/lib/dataApi/copyImage.js +++ b/browser/main/lib/dataApi/copyImage.js @@ -3,19 +3,20 @@ const path = require('path') const { findStorage } = require('browser/lib/findStorage') /** - * @description To copy an image and return the path. + * @description Copy an image and return the path. * @param {String} filePath * @param {String} storageKey - * @return {String} an image path + * @param {Boolean} rename create new filename or leave the old one + * @return {Promise} an image path */ -function copyImage (filePath, storageKey) { +function copyImage (filePath, storageKey, rename = true) { return new Promise((resolve, reject) => { try { const targetStorage = findStorage(storageKey) const inputImage = fs.createReadStream(filePath) const imageExt = path.extname(filePath) - const imageName = Math.random().toString(36).slice(-16) + const imageName = rename ? Math.random().toString(36).slice(-16) : path.basename(filePath, imageExt) const basename = `${imageName}${imageExt}` const imageDir = path.join(targetStorage.path, 'images') if (!fs.existsSync(imageDir)) fs.mkdirSync(imageDir) diff --git a/browser/main/lib/dataApi/exportNote.js b/browser/main/lib/dataApi/exportNote.js index 3ed46b92..740baa20 100755 --- a/browser/main/lib/dataApi/exportNote.js +++ b/browser/main/lib/dataApi/exportNote.js @@ -4,7 +4,7 @@ import {findStorage} from 'browser/lib/findStorage' const fs = require('fs') const path = require('path') -const LOCAL_STORED_REGEX = /!\[(.*?)\]\(\s*?\/:storage\/(.*\.\S*?)\)/gi +const LOCAL_STORED_REGEX = /!\[(.*?)]\(\s*?\/:storage\/(.*\.\S*?)\)/gi const IMAGES_FOLDER_NAME = 'images' /** diff --git a/browser/main/lib/dataApi/moveNote.js b/browser/main/lib/dataApi/moveNote.js index 4580062e..b37b6ac5 100644 --- a/browser/main/lib/dataApi/moveNote.js +++ b/browser/main/lib/dataApi/moveNote.js @@ -1,10 +1,12 @@ const resolveStorageData = require('./resolveStorageData') const _ = require('lodash') const path = require('path') +const fs = require('fs') const CSON = require('@rokt33r/season') const keygen = require('browser/lib/keygen') const sander = require('sander') const { findStorage } = require('browser/lib/findStorage') +const copyImage = require('./copyImage') function moveNote (storageKey, noteKey, newStorageKey, newFolderKey) { let oldStorage, newStorage @@ -65,6 +67,27 @@ function moveNote (storageKey, noteKey, newStorageKey, newFolderKey) { return noteData }) + .then(function moveImages (noteData) { + const searchImagesRegex = /!\[.*?]\(\s*?\/:storage\/(.*\.\S*?)\)/gi + let match = searchImagesRegex.exec(noteData.content) + + const moveTasks = [] + while (match != null) { + const [, filename] = match + const oldPath = path.join(oldStorage.path, 'images', filename) + moveTasks.push( + copyImage(oldPath, noteData.storage, false) + .then(() => { + fs.unlinkSync(oldPath) + }) + ) + + // find next occurence + match = searchImagesRegex.exec(noteData.content) + } + + return Promise.all(moveTasks).then(() => noteData) + }) .then(function writeAndReturn (noteData) { CSON.writeFileSync(path.join(newStorage.path, 'notes', noteData.key + '.cson'), _.omit(noteData, ['key', 'storage'])) return noteData diff --git a/browser/main/lib/dataApi/updateNote.js b/browser/main/lib/dataApi/updateNote.js index 2fbd52c2..147fbc06 100644 --- a/browser/main/lib/dataApi/updateNote.js +++ b/browser/main/lib/dataApi/updateNote.js @@ -30,6 +30,9 @@ function validateInput (input) { validatedInput.isPinned = !!input.isPinned } + if (!_.isNil(input.blog)) { + validatedInput.blog = input.blog + } validatedInput.type = input.type switch (input.type) { case 'MARKDOWN_NOTE': diff --git a/browser/main/modals/PreferencesModal/Blog.js b/browser/main/modals/PreferencesModal/Blog.js new file mode 100644 index 00000000..675455f7 --- /dev/null +++ b/browser/main/modals/PreferencesModal/Blog.js @@ -0,0 +1,198 @@ +import React from 'react' +import CSSModules from 'browser/lib/CSSModules' +import styles from './ConfigTab.styl' +import ConfigManager from 'browser/main/lib/ConfigManager' +import store from 'browser/main/store' +import PropTypes from 'prop-types' +import _ from 'lodash' + +const electron = require('electron') +const { shell } = electron +const ipc = electron.ipcRenderer +class Blog extends React.Component { + constructor (props) { + super(props) + + this.state = { + config: props.config, + BlogAlert: null + } + } + + handleLinkClick (e) { + shell.openExternal(e.currentTarget.href) + e.preventDefault() + } + + clearMessage () { + _.debounce(() => { + this.setState({ + BlogAlert: null + }) + }, 2000)() + } + + componentDidMount () { + this.handleSettingDone = () => { + this.setState({BlogAlert: { + type: 'success', + message: 'Successfully applied!' + }}) + } + this.handleSettingError = (err) => { + this.setState({BlogAlert: { + type: 'error', + message: err.message != null ? err.message : 'Error occurs!' + }}) + } + this.oldBlog = this.state.config.blog + ipc.addListener('APP_SETTING_DONE', this.handleSettingDone) + ipc.addListener('APP_SETTING_ERROR', this.handleSettingError) + } + + handleBlogChange (e) { + const { config } = this.state + config.blog = { + password: !_.isNil(this.refs.passwordInput) ? this.refs.passwordInput.value : config.blog.password, + username: !_.isNil(this.refs.usernameInput) ? this.refs.usernameInput.value : config.blog.username, + token: !_.isNil(this.refs.tokenInput) ? this.refs.tokenInput.value : config.blog.token, + authMethod: this.refs.authMethodDropdown.value, + address: this.refs.addressInput.value, + type: this.refs.typeDropdown.value + } + this.setState({ + config + }) + if (_.isEqual(this.oldBlog, config.blog)) { + this.props.haveToSave() + } else { + this.props.haveToSave({ + tab: 'Blog', + type: 'warning', + message: 'You have to save!' + }) + } + } + + handleSaveButtonClick (e) { + const newConfig = { + blog: this.state.config.blog + } + + ConfigManager.set(newConfig) + + store.dispatch({ + type: 'SET_UI', + config: newConfig + }) + this.clearMessage() + this.props.haveToSave() + } + + render () { + const {config, BlogAlert} = this.state + const blogAlertElement = BlogAlert != null + ?

+ {BlogAlert.message} +

+ : null + return ( +
+
+
Blog
+
+
+ Blog Type +
+
+ +
+
+
+
Blog Address
+
+ this.handleBlogChange(e)} + ref='addressInput' + value={config.blog.address} + type='text' + /> +
+
+
+ + {blogAlertElement} +
+
+
Auth
+ +
+
+ Authentication Method +
+
+ +
+
+ { config.blog.authMethod === 'JWT' && +
+
Token
+
+ this.handleBlogChange(e)} + ref='tokenInput' + value={config.blog.token} + type='text' /> +
+
+ } + { config.blog.authMethod === 'USER' && +
+
+
UserName
+
+ this.handleBlogChange(e)} + ref='usernameInput' + value={config.blog.username} + type='text' /> +
+
+
+
Password
+
+ this.handleBlogChange(e)} + ref='passwordInput' + value={config.blog.password} + type='password' /> +
+
+
+ } +
+ ) + } +} + +Blog.propTypes = { + dispatch: PropTypes.func, + haveToSave: PropTypes.func +} + +export default CSSModules(Blog, styles) diff --git a/browser/main/modals/PreferencesModal/index.js b/browser/main/modals/PreferencesModal/index.js index 6cd5badc..09ca9a4e 100644 --- a/browser/main/modals/PreferencesModal/index.js +++ b/browser/main/modals/PreferencesModal/index.js @@ -6,6 +6,7 @@ import UiTab from './UiTab' import InfoTab from './InfoTab' import Crowdfunding from './Crowdfunding' import StoragesTab from './StoragesTab' +import Blog from './Blog' import ModalEscButton from 'browser/components/ModalEscButton' import CSSModules from 'browser/lib/CSSModules' import styles from './PreferencesModal.styl' @@ -19,7 +20,8 @@ class Preferences extends React.Component { this.state = { currentTab: 'STORAGES', UIAlert: '', - HotkeyAlert: '' + HotkeyAlert: '', + BlogAlert: '' } } @@ -75,6 +77,14 @@ class Preferences extends React.Component { return ( ) + case 'BLOG': + return ( + this.setState({BlogAlert: alert})} + /> + ) case 'STORAGES': default: return ( @@ -111,7 +121,8 @@ class Preferences extends React.Component { {target: 'HOTKEY', label: 'Hotkeys', Hotkey: this.state.HotkeyAlert}, {target: 'UI', label: 'Interface', UI: this.state.UIAlert}, {target: 'INFO', label: 'About'}, - {target: 'CROWDFUNDING', label: 'Crowdfunding'} + {target: 'CROWDFUNDING', label: 'Crowdfunding'}, + {target: 'BLOG', label: 'Blog', Blog: this.state.BlogAlert} ] const navButtons = tabs.map((tab) => { diff --git a/browser/styles/index.styl b/browser/styles/index.styl index 1bd183bf..6fb208b1 100644 --- a/browser/styles/index.styl +++ b/browser/styles/index.styl @@ -5,7 +5,7 @@ $danger-color = #c9302c $danger-lighten-color = lighten(#c9302c, 5%) // Layouts -$statusBar-height = 0px +$statusBar-height = 22px $sideNav-width = 200px $sideNav--folded-width = 44px $topBar-height = 60px @@ -347,4 +347,4 @@ modalSolarizedDark() width 100% background-color $ui-solarized-dark-backgroundColor overflow hidden - border-radius $modal-border-radius \ No newline at end of file + border-radius $modal-border-radius diff --git a/docs/zh_TW/build.md b/docs/zh_TW/build.md new file mode 100644 index 00000000..ff17d9c5 --- /dev/null +++ b/docs/zh_TW/build.md @@ -0,0 +1,86 @@ +# 編譯 +此文件還提供下列的語言 [日文](https://github.com/BoostIO/Boostnote/blob/master/docs/jp/build.md), [韓文](https://github.com/BoostIO/Boostnote/blob/master/docs/ko/build.md), [俄文](https://github.com/BoostIO/Boostnote/blob/master/docs/ru/build.md), [簡體中文](https://github.com/BoostIO/Boostnote/blob/master/docs/zh_CN/build.md), [法文](https://github.com/BoostIO/Boostnote/blob/master/docs/fr/build.md) and [德文](https://github.com/BoostIO/Boostnote/blob/master/docs/de/build.md). + +## 環境 +* npm: 4.x +* node: 7.x + +`$ grunt pre-build` 在 `npm v5.x` 有問題,所以只能用 `npm v4.x` 。 + +## 開發 + +我們使用 Webpack HMR 來開發 Boostnote。 + +在專案根目錄底下執行下列指令,將會以原始設置啟動 Boostnote。 + +**用 yarn 來安裝必要 packages** + +```bash +$ yarn +``` + +**開始開發** + +``` +$ yarn run dev-start +``` + +上述指令同時運行了 `yarn run webpack` 及 `yarn run hot`,相當於將這兩個指令在不同的 terminal 中運行。 + +`webpack` 會同時監控修改過的程式碼,並 +The `webpack` will watch for code changes and then apply them automatically. + +If the following error occurs: `Failed to load resource: net::ERR_CONNECTION_REFUSED`, please reload Boostnote. + +![net::ERR_CONNECTION_REFUSED](https://cloud.githubusercontent.com/assets/11307908/24343004/081e66ae-1279-11e7-8d9e-7f478043d835.png) + +> ### Notice +> There are some cases where you have to refresh the app manually. +> 1. When editing a constructor method of a component +> 2. When adding a new css class (similar to 1: the CSS class is re-written by each component. This process occurs at the Constructor method.) + +## Deploy + +We use Grunt to automate deployment. +You can build the program by using `grunt`. However, we don't recommend this because the default task includes codesign and authenticode. + +So, we've prepared a separate script which just makes an executable file. + +This build doesn't work on npm v5.3.0. So you need to use v5.2.0 when you build it. + +``` +grunt pre-build +``` + +You will find the executable in the `dist` directory. Note, the auto updater won't work because the app isn't signed. + +If you find it necessary, you can use codesign or authenticode with this executable. + +## Make own distribution packages (deb, rpm) + +Distribution packages are created by exec `grunt build` on Linux platform (e.g. Ubuntu, Fedora). + +> Note: You can create both `.deb` and `.rpm` in a single environment. + +After installing the supported version of `node` and `npm`, install build dependency packages. + + +Ubuntu/Debian: + +``` +$ sudo apt-get install -y rpm fakeroot +``` + +Fedora: + +``` +$ sudo dnf install -y dpkg dpkg-dev rpm-build fakeroot +``` + +Then execute `grunt build`. + +``` +$ grunt build +``` + +You will find `.deb` and `.rpm` in the `dist` directory. diff --git a/extra_scripts/boost/boostNewLineIndentContinueMarkdownList.js b/extra_scripts/boost/boostNewLineIndentContinueMarkdownList.js new file mode 100644 index 00000000..aa766f6e --- /dev/null +++ b/extra_scripts/boost/boostNewLineIndentContinueMarkdownList.js @@ -0,0 +1,52 @@ +(function (mod) { + if (typeof exports === 'object' && typeof module === 'object') { // Common JS + mod(require('../codemirror/lib/codemirror')) + } else if (typeof define === 'function' && define.amd) { // AMD + define(['../codemirror/lib/codemirror'], mod) + } else { // Plain browser env + mod(CodeMirror) + } +})(function (CodeMirror) { + 'use strict' + + var listRE = /^(\s*)(>[> ]*|[*+-] \[[x ]\]\s|[*+-]\s|(\d+)([.)]))(\s*)/ + var emptyListRE = /^(\s*)(>[> ]*|[*+-] \[[x ]\]|[*+-]|(\d+)[.)])(\s*)$/ + var unorderedListRE = /[*+-]\s/ + + CodeMirror.commands.boostNewLineAndIndentContinueMarkdownList = function (cm) { + if (cm.getOption('disableInput')) return CodeMirror.Pass + var ranges = cm.listSelections() + var replacements = [] + for (var i = 0; i < ranges.length; i++) { + var pos = ranges[i].head + var eolState = cm.getStateAfter(pos.line) + var inList = eolState.list !== false + var inQuote = eolState.quote !== 0 + var line = cm.getLine(pos.line) + var match = listRE.exec(line) + if (!ranges[i].empty() || (!inList && !inQuote) || !match || pos.ch < match[2].length - 1) { + cm.execCommand('newlineAndIndent') + return + } + if (emptyListRE.test(line)) { + if (!/>\s*$/.test(line)) { + cm.replaceRange('', { + line: pos.line, ch: 0 + }, { + line: pos.line, ch: pos.ch + 1 + }) + } + replacements[i] = '\n' + } else { + var indent = match[1] + var after = match[5] + var bullet = unorderedListRE.test(match[2]) || match[2].indexOf('>') >= 0 + ? match[2].replace('x', ' ') + : (parseInt(match[3], 10) + 1) + match[4] + replacements[i] = '\n' + indent + bullet + after + } + } + + cm.replaceSelections(replacements) + } +}) diff --git a/lib/main-menu.js b/lib/main-menu.js index 0d49ab86..ddf5ee54 100644 --- a/lib/main-menu.js +++ b/lib/main-menu.js @@ -1,6 +1,7 @@ const electron = require('electron') const BrowserWindow = electron.BrowserWindow const shell = electron.shell +const ipc = electron.ipcMain const mainWindow = require('./main-window') const macOS = process.platform === 'darwin' @@ -259,6 +260,23 @@ const view = { ] } +let editorFocused + +// Define extra shortcut keys +mainWindow.webContents.on('before-input-event', (event, input) => { + // Synonyms for Search (Find) + if (input.control && input.key === 'f' && input.type === 'keyDown') { + if (!editorFocused) { + mainWindow.webContents.send('top:focus-search') + event.preventDefault() + } + } +}) + +ipc.on('editor:focused', (event, isFocused) => { + editorFocused = isFocused +}) + const window = { label: 'Window', submenu: [ @@ -267,6 +285,11 @@ const window = { accelerator: 'Command+M', selector: 'performMiniaturize:' }, + { + label: 'Toggle Full Screen', + accelerator: 'Command+Control+F', + selector: 'toggleFullScreen:' + }, { label: 'Close', accelerator: 'Command+W', diff --git a/lib/main.html b/lib/main.html index 53520957..8852bd9e 100644 --- a/lib/main.html +++ b/lib/main.html @@ -78,7 +78,7 @@ - + diff --git a/node_modules/boost/boostNewLineIndentContinueMarkdownList.js b/node_modules/boost/boostNewLineIndentContinueMarkdownList.js deleted file mode 100644 index 159d20eb..00000000 --- a/node_modules/boost/boostNewLineIndentContinueMarkdownList.js +++ /dev/null @@ -1,46 +0,0 @@ -(function(mod) { - if (typeof exports == "object" && typeof module == "object") // CommonJS - mod(require("../codemirror/lib/codemirror")); - else if (typeof define == "function" && define.amd) // AMD - define(["../codemirror/lib/codemirror"], mod); - else // Plain browser env - mod(CodeMirror); -})(function(CodeMirror) { - "use strict"; - - var listRE = /^(\s*)(>[> ]*|[*+-] \[[x ]\]\s|[*+-]\s|(\d+)([.)]))(\s*)/, - emptyListRE = /^(\s*)(>[> ]*|[*+-] \[[x ]\]|[*+-]|(\d+)[.)])(\s*)$/, - unorderedListRE = /[*+-]\s/; - - CodeMirror.commands.boostNewLineAndIndentContinueMarkdownList = function(cm) { - if (cm.getOption("disableInput")) return CodeMirror.Pass; - var ranges = cm.listSelections(), replacements = []; - for (var i = 0; i < ranges.length; i++) { - var pos = ranges[i].head; - var eolState = cm.getStateAfter(pos.line); - var inList = eolState.list !== false; - var inQuote = eolState.quote !== 0; - var line = cm.getLine(pos.line), match = listRE.exec(line); - if (!ranges[i].empty() || (!inList && !inQuote) || !match || pos.ch < match[2].length - 1) { - cm.execCommand("newlineAndIndent"); - return; - } - if (emptyListRE.test(line)) { - if (!/>\s*$/.test(line)) cm.replaceRange("", { - line: pos.line, ch: 0 - }, { - line: pos.line, ch: pos.ch + 1 - }); - replacements[i] = "\n"; - } else { - var indent = match[1], after = match[5]; - var bullet = unorderedListRE.test(match[2]) || match[2].indexOf(">") >= 0 - ? match[2].replace("x", " ") - : (parseInt(match[3], 10) + 1) + match[4]; - replacements[i] = "\n" + indent + bullet + after; - } - } - - cm.replaceSelections(replacements); - }; -}); diff --git a/package.json b/package.json index db2a9733..6b90ade9 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "electron-gh-releases": "^2.0.2", "flowchart.js": "^1.6.5", "font-awesome": "^4.3.0", + "iconv-lite": "^0.4.19", "immutable": "^3.8.1", "js-sequence-diagrams": "^1000000.0.6", "katex": "^0.8.3", @@ -84,6 +85,7 @@ "react-sortable-hoc": "^0.6.7", "redux": "^3.5.2", "sander": "^0.5.1", + "sanitize-html": "^1.18.2", "striptags": "^2.2.1", "superagent": "^1.2.0", "superagent-promise": "^1.0.3" diff --git a/yarn.lock b/yarn.lock index c1a949a4..c5da5dcd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3414,6 +3414,10 @@ iconv-lite@0.4.13, iconv-lite@~0.4.13: version "0.4.13" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.13.tgz#1f88aba4ab0b1508e8312acc39345f36e992e2f2" +iconv-lite@^0.4.19: + version "0.4.19" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" + iconv-lite@~0.2.11: version "0.2.11" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.2.11.tgz#1ce60a3a57864a292d1321ff4609ca4bb965adc8"