diff --git a/browser/components/MarkdownPreview.js b/browser/components/MarkdownPreview.js index c1be9ef1..e305d1e0 100755 --- 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 @@ -24,6 +24,11 @@ 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) { return ` @font-face { @@ -146,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': @@ -179,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} + ` }) } @@ -188,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) { @@ -221,12 +255,16 @@ 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() @@ -266,25 +304,31 @@ export default class MarkdownPreview extends React.Component { } } - applyStyle () { - const { fontSize, lineNumber, codeBlockTheme } = this.props - let { fontFamily, codeBlockFontFamily } = this.props + getStyleParams () { + const {fontSize, lineNumber, codeBlockTheme} = 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} + } + + applyStyle () { + const {fontFamily, fontSize, codeBlockFontFamily, lineNumber, codeBlockTheme} = this.getStyleParams() + + this.getWindow().document.getElementById('codeTheme').href = this.GetCodeThemeLink(codeBlockTheme) this.getWindow().document.getElementById('style').innerHTML = buildStyle(fontFamily, fontSize, codeBlockFontFamily, lineNumber, codeBlockTheme, lineNumber) } - 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/main/lib/dataApi/copyFile.js b/browser/main/lib/dataApi/copyFile.js index b46ffd6a..2dc66309 100755 --- a/browser/main/lib/dataApi/copyFile.js +++ b/browser/main/lib/dataApi/copyFile.js @@ -2,35 +2,30 @@ const fs = require('fs') const path = require('path') /** - * @description Export a file - * @param {String} storagePath - * @param {String} srcFilename + * @description Copy a file from source to destination + * @param {String} srcPath * @param {String} dstPath - * @param {String} dstFilename if not present, destination filename will be equal to srcFilename * @return {Promise} an image path */ -function exportFile (storagePath, srcFilename, dstPath, dstFilename = '') { - dstFilename = dstFilename || srcFilename - - const src = path.join(storagePath, 'images', srcFilename) - - if (!path.extname(dstFilename)) { - dstFilename += path.extname(srcFilename) +function copyFile (srcPath, dstPath) { + if (!path.extname(dstPath)) { + dstPath = path.join(dstPath, path.basename(srcPath)) } - const dst = path.join(dstPath, dstFilename) - return new Promise((resolve, reject) => { - if (!fs.existsSync(dstPath)) fs.mkdirSync(dstPath) + const dstFolder = path.dirname(dstPath) + if (!fs.existsSync(dstFolder)) fs.mkdirSync(dstFolder) - const input = fs.createReadStream(src) - const output = fs.createWriteStream(dst) + const input = fs.createReadStream(srcPath) + const output = fs.createWriteStream(dstPath) output.on('error', reject) input.on('error', reject) - input.on('end', resolve, dst) + input.on('end', () => { + resolve(dstPath) + }) input.pipe(output) }) } -module.exports = exportFile +module.exports = copyFile diff --git a/browser/main/lib/dataApi/exportNote.js b/browser/main/lib/dataApi/exportNote.js index 368b43ca..9a3d21ac 100755 --- a/browser/main/lib/dataApi/exportNote.js +++ b/browser/main/lib/dataApi/exportNote.js @@ -1,48 +1,77 @@ -import exportImage from 'browser/main/lib/dataApi/exportImage' +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 + * @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) { - const targetStorage = findStorage(storageKey) - const storagedImagesRe = /!\[(.*?)\]\(\s*?\/:storage\/(.*\.\S*?)\)/gi +function exportNote (storageKey, noteContent, targetPath, outputFormatter) { + const storagePath = path.isAbsolute(storageKey) ? storageKey : findStorage(storageKey).path const exportTasks = [] - const images = [] - const exportedData = noteContent.replace(storagedImagesRe, (match, dstFilename, srcFilename) => { + 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 imagePath = path.join('images', dstFilename) - exportTasks.push( - exportImage(targetStorage.path, srcFilename, path.dirname(targetPath), dstFilename) - ) - images.push(imagePath) - return `` + const dstRelativePath = path.join(IMAGES_FOLDER_NAME, dstFilename) + + exportTasks.push({ + src: path.join(IMAGES_FOLDER_NAME, srcFilename), + dst: dstRelativePath + }) + + return `` }) - exportTasks.push(exportFile(exportedData, targetPath)) - return Promise.all(exportTasks) - .catch((err) => { - rollbackExport(images) - throw err - }) + 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 exportFile (data, filename) { +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) throw err @@ -53,13 +82,27 @@ function exportFile (data, filename) { } /** - * Remove exported images - * @param imagesPaths + * Remove exported files + * @param tasks Array of copy task objects. Object consists of two mandatory fields – `src` and `dst` */ -function rollbackExport (imagesPaths) { - imagesPaths.forEach((path) => { - if (fs.existsSync(path)) { - fs.unlink(path) +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) } }) }