import PropTypes from 'prop-types' import React from 'react' import markdown from 'browser/lib/markdown' import _ from 'lodash' import CodeMirror from 'codemirror' import consts from 'browser/lib/consts' 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' const { remote } = require('electron') const { app } = remote const path = require('path') const dialog = remote.dialog const markdownStyle = require('!!css!stylus?sourceMap!./markdown.styl')[0][1] const appPath = 'file://' + (process.env.NODE_ENV === 'production' ? app.getAppPath() : path.resolve()) function buildStyle (fontFamily, fontSize, codeBlockFontFamily, lineNumber) { return ` @font-face { font-family: 'Lato'; src: url('${appPath}/resources/fonts/Lato-Regular.woff2') format('woff2'), /* Modern Browsers */ url('${appPath}/resources/fonts/Lato-Regular.woff') format('woff'), /* Modern Browsers */ url('${appPath}/resources/fonts/Lato-Regular.ttf') format('truetype'); font-style: normal; font-weight: normal; text-rendering: optimizeLegibility; } @font-face { font-family: 'Lato'; src: url('${appPath}/resources/fonts/Lato-Black.woff2') format('woff2'), /* Modern Browsers */ url('${appPath}/resources/fonts/Lato-Black.woff') format('woff'), /* Modern Browsers */ url('${appPath}/resources/fonts/Lato-Black.ttf') format('truetype'); font-style: normal; font-weight: 700; text-rendering: optimizeLegibility; } ${markdownStyle} body { font-family: '${fontFamily.join("','")}'; font-size: ${fontSize}px; } code { font-family: '${codeBlockFontFamily.join("','")}'; background-color: rgba(0,0,0,0.04); } .lineNumber { ${lineNumber && 'display: block !important;'} font-family: '${codeBlockFontFamily.join("','")}'; } .clipboardButton { color: rgba(147,147,149,0.8);; fill: rgba(147,147,149,1);; border-radius: 50%; margin: 0px 10px; border: none; background-color: transparent; outline: none; height: 15px; width: 15px; cursor: pointer; } .clipboardButton:hover { transition: 0.2s; color: #939395; fill: #939395; background-color: rgba(0,0,0,0.1); } h1, h2 { border: none; } h1 { padding-bottom: 4px; margin: 1em 0 8px; } h2 { padding-bottom: 0.2em; margin: 1em 0 0.37em; } body p { white-space: normal; } ` } const { shell } = require('electron') const OSX = global.process.platform === 'darwin' const defaultFontFamily = ['helvetica', 'arial', 'sans-serif'] if (!OSX) { defaultFontFamily.unshift('Microsoft YaHei') defaultFontFamily.unshift('meiryo') } const defaultCodeBlockFontFamily = ['Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', 'monospace'] export default class MarkdownPreview extends React.Component { constructor (props) { super(props) this.contextMenuHandler = (e) => this.handleContextMenu(e) this.mouseDownHandler = (e) => this.handleMouseDown(e) this.mouseUpHandler = (e) => this.handleMouseUp(e) this.anchorClickHandler = (e) => this.handlePreviewAnchorClick(e) this.checkboxClickHandler = (e) => this.handleCheckboxClick(e) this.saveAsTextHandler = () => this.handleSaveAsText() this.saveAsMdHandler = () => this.handleSaveAsMd() this.saveAsHtmlHandler = () => this.handleSaveAsHtml() this.printHandler = () => this.handlePrint() this.linkClickHandler = this.handlelinkClick.bind(this) } handlePreviewAnchorClick (e) { e.preventDefault() e.stopPropagation() const anchor = e.target.closest('a') const href = anchor.getAttribute('href') if (_.isString(href) && href.match(/^#/)) { const targetElement = this.refs.root.contentWindow.document.getElementById(href.substring(1, href.length)) if (targetElement != null) { this.getWindow().scrollTo(0, targetElement.offsetTop) } } else { shell.openExternal(href) } } handleCheckboxClick (e) { this.props.onCheckboxClick(e) } handleContextMenu (e) { this.props.onContextMenu(e) } handleMouseDown (e) { if (e.target != null) { switch (e.target.tagName) { case 'A': case 'INPUT': return null } } if (this.props.onMouseDown != null) this.props.onMouseDown(e) } handleMouseUp (e) { if (e.target != null && e.target.tagName === 'A') { return null } if (this.props.onMouseUp != null) this.props.onMouseUp(e) } handleSaveAsText () { this.exportAsDocument('txt') } handleSaveAsMd () { this.exportAsDocument('md') } handleSaveAsHtml () { this.exportAsDocument('html', (value) => { return this.refs.root.contentWindow.document.documentElement.outerHTML }) } handlePrint () { this.refs.root.contentWindow.print() } exportAsDocument (fileType, formatter) { const options = { filters: [ { 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 }) } }) } fixDecodedURI (node) { if (node && node.children.length === 1 && typeof node.children[0] === 'string') { const { innerText, href } = node node.innerText = mdurl.decode(href) === innerText ? href : innerText } } componentDidMount () { this.refs.root.setAttribute('sandbox', 'allow-scripts') this.refs.root.contentWindow.document.body.addEventListener('contextmenu', this.contextMenuHandler) this.refs.root.contentWindow.document.head.innerHTML = ` ` this.rewriteIframe() this.applyStyle() this.refs.root.contentWindow.document.addEventListener('mousedown', this.mouseDownHandler) this.refs.root.contentWindow.document.addEventListener('mouseup', this.mouseUpHandler) this.refs.root.contentWindow.document.addEventListener('drop', this.preventImageDroppedHandler) this.refs.root.contentWindow.document.addEventListener('dragover', this.preventImageDroppedHandler) eventEmitter.on('export:save-text', this.saveAsTextHandler) eventEmitter.on('export:save-md', this.saveAsMdHandler) eventEmitter.on('export:save-html', this.saveAsHtmlHandler) eventEmitter.on('print', this.printHandler) } componentWillUnmount () { this.refs.root.contentWindow.document.body.removeEventListener('contextmenu', this.contextMenuHandler) this.refs.root.contentWindow.document.removeEventListener('mousedown', this.mouseDownHandler) this.refs.root.contentWindow.document.removeEventListener('mouseup', this.mouseUpHandler) this.refs.root.contentWindow.document.removeEventListener('drop', this.preventImageDroppedHandler) this.refs.root.contentWindow.document.removeEventListener('dragover', this.preventImageDroppedHandler) eventEmitter.off('export:save-text', this.saveAsTextHandler) eventEmitter.off('export:save-md', this.saveAsMdHandler) eventEmitter.off('export:save-html', this.saveAsHtmlHandler) eventEmitter.off('print', this.printHandler) } componentDidUpdate (prevProps) { if (prevProps.value !== this.props.value) this.rewriteIframe() if (prevProps.fontFamily !== this.props.fontFamily || prevProps.fontSize !== this.props.fontSize || prevProps.codeBlockFontFamily !== this.props.codeBlockFontFamily || prevProps.codeBlockTheme !== this.props.codeBlockTheme || prevProps.lineNumber !== this.props.lineNumber || prevProps.showCopyNotification !== this.props.showCopyNotification || prevProps.theme !== this.props.theme) { this.applyStyle() this.rewriteIframe() } } applyStyle () { 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 codeBlockFontFamily = _.isString(codeBlockFontFamily) && codeBlockFontFamily.trim().length > 0 ? codeBlockFontFamily.split(',').map(fontName => fontName.trim()).concat(defaultCodeBlockFontFamily) : defaultCodeBlockFontFamily this.setCodeTheme(codeBlockTheme) this.getWindow().document.getElementById('style').innerHTML = buildStyle(fontFamily, fontSize, codeBlockFontFamily, lineNumber, codeBlockTheme, lineNumber) } setCodeTheme (theme) { theme = consts.THEMES.some((_theme) => _theme === theme) && theme !== 'default' ? theme : 'elegant' this.getWindow().document.getElementById('codeTheme').href = theme.startsWith('solarized') ? `${appPath}/node_modules/codemirror/theme/solarized.css` : `${appPath}/node_modules/codemirror/theme/${theme}.css` } rewriteIframe () { _.forEach(this.refs.root.contentWindow.document.querySelectorAll('a'), (el) => { el.removeEventListener('click', this.anchorClickHandler) }) _.forEach(this.refs.root.contentWindow.document.querySelectorAll('input[type="checkbox"]'), (el) => { el.removeEventListener('click', this.checkboxClickHandler) }) _.forEach(this.refs.root.contentWindow.document.querySelectorAll('a'), (el) => { el.removeEventListener('click', this.linkClickHandler) }) const { theme, indentSize, showCopyNotification, storagePath } = this.props let { value, codeBlockTheme } = this.props this.refs.root.contentWindow.document.body.setAttribute('data-theme', theme) const codeBlocks = value.match(/(```)(.|[\n])*?(```)/g) if (codeBlocks !== null) { codeBlocks.forEach((codeBlock) => { value = value.replace(codeBlock, htmlTextHelper.encodeEntities(codeBlock)) }) } this.refs.root.contentWindow.document.body.innerHTML = markdown.render(value) _.forEach(this.refs.root.contentWindow.document.querySelectorAll('.taskListItem'), (el) => { el.parentNode.parentNode.style.listStyleType = 'none' }) _.forEach(this.refs.root.contentWindow.document.querySelectorAll('a'), (el) => { this.fixDecodedURI(el) el.addEventListener('click', this.anchorClickHandler) }) _.forEach(this.refs.root.contentWindow.document.querySelectorAll('input[type="checkbox"]'), (el) => { el.addEventListener('click', this.checkboxClickHandler) }) _.forEach(this.refs.root.contentWindow.document.querySelectorAll('a'), (el) => { el.addEventListener('click', this.linkClickHandler) }) _.forEach(this.refs.root.contentWindow.document.querySelectorAll('img'), (el) => { el.src = markdown.normalizeLinkText(el.src) if (!/\/:storage/.test(el.src)) return el.src = `file:///${markdown.normalizeLinkText(path.join(storagePath, 'images', path.basename(el.src)))}` }) codeBlockTheme = consts.THEMES.some((_theme) => _theme === codeBlockTheme) ? codeBlockTheme : 'default' _.forEach(this.refs.root.contentWindow.document.querySelectorAll('.code code'), (el) => { let syntax = CodeMirror.findModeByName(el.className) if (syntax == null) syntax = CodeMirror.findModeByName('Plain Text') CodeMirror.requireMode(syntax.mode, () => { const content = htmlTextHelper.decodeEntities(el.innerHTML) const copyIcon = document.createElement('i') copyIcon.innerHTML = '' copyIcon.onclick = (e) => { copy(content) if (showCopyNotification) { this.notify('Saved to Clipboard!', { body: 'Paste it wherever you want!', silent: true }) } } el.parentNode.appendChild(copyIcon) el.innerHTML = '' if (codeBlockTheme.indexOf('solarized') === 0) { const [refThema, color] = codeBlockTheme.split(' ') el.parentNode.className += ` cm-s-${refThema} cm-s-${color} CodeMirror` } else { el.parentNode.className += ` cm-s-${codeBlockTheme} CodeMirror` } CodeMirror.runMode(content, syntax.mime, el, { tabSize: indentSize }) }) }) const opts = {} // if (this.props.theme === 'dark') { // opts['font-color'] = '#DDD' // opts['line-color'] = '#DDD' // opts['element-color'] = '#DDD' // opts['fill'] = '#3A404C' // } _.forEach(this.refs.root.contentWindow.document.querySelectorAll('.flowchart'), (el) => { Raphael.setWindow(this.getWindow()) try { const diagram = flowchart.parse(htmlTextHelper.decodeEntities(el.innerHTML)) el.innerHTML = '' diagram.drawSVG(el, opts) _.forEach(el.querySelectorAll('a'), (el) => { el.addEventListener('click', this.anchorClickHandler) }) } catch (e) { console.error(e) el.className = 'flowchart-error' el.innerHTML = 'Flowchart parse error: ' + e.message } }) _.forEach(this.refs.root.contentWindow.document.querySelectorAll('.sequence'), (el) => { Raphael.setWindow(this.getWindow()) try { const diagram = SequenceDiagram.parse(htmlTextHelper.decodeEntities(el.innerHTML)) el.innerHTML = '' diagram.drawSVG(el, {theme: 'simple'}) _.forEach(el.querySelectorAll('a'), (el) => { el.addEventListener('click', this.anchorClickHandler) }) } catch (e) { console.error(e) el.className = 'sequence-error' el.innerHTML = 'Sequence diagram parse error: ' + e.message } }) } focus () { this.refs.root.focus() } getWindow () { return this.refs.root.contentWindow } scrollTo (targetRow) { const blocks = this.getWindow().document.querySelectorAll('body>[data-line]') for (let index = 0; index < blocks.length; index++) { let block = blocks[index] const row = parseInt(block.getAttribute('data-line')) if (row > targetRow || index === blocks.length - 1) { block = blocks[index - 1] block != null && this.getWindow().scrollTo(0, block.offsetTop) break } } } preventImageDroppedHandler (e) { e.preventDefault() e.stopPropagation() } notify (title, options) { if (global.process.platform === 'win32') { options.icon = path.join('file://', global.__dirname, '../../resources/app.png') } return new window.Notification(title, options) } handlelinkClick (e) { const noteHash = e.target.href.split('/').pop() const regexIsNoteLink = /^(.{20})-(.{20})$/ if (regexIsNoteLink.test(noteHash)) { eventEmitter.emit('list:jump', noteHash) } } render () { const { className, style, tabIndex } = this.props return (