diff --git a/browser/components/MarkdownPreview.js b/browser/components/MarkdownPreview.js old mode 100644 new mode 100755 index 62029cb0..71e1761d --- a/browser/components/MarkdownPreview.js +++ b/browser/components/MarkdownPreview.js @@ -9,10 +9,10 @@ import Raphael from 'raphael' import flowchart from 'flowchart' import SequenceDiagram from 'js-sequence-diagrams' import eventEmitter from 'browser/main/lib/eventEmitter' -import fs from 'fs' import htmlTextHelper from 'browser/lib/htmlTextHelper' import copy from 'copy-to-clipboard' import mdurl from 'mdurl' +import exportNote from 'browser/main/lib/dataApi/exportNote' const { remote } = require('electron') const { app } = remote @@ -23,6 +23,10 @@ const markdownStyle = require('!!css!stylus?sourceMap!./markdown.styl')[0][1] const appPath = 'file://' + (process.env.NODE_ENV === 'production' ? app.getAppPath() : path.resolve()) +const CSS_FILES = [ + `${appPath}/node_modules/katex/dist/katex.min.css`, + `${appPath}/node_modules/codemirror/lib/codemirror.css` +] function buildStyle (fontFamily, fontSize, codeBlockFontFamily, lineNumber, scrollPastEnd) { return ` @@ -147,12 +151,10 @@ export default class MarkdownPreview extends React.Component { } handleContextMenu (e) { - if (!this.props.onContextMenu) return this.props.onContextMenu(e) } handleMouseDown (e) { - if (!this.props.onMouseDown) return if (e.target != null) { switch (e.target.tagName) { case 'A': @@ -180,8 +182,33 @@ export default class MarkdownPreview extends React.Component { } handleSaveAsHtml () { - this.exportAsDocument('html', (value) => { - return this.refs.root.contentWindow.document.documentElement.outerHTML + this.exportAsDocument('html', (noteContent, exportTasks) => { + const {fontFamily, fontSize, codeBlockFontFamily, lineNumber, codeBlockTheme} = this.getStyleParams() + + const inlineStyles = buildStyle(fontFamily, fontSize, codeBlockFontFamily, lineNumber, codeBlockTheme, lineNumber) + const body = markdown.render(noteContent) + const files = [this.GetCodeThemeLink(codeBlockTheme), ...CSS_FILES] + + files.forEach((file) => { + file = file.replace('file://', '') + exportTasks.push({ + src: file, + dst: 'css' + }) + }) + + let styles = '' + files.forEach((file) => { + styles += `` + }) + + return ` + + + ${styles} + + ${body} + ` }) } @@ -189,23 +216,29 @@ export default class MarkdownPreview extends React.Component { this.refs.root.contentWindow.print() } - exportAsDocument (fileType, formatter) { + exportAsDocument (fileType, contentFormatter) { const options = { filters: [ - { name: 'Documents', extensions: [fileType] } + {name: 'Documents', extensions: [fileType]} ], properties: ['openFile', 'createDirectory'] } - const value = formatter ? formatter.call(this, this.props.value) : this.props.value dialog.showSaveDialog(remote.getCurrentWindow(), options, - (filename) => { - if (filename) { - fs.writeFile(filename, value, (err) => { - if (err) throw err + (filename) => { + if (filename) { + const content = this.props.value + const storage = this.props.storagePath + + exportNote(storage, content, filename, contentFormatter) + .then((res) => { + dialog.showMessageBox(remote.getCurrentWindow(), {type: 'info', message: `Exported to ${filename}`}) + }).catch((err) => { + dialog.showErrorBox('Export error', err ? err.message || err : 'Unexpected error during export') + throw err + }) + } }) - } - }) } fixDecodedURI (node) { @@ -222,13 +255,17 @@ export default class MarkdownPreview extends React.Component { this.refs.root.setAttribute('sandbox', 'allow-scripts') this.refs.root.contentWindow.document.body.addEventListener('contextmenu', this.contextMenuHandler) - this.refs.root.contentWindow.document.head.innerHTML = ` + let styles = ` - - ` + + CSS_FILES.forEach((file) => { + styles += `` + }) + + this.refs.root.contentWindow.document.head.innerHTML = styles this.rewriteIframe() this.applyStyle() @@ -269,25 +306,31 @@ export default class MarkdownPreview extends React.Component { } } - applyStyle () { + getStyleParams () { const { fontSize, lineNumber, codeBlockTheme, scrollPastEnd } = this.props let { fontFamily, codeBlockFontFamily } = this.props fontFamily = _.isString(fontFamily) && fontFamily.trim().length > 0 - ? fontFamily.split(',').map(fontName => fontName.trim()).concat(defaultFontFamily) - : defaultFontFamily + ? fontFamily.split(',').map(fontName => fontName.trim()).concat(defaultFontFamily) + : defaultFontFamily codeBlockFontFamily = _.isString(codeBlockFontFamily) && codeBlockFontFamily.trim().length > 0 - ? codeBlockFontFamily.split(',').map(fontName => fontName.trim()).concat(defaultCodeBlockFontFamily) - : defaultCodeBlockFontFamily + ? codeBlockFontFamily.split(',').map(fontName => fontName.trim()).concat(defaultCodeBlockFontFamily) + : defaultCodeBlockFontFamily - this.setCodeTheme(codeBlockTheme) + return {fontFamily, fontSize, codeBlockFontFamily, lineNumber, codeBlockTheme, scrollPastEnd} + } + + applyStyle () { + const {fontFamily, fontSize, codeBlockFontFamily, lineNumber, codeBlockTheme, scrollPastEnd} = this.getStyleParams() + + this.getWindow().document.getElementById('codeTheme').href = this.GetCodeThemeLink(codeBlockTheme) this.getWindow().document.getElementById('style').innerHTML = buildStyle(fontFamily, fontSize, codeBlockFontFamily, lineNumber, scrollPastEnd) } - setCodeTheme (theme) { + GetCodeThemeLink (theme) { theme = consts.THEMES.some((_theme) => _theme === theme) && theme !== 'default' ? theme : 'elegant' - this.getWindow().document.getElementById('codeTheme').href = theme.startsWith('solarized') + return theme.startsWith('solarized') ? `${appPath}/node_modules/codemirror/theme/solarized.css` : `${appPath}/node_modules/codemirror/theme/${theme}.css` } diff --git a/browser/components/markdown.styl b/browser/components/markdown.styl index 4938f243..8b8d80c1 100644 --- a/browser/components/markdown.styl +++ b/browser/components/markdown.styl @@ -76,7 +76,7 @@ body justify-content left li label.taskListItem - margin-left -2em + margin-left -1.8em &.checked text-decoration line-through opacity 0.5 diff --git a/browser/main/Detail/MarkdownNoteDetail.js b/browser/main/Detail/MarkdownNoteDetail.js old mode 100644 new mode 100755 diff --git a/browser/main/lib/dataApi/copyFile.js b/browser/main/lib/dataApi/copyFile.js new file mode 100755 index 00000000..2dc66309 --- /dev/null +++ b/browser/main/lib/dataApi/copyFile.js @@ -0,0 +1,31 @@ +const fs = require('fs') +const path = require('path') + +/** + * @description Copy a file from source to destination + * @param {String} srcPath + * @param {String} dstPath + * @return {Promise} an image path + */ +function copyFile (srcPath, dstPath) { + if (!path.extname(dstPath)) { + dstPath = path.join(dstPath, path.basename(srcPath)) + } + + return new Promise((resolve, reject) => { + const dstFolder = path.dirname(dstPath) + if (!fs.existsSync(dstFolder)) fs.mkdirSync(dstFolder) + + const input = fs.createReadStream(srcPath) + const output = fs.createWriteStream(dstPath) + + output.on('error', reject) + input.on('error', reject) + input.on('end', () => { + resolve(dstPath) + }) + input.pipe(output) + }) +} + +module.exports = copyFile diff --git a/browser/main/lib/dataApi/exportNote.js b/browser/main/lib/dataApi/exportNote.js new file mode 100755 index 00000000..3ed46b92 --- /dev/null +++ b/browser/main/lib/dataApi/exportNote.js @@ -0,0 +1,110 @@ +import copyFile from 'browser/main/lib/dataApi/copyFile' +import {findStorage} from 'browser/lib/findStorage' + +const fs = require('fs') +const path = require('path') + +const LOCAL_STORED_REGEX = /!\[(.*?)\]\(\s*?\/:storage\/(.*\.\S*?)\)/gi +const IMAGES_FOLDER_NAME = 'images' + +/** + * Export note together with images + * + * 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 + * + * @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) { + const storagePath = path.isAbsolute(storageKey) ? storageKey : findStorage(storageKey).path + const exportTasks = [] + + if (!storagePath) { + throw new Error('Storage path is not found') + } + + let exportedData = noteContent.replace(LOCAL_STORED_REGEX, (match, dstFilename, srcFilename) => { + if (!path.extname(dstFilename)) { + dstFilename += path.extname(srcFilename) + } + + const dstRelativePath = path.join(IMAGES_FOLDER_NAME, dstFilename) + + exportTasks.push({ + src: path.join(IMAGES_FOLDER_NAME, srcFilename), + dst: dstRelativePath + }) + + return `![${dstFilename}](${dstRelativePath})` + }) + + if (outputFormatter) { + exportedData = outputFormatter(exportedData, exportTasks) + } + + const tasks = prepareTasks(exportTasks, storagePath, path.dirname(targetPath)) + + return Promise.all(tasks.map((task) => copyFile(task.src, task.dst))) + .then(() => { + return saveToFile(exportedData, targetPath) + }).catch((err) => { + rollbackExport(tasks) + throw err + }) +} + +function prepareTasks (tasks, storagePath, targetPath) { + return tasks.map((task) => { + if (!path.isAbsolute(task.src)) { + task.src = path.join(storagePath, task.src) + } + + if (!path.isAbsolute(task.dst)) { + task.dst = path.join(targetPath, task.dst) + } + + return task + }) +} + +function saveToFile (data, filename) { + return new Promise((resolve, reject) => { + fs.writeFile(filename, data, (err) => { + if (err) return reject(err) + + resolve(filename) + }) + }) +} + +/** + * Remove exported files + * @param tasks Array of copy task objects. Object consists of two mandatory fields – `src` and `dst` + */ +function rollbackExport (tasks) { + const folders = new Set() + tasks.forEach((task) => { + let fullpath = task.dst + + if (!path.extname(task.dst)) { + fullpath = path.join(task.dst, path.basename(task.src)) + } + + if (fs.existsSync(fullpath)) { + fs.unlink(fullpath) + folders.add(path.dirname(fullpath)) + } + }) + + folders.forEach((folder) => { + if (fs.readdirSync(folder).length === 0) { + fs.rmdir(folder) + } + }) +} + +export default exportNote