import PropTypes from 'prop-types' import React from 'react' import Markdown from 'browser/lib/markdown' import _ from 'lodash' import CodeMirror from 'codemirror' import 'codemirror-mode-elixir' import consts from 'browser/lib/consts' import Raphael from 'raphael' import flowchart from 'flowchart' import mermaidRender from './render/MermaidRender' import SequenceDiagram from 'js-sequence-diagrams' import Chart from 'chart.js' import eventEmitter from 'browser/main/lib/eventEmitter' import htmlTextHelper from 'browser/lib/htmlTextHelper' import convertModeName from 'browser/lib/convertModeName' import copy from 'copy-to-clipboard' import mdurl from 'mdurl' import exportNote from 'browser/main/lib/dataApi/exportNote' import formatMarkdown from 'browser/main/lib/dataApi/formatMarkdown' import formatHTML, { CSS_FILES, buildStyle, getCodeThemeLink, getStyleParams } from 'browser/main/lib/dataApi/formatHTML' import { escapeHtmlCharacters } from 'browser/lib/utils' import yaml from 'js-yaml' import context from 'browser/lib/context' import i18n from 'browser/lib/i18n' import fs from 'fs' import path from 'path' import uri2path from 'file-uri-to-path' import { remote, shell } from 'electron' import attachmentManagement from '../main/lib/dataApi/attachmentManagement' import filenamify from 'filenamify' const dialog = remote.dialog const scrollBarStyle = ` ::-webkit-scrollbar { width: 12px; } ::-webkit-scrollbar-thumb { background-color: rgba(0, 0, 0, 0.15); } ` const scrollBarDarkStyle = ` ::-webkit-scrollbar { width: 12px; } ::-webkit-scrollbar-thumb { background-color: rgba(0, 0, 0, 0.3); } ` 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.DoubleClickHandler = e => this.handleDoubleClick(e) this.scrollHandler = _.debounce(this.handleScroll.bind(this), 100, { leading: false, trailing: true }) 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) this.initMarkdown = this.initMarkdown.bind(this) this.initMarkdown() } initMarkdown () { const { smartQuotes, sanitize, breaks } = this.props this.markdown = new Markdown({ typographer: smartQuotes, sanitize, breaks }) } handleCheckboxClick (e) { this.props.onCheckboxClick(e) } handleScroll (e) { if (this.props.onScroll) { this.props.onScroll(e) } } handleContextMenu (event) { // If a contextMenu handler was passed to us, use it instead of the self-defined one -> return if (_.isFunction(this.props.onContextMenu)) { this.props.onContextMenu(event) return } // No contextMenu was passed to us -> execute our own link-opener if (event.target.tagName.toLowerCase() === 'a') { const href = event.target.href const isLocalFile = href.startsWith('file:') if (isLocalFile) { const absPath = uri2path(href) try { if (fs.lstatSync(absPath).isFile()) { context.popup([ { label: i18n.__('Show in explorer'), click: (e) => shell.showItemInFolder(absPath) } ]) } } catch (e) { console.log('Error while evaluating if the file is locally available', e) } } } } handleDoubleClick (e) { if (this.props.onDoubleClick != null) this.props.onDoubleClick(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 (!this.props.onMouseUp) return 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', formatMarkdown(this.props)) } handleSaveAsHtml () { this.exportAsDocument('html', formatHTML(this.props)) } handlePrint () { this.refs.root.contentWindow.print() } exportAsDocument (fileType, contentFormatter) { const note = this.props.getNote() const options = { defaultPath: filenamify(note.title, { replacement: '_' }), filters: [{ name: 'Documents', extensions: [fileType] }], properties: ['openFile', 'createDirectory'] } dialog.showSaveDialog(remote.getCurrentWindow(), options, filename => { if (filename) { const storagePath = this.props.storagePath exportNote(storagePath, note, 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) { if ( node && node.children.length === 1 && typeof node.children[0] === 'string' ) { const { innerText, href } = node node.innerText = mdurl.decode(href) === innerText ? href : innerText } } getScrollBarStyle () { const { theme } = this.props switch (theme) { case 'dark': case 'solarized-dark': case 'monokai': case 'dracula': return scrollBarDarkStyle default: return scrollBarStyle } } componentDidMount () { this.refs.root.setAttribute('sandbox', 'allow-scripts') this.refs.root.contentWindow.document.body.addEventListener( 'contextmenu', this.contextMenuHandler ) let styles = ` ` CSS_FILES.forEach(file => { styles += `` }) this.refs.root.contentWindow.document.head.innerHTML = styles 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( 'dblclick', this.DoubleClickHandler ) this.refs.root.contentWindow.document.addEventListener( 'drop', this.preventImageDroppedHandler ) this.refs.root.contentWindow.document.addEventListener( 'dragover', this.preventImageDroppedHandler ) this.refs.root.contentWindow.document.addEventListener( 'scroll', this.scrollHandler ) 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( 'dblclick', this.DoubleClickHandler ) this.refs.root.contentWindow.document.removeEventListener( 'drop', this.preventImageDroppedHandler ) this.refs.root.contentWindow.document.removeEventListener( 'dragover', this.preventImageDroppedHandler ) this.refs.root.contentWindow.document.removeEventListener( 'scroll', this.scrollHandler ) 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.smartQuotes !== this.props.smartQuotes || prevProps.sanitize !== this.props.sanitize || prevProps.smartArrows !== this.props.smartArrows || prevProps.breaks !== this.props.breaks || prevProps.lineThroughCheckbox !== this.props.lineThroughCheckbox ) { this.initMarkdown() 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 || prevProps.scrollPastEnd !== this.props.scrollPastEnd || prevProps.allowCustomCSS !== this.props.allowCustomCSS || prevProps.customCSS !== this.props.customCSS ) { this.applyStyle() this.rewriteIframe() } } applyStyle () { const { fontFamily, fontSize, codeBlockFontFamily, lineNumber, codeBlockTheme, scrollPastEnd, theme, allowCustomCSS, customCSS } = getStyleParams(this.props) this.getWindow().document.getElementById('codeTheme').href = getCodeThemeLink(codeBlockTheme) this.getWindow().document.getElementById('style').innerHTML = buildStyle( fontFamily, fontSize, codeBlockFontFamily, lineNumber, scrollPastEnd, theme, allowCustomCSS, customCSS ) } rewriteIframe () { _.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, noteKey } = this.props let { value, codeBlockTheme } = this.props this.refs.root.contentWindow.document.body.setAttribute('data-theme', theme) const renderedHTML = this.markdown.render(value) attachmentManagement.migrateAttachments(value, storagePath, noteKey) this.refs.root.contentWindow.document.body.innerHTML = attachmentManagement.fixLocalURLS( renderedHTML, storagePath ) _.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 => { this.fixDecodedURI(el) el.addEventListener('click', this.linkClickHandler) } ) codeBlockTheme = consts.THEMES.some(_theme => _theme === codeBlockTheme) ? codeBlockTheme : 'default' _.forEach( this.refs.root.contentWindow.document.querySelectorAll('.code code'), el => { let syntax = CodeMirror.findModeByName(convertModeName(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}` } else { el.parentNode.className += ` cm-s-${codeBlockTheme}` } 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.linkClickHandler) }) } catch (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.linkClickHandler) }) } catch (e) { el.className = 'sequence-error' el.innerHTML = 'Sequence diagram parse error: ' + e.message } } ) _.forEach( this.refs.root.contentWindow.document.querySelectorAll('.chart'), el => { try { const format = el.attributes.getNamedItem('data-format').value const chartConfig = format === 'yaml' ? yaml.load(el.innerHTML) : JSON.parse(el.innerHTML) el.innerHTML = '' const canvas = document.createElement('canvas') el.appendChild(canvas) const height = el.attributes.getNamedItem('data-height') if (height && height.value !== 'undefined') { el.style.height = height.value + 'vh' canvas.height = height.value + 'vh' } const chart = new Chart(canvas, chartConfig) } catch (e) { el.className = 'chart-error' el.innerHTML = 'chartjs diagram parse error: ' + e.message } } ) _.forEach( this.refs.root.contentWindow.document.querySelectorAll('.mermaid'), el => { mermaidRender(el, htmlTextHelper.decodeEntities(el.innerHTML), theme) } ) } 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) { e.preventDefault() e.stopPropagation() const href = e.target.href const linkHash = href.split('/').pop() const regexNoteInternalLink = /main.html#(.+)/ if (regexNoteInternalLink.test(linkHash)) { const targetId = mdurl.encode(linkHash.match(regexNoteInternalLink)[1]) const targetElement = this.refs.root.contentWindow.document.getElementById( targetId ) if (targetElement != null) { this.getWindow().scrollTo(0, targetElement.offsetTop) } return } // this will match the new uuid v4 hash and the old hash // e.g. // :note:1c211eb7dcb463de6490 and // :note:7dd23275-f2b4-49cb-9e93-3454daf1af9c const regexIsNoteLink = /^:note:([a-zA-Z0-9-]{20,36})$/ if (regexIsNoteLink.test(linkHash)) { eventEmitter.emit('list:jump', linkHash.replace(':note:', '')) return } const regexIsLine = /^:line:[0-9]/ if (regexIsLine.test(linkHash)) { const numberPattern = /\d+/g const lineNumber = parseInt(linkHash.match(numberPattern)[0]) eventEmitter.emit('line:jump', lineNumber) return } // this will match the old link format storage.key-note.key // e.g. // 877f99c3268608328037-1c211eb7dcb463de6490 const regexIsLegacyNoteLink = /^(.{20})-(.{20})$/ if (regexIsLegacyNoteLink.test(linkHash)) { eventEmitter.emit('list:jump', linkHash.split('-')[1]) return } // other case shell.openExternal(href) } render () { const { className, style, tabIndex } = this.props return (