From e5dc8f154030e6e15cf0776b1a3039a6c4a18b82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20H=C3=A4usler?= Date: Fri, 25 Oct 2019 03:03:33 +0200 Subject: [PATCH] Export with options (#1511) * Export with options * Fix function names and add documentation * Narrow scrollbar --- .electron-vue/webpack.renderer.config.js | 4 +- src/main/menu/actions/file.js | 56 ++- .../lib/assets/styles/headerFooterStyle.css | 99 ++++ src/muya/lib/config/index.js | 1 - src/muya/lib/index.js | 4 +- src/muya/lib/utils/exportHtml.js | 146 +++++- src/muya/lib/utils/index.js | 13 +- src/renderer/assets/styles/printService.css | 3 +- .../assets/themes/export/academic.theme.css | 97 ++++ .../assets/themes/export/liber.theme.css | 66 +++ .../components/editorWithTabs/editor.vue | 67 ++- .../components/editorWithTabs/index.vue | 4 - .../exportSettings/exportOptions.js | 61 +++ .../components/exportSettings/index.vue | 474 ++++++++++++++++++ src/renderer/pages/app.vue | 7 +- .../prefComponents/common/textBox/index.vue | 16 +- src/renderer/prefComponents/editor/index.vue | 2 +- src/renderer/services/printService.js | 1 - src/renderer/store/editor.js | 43 +- src/renderer/store/listenForMain.js | 5 +- src/renderer/util/pdf.js | 116 +++++ 21 files changed, 1203 insertions(+), 82 deletions(-) create mode 100644 src/muya/lib/assets/styles/headerFooterStyle.css create mode 100644 src/renderer/assets/themes/export/academic.theme.css create mode 100644 src/renderer/assets/themes/export/liber.theme.css create mode 100644 src/renderer/components/exportSettings/exportOptions.js create mode 100644 src/renderer/components/exportSettings/index.vue create mode 100644 src/renderer/util/pdf.js diff --git a/.electron-vue/webpack.renderer.config.js b/.electron-vue/webpack.renderer.config.js index 421546ae..96ffeb75 100644 --- a/.electron-vue/webpack.renderer.config.js +++ b/.electron-vue/webpack.renderer.config.js @@ -51,7 +51,7 @@ const rendererConfig = { } }, { - test: /(theme\-chalk(?:\/|\\)index|katex|github\-markdown|prism[\-a-z]*|\.theme)\.css$/, + test: /(theme\-chalk(?:\/|\\)index|katex|github\-markdown|prism[\-a-z]*|\.theme|headerFooterStyle)\.css$/, use: [ 'to-string-loader', 'css-loader' @@ -59,7 +59,7 @@ const rendererConfig = { }, { test: /\.css$/, - exclude: /(theme\-chalk(?:\/|\\)index|katex|github\-markdown|prism[\-a-z]*|\.theme)\.css$/, + exclude: /(theme\-chalk(?:\/|\\)index|katex|github\-markdown|prism[\-a-z]*|\.theme|headerFooterStyle)\.css$/, use: [ proMode ? MiniCssExtractPlugin.loader : 'style-loader', { loader: 'css-loader', options: { importLoaders: 1 } }, diff --git a/src/main/menu/actions/file.js b/src/main/menu/actions/file.js index c966fbf7..695d49c4 100644 --- a/src/main/menu/actions/file.js +++ b/src/main/menu/actions/file.js @@ -1,4 +1,4 @@ -import fs from 'fs' +import fs from 'fs-extra' import path from 'path' import { BrowserWindow, dialog, ipcMain, shell } from 'electron' import log from 'electron-log' @@ -14,32 +14,53 @@ import pandoc from '../../utils/pandoc' // the renderer should communicate only with the editor window for file relevant stuff. // E.g. "mt::save-tabs" --> "mt::window-save-tabs$wid:" +const getPdfPageOptions = options => { + if (!options) { + return {} + } + + const { pageSize, pageSizeWidth, pageSizeHeight, isLandscape } = options + if (pageSize === 'custom' && pageSizeWidth && pageSizeHeight) { + return { + // Note: mm to microns + pageSize: { height: pageSizeHeight * 1000, width: pageSizeWidth * 1000 }, + landscape: !!isLandscape + } + } else { + return { pageSize, landscape: !!isLandscape } + } +} + // Handle the export response from renderer process. -const handleResponseForExport = async (e, { type, content, pathname, markdown }) => { +const handleResponseForExport = async (e, { type, content, pathname, title, pageOptions }) => { const win = BrowserWindow.fromWebContents(e.sender) const extension = EXTENSION_HASN[type] const dirname = pathname ? path.dirname(pathname) : getPath('documents') - let nakedFilename = getRecommendTitleFromMarkdownString(markdown) + let nakedFilename = title if (!nakedFilename) { nakedFilename = pathname ? path.basename(pathname, '.md') : 'Untitled' } - const defaultPath = path.join(dirname, `${nakedFilename}${extension}`) + const defaultPath = path.join(dirname, `${nakedFilename}${extension}`) const { filePath, canceled } = await dialog.showSaveDialog(win, { defaultPath }) if (filePath && !canceled) { - let data = content try { - if (!content && type === 'pdf') { - data = await win.webContents.printToPDF({ printBackground: true }) + if (type === 'pdf') { + const options = { printBackground: true } + Object.assign(options, getPdfPageOptions(pageOptions)) + const data = await win.webContents.printToPDF(options) removePrintServiceFromWindow(win) + await writeFile(filePath, data, extension, {}) + } else { + if (!content) { + throw new Error('No HTML content found.') + } + await writeFile(filePath, content, extension, 'utf8') } - if (data) { - await writeFile(filePath, data, extension) - win.webContents.send('AGANI::export-success', { type, filePath }) - } + win.webContents.send('AGANI::export-success', { type, filePath }) } catch (err) { log.error(err) const ERROR_MSG = err.message || `Error happened when export ${filePath}` @@ -399,7 +420,7 @@ ipcMain.on('AGANI::format-link-click', (e, { data, dirname }) => { // --- menu ------------------------------------- export const exportFile = (win, type) => { - win.webContents.send('AGANI::export', { type }) + win.webContents.send('mt::show-export-dialog', type) } export const importFile = async win => { @@ -423,7 +444,16 @@ export const importFile = async win => { } export const print = win => { - win.webContents.send('AGANI::print') + // See GH#749, Electron#16085 and Electron#17523. + dialog.showMessageBox(win, { + type: 'info', + buttons: ['OK'], + defaultId: 0, + noLink: true, + message: 'Printing doesn\'t work', + detail: 'Printing is disabled due to an Electron upstream issue. Please export the document as PDF and print the PDF file. We apologize for the inconvenience!' + }) + // win.webContents.send('mt::show-export-dialog', 'print') } export const openFile = async win => { diff --git a/src/muya/lib/assets/styles/headerFooterStyle.css b/src/muya/lib/assets/styles/headerFooterStyle.css new file mode 100644 index 00000000..fc77603b --- /dev/null +++ b/src/muya/lib/assets/styles/headerFooterStyle.css @@ -0,0 +1,99 @@ +:root { + --footerHeaderBorderColor: #1c1c1c; +} + +table.page-container, +table.page-container > thead, +table.page-container > tfoot, +table.page-container > tbody, +table.page-container > thead > tr, +table.page-container > thead > tr > th, +table.page-container > thead > tr > td, +table.page-container > tbody > tr, +table.page-container > tbody > tr > th, +table.page-container > tbody > tr > td, +table.page-container > tfoot > tr, +table.page-container > tfoot > tr > th, +table.page-container > tfoot > tr > td { + position: relative !important; + margin: 0 !important; + padding: 0 !important; +} + +.page-header .hf-container, +.page-footer-fake .hf-container, +.page-footer .hf-container { + display: flex; + justify-content: space-between; + font-size: 0.75em; + font-weight: 400; +} + +.page-header { + display: table-header-group; +} +.page-header .hf-container { + margin-bottom: 16px; +} +.page-header.styled .hf-container { + padding-bottom: 1px; + border-bottom: 1px solid var(--footerHeaderBorderColor); +} +.page-header .hf-container > div { + flex: 1; + max-height: 100px; + overflow: hidden; +} +.page-header .header-content-left { + text-align: left; + margin-right: 4px; +} +.page-header .header-content { + text-align: center; +} +.page-header .header-content-right { + text-align: right; + margin-left: 4px; +} +.page-header.single .header-content-left, +.page-header.single .header-content-right { + display: none; +} + +.page-footer-fake { + display: table-footer-group; +} +.page-footer-fake .hf-container { + margin-top: 16px; + visibility: hidden; +} +.page-footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; +} +.page-footer.styled .hf-container { + padding-top: 1px; + border-top: 1px solid var(--footerHeaderBorderColor); +} +.page-footer .hf-container > div { + flex: 1; + white-space: nowrap; + overflow: hidden; +} +.page-footer .footer-content-left { + text-align: left; + margin-right: 14px; +} +.page-footer .footer-content { + text-align: center; +} +.page-footer .footer-content-right { + text-align: right; + margin-left: 14px; +} +.page-footer.single .footer-content-left, +.page-footer.single .footer-content-right { + display: none; +} diff --git a/src/muya/lib/config/index.js b/src/muya/lib/config/index.js index b6ca8832..aaf247fc 100644 --- a/src/muya/lib/config/index.js +++ b/src/muya/lib/config/index.js @@ -273,7 +273,6 @@ export const MUYA_DEFAULT_OPTION = { // 'mermaid': `graph LR;\nYou-->|Mark Text|Me;` // } -export const isInElectron = window && window.process && window.process.type === 'renderer' export const isOsx = window && window.navigator && /Mac/.test(window.navigator.platform) export const isWin = window && window.navigator.userAgent && /win32|wow32|win64|wow64/i.test(window.navigator.userAgent) // http[s] (domain or IPv4 or localhost or IPv6) [port] /not-white-space diff --git a/src/muya/lib/index.js b/src/muya/lib/index.js index c41cd40f..bc82d7f8 100644 --- a/src/muya/lib/index.js +++ b/src/muya/lib/index.js @@ -145,9 +145,9 @@ class Muya { return this.contentState.history.clearHistory() } - exportStyledHTML (title = '', printOptimization = false, extraCss = '') { + exportStyledHTML (options) { const { markdown } = this - return new ExportHtml(markdown, this).generate(title, printOptimization, extraCss) + return new ExportHtml(markdown, this).generate(options) } exportHtml () { diff --git a/src/muya/lib/utils/exportHtml.js b/src/muya/lib/utils/exportHtml.js index 9f041c9c..e7341632 100644 --- a/src/muya/lib/utils/exportHtml.js +++ b/src/muya/lib/utils/exportHtml.js @@ -5,6 +5,7 @@ import loadRenderer from '../renderers' import githubMarkdownCss from 'github-markdown-css/github-markdown.css' import highlightCss from 'prismjs/themes/prism.css' import katexCss from 'katex/dist/katex.css' +import footerHeaderCss from '../assets/styles/headerFooterStyle.css' import { EXPORT_DOMPURIFY_CONFIG } from '../config' import { sanitize, unescapeHtml } from '../utils' import { validEmoji } from '../ui/emojis' @@ -131,16 +132,20 @@ class ExportHtml { }, mathRenderer: this.mathRenderer }) + html = sanitize(html, EXPORT_DOMPURIFY_CONFIG) + const exportContainer = this.exportContainer = document.createElement('div') exportContainer.classList.add('ag-render-container') exportContainer.innerHTML = html document.body.appendChild(exportContainer) + // render only render the light theme of mermaid and diragram... await this.renderMermaid() await this.renderDiagram() let result = exportContainer.innerHTML exportContainer.remove() + // hack to add arrow marker to output html const pathes = document.querySelectorAll('path[id^=raphael-marker-]') const def = '' @@ -151,6 +156,7 @@ class ExportHtml { } return `${def}${str}` }) + this.exportContainer = null return result } @@ -158,20 +164,25 @@ class ExportHtml { /** * Get HTML with style * - * @param {*} title Page title - * @param {*} printOptimization Optimize HTML and CSS for printing + * @param {*} options Document options */ - async generate (title = '', printOptimization = false, extraCss = '') { + async generate (options) { + const { printOptimization } = options + // WORKAROUND: Hide Prism.js style when exporting or printing. Otherwise the background color is white in the dark theme. const highlightCssStyle = printOptimization ? `@media print { ${highlightCss} }` : highlightCss - const html = await this.renderHtml() + const html = this._prepareHtml(await this.renderHtml(), options) const katexCssStyle = this.mathRendererCalled ? katexCss : '' + this.mathRendererCalled = false + + // `extraCss` may changed in the mean time. + const { title, extraCss } = options return ` - ${title} + ${sanitize(title, EXPORT_DOMPURIFY_CONFIG)} @@ -189,6 +200,28 @@ class ExportHtml { margin: 0 auto; padding: 45px; } + + @media not print { + .markdown-body { + padding: 45px; + } + + @media (max-width: 767px) { + .markdown-body { + padding: 15px; + } + } + } + + .hf-container { + color: #24292e; + line-height: 1.3; + } + + .markdown-body .highlight pre, + .markdown-body pre { + white-space: pre-wrap; + } .markdown-body table { display: table; } @@ -202,12 +235,6 @@ class ExportHtml { margin-top: 0; display: inline-block; } - @media (max-width: 767px) { - .markdown-body { - padding: 15px; - } - } - .markdown-body ol ol, .markdown-body ul ol { list-style-type: decimal; @@ -222,12 +249,105 @@ class ExportHtml { -
${html} -
` } + + /** + * @private + * + * @param {string} html The converted HTML text. + * @param {*} options The export options. + */ + _prepareHtml (html, options) { + const { header, footer } = options + const appendHeaderFooter = !!header || !!footer + if (!appendHeaderFooter) { + return createMarkdownArticle(html) + } + + if (!options.extraCss) { + options.extraCss = footerHeaderCss + } else { + options.extraCss = footerHeaderCss + options.extraCss + } + + let output = HF_TABLE_START + if (header) { + output += createTableHeader(options) + } + + if (footer) { + output += HF_TABLE_FOOTER() + output = createRealFooter(options) + output + } + + output = output + createTableBody(html) + HF_TABLE_END + return sanitize(output, EXPORT_DOMPURIFY_CONFIG) + } +} + +// Variables and function to generate the header and footer. +const HF_TABLE_START = '' +const createTableBody = html => { + return `` +} +const HF_TABLE_END = '
+
+ ${createMarkdownArticle(html)} +
+
' + +/// The header at is shown at the top. +const createTableHeader = options => { + const { header, headerFooterStyled } = options + const { type, left, center, right } = header + let headerClass = type === 1 ? 'single' : '' + headerClass += getHeaderFooterStyledClass(headerFooterStyled) + return ` +
+
${left}
+
${center}
+
${right}
+
+` +} + +/// Fake footer to reserve space. +const HF_TABLE_FOOTER = ` +
+   +
+` + +/// The real footer at is shown at the bottom. +const createRealFooter = options => { + const { footer, headerFooterStyled } = options + const { type, left, center, right } = footer + let footerClass = type === 1 ? 'single' : '' + footerClass += getHeaderFooterStyledClass(headerFooterStyled) + return `` +} + +/// Generate the mardown article HTML. +const createMarkdownArticle = html => { + return `
${html}
` +} + +/// Return the class whether a header/footer should be styled. +const getHeaderFooterStyledClass = value => { + if (value === undefined) { + // Prefer theme settings. + return '' + } + return !value ? ' simple' : ' styled' } export default ExportHtml diff --git a/src/muya/lib/utils/index.js b/src/muya/lib/utils/index.js index cb5c1d1e..c4cbb103 100644 --- a/src/muya/lib/utils/index.js +++ b/src/muya/lib/utils/index.js @@ -1,6 +1,7 @@ -// DOTO: Don't use Node API in editor folder, remove `path` @jocs import createDOMPurify from 'dompurify' -import { isInElectron, URL_REG } from '../config' +import { URL_REG } from '../config' + +const { sanitize: runSanitize } = createDOMPurify(window) const ID_PREFIX = 'ag-' let id = 0 @@ -263,7 +264,7 @@ export const getImageInfo = (src, baseUrl = window.DIRNAME) => { if (imageExtension) { const isAbsoluteLocal = /^(?:\/|\\\\|[a-zA-Z]:\\).+/.test(src) if (isUrl || (!isAbsoluteLocal && !baseUrl)) { - if (!isUrl && !baseUrl && isInElectron) { + if (!isUrl && !baseUrl) { console.warn('"baseUrl" is not defined!') } @@ -271,14 +272,13 @@ export const getImageInfo = (src, baseUrl = window.DIRNAME) => { isUnknownType: false, src } - } else if (isInElectron) { + } else { // Correct relative path on desktop. If we resolve a absolute path "path.resolve" doesn't do anything. return { isUnknownType: false, src: 'file://' + require('path').resolve(baseUrl, src) } } - // else: Forbid the request due absolute or relative path in browser } else if (isUrl && !imageExtension) { // Assume it's a valid image and make a http request later return { @@ -367,8 +367,7 @@ export const mixins = (constructor, ...object) => { } export const sanitize = (html, options) => { - const DOMPurify = createDOMPurify(window) - return DOMPurify.sanitize(escapeInBlockHtml(html), options) + return runSanitize(escapeInBlockHtml(html), options) } export const getParagraphReference = (ele, id) => { diff --git a/src/renderer/assets/styles/printService.css b/src/renderer/assets/styles/printService.css index 1c012053..14722f05 100644 --- a/src/renderer/assets/styles/printService.css +++ b/src/renderer/assets/styles/printService.css @@ -26,7 +26,8 @@ body article.print-container { height: auto !important; } - body > div { + body > div, + body > svg { display: none !important; } diff --git a/src/renderer/assets/themes/export/academic.theme.css b/src/renderer/assets/themes/export/academic.theme.css new file mode 100644 index 00000000..792c6937 --- /dev/null +++ b/src/renderer/assets/themes/export/academic.theme.css @@ -0,0 +1,97 @@ +/** Academic **/ + +/* DejaVu Serif --> 11pt */ +/* Don't use "Noto Color Emoji" because it will result in PDF files with multiple MB and weird looking emojis. */ +.hf-container, +.markdown-body { + font-family: Georgia,Palatino,"Palatino Linotype","Times New Roman","DejaVu Serif",serif,"Apple Color Emoji","Segoe UI Emoji"; + font-size: 12pt; + line-height: 1.3; + color: #212121; +} + +:root { + --footerHeaderBorderColor: #1c1c1c; +} +.hf-container { + font-size: 0.75em; +} +.page-header:not(.simple) .hf-container { + padding-bottom: 2px; + border-bottom: 1px solid var(--footerHeaderBorderColor); +} +.page-footer:not(.simple) .hf-container { + padding-top: 2px; + border-top: 1px solid var(--footerHeaderBorderColor); +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + font-weight: 600; + color: #282828; +} + +.markdown-body h1 { + font-size: 1.8em; +} +.markdown-body h2 { + font-size: 1.6em; +} +.markdown-body h3 { + font-size: 1.4em; +} +.markdown-body h4, +.markdown-body h5 { + font-size: 1.2em; +} +.markdown-body h6 { + font-size: 1em; +} + +.markdown-body h1, +.markdown-body h2 { + border-bottom: none; + padding-bottom: 0; +} + +.markdown-body a { + color: #212121; + text-decoration: underline; +} + +.markdown-body hr { + background-color: #b0b0b0; + height: 2px; +} + +.markdown-body blockquote { + border: none; + color: #282828; + padding: 0 1em; + margin-left: 20px; +} + +.markdown-body .highlight pre, +.markdown-body pre { + background-color: #f6f8fa; + border: 1px solid #e5e5e5; + border-radius: 0; +} + +.markdown-body table td, +.markdown-body table th { + border: 1px solid #1a1a1a; + padding: 2px 6px; +} +.markdown-body table tr { + background: none; + border-top: 1px solid #1a1a1a; + text-align: left; +} +.markdown-body table tr:nth-child(2n) { + background: none; +} diff --git a/src/renderer/assets/themes/export/liber.theme.css b/src/renderer/assets/themes/export/liber.theme.css new file mode 100644 index 00000000..bcaf3604 --- /dev/null +++ b/src/renderer/assets/themes/export/liber.theme.css @@ -0,0 +1,66 @@ +/** Liber **/ + +/* Don't use "Noto Color Emoji" because it will result in PDF files with multiple MB and weird looking emojis. */ +.hf-container, +.markdown-body { + font-family: "Source Sans Pro","Segoe UI","Helvetica Neue","Open Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji"; + font-size: 16px; + line-height: 1.3; + color: #212121; +} + +:root { + --footerHeaderBorderColor: #1c1c1c; +} +.hf-container { + font-size: 0.75em; +} +.page-header:not(.simple) .hf-container { + padding-bottom: 2px; + border-bottom: 1px solid var(--footerHeaderBorderColor); +} +.page-footer:not(.simple) .hf-container { + padding-top: 2px; + border-top: 1px solid var(--footerHeaderBorderColor); +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + font-weight: 600; + color: #282828; +} + +.markdown-body h1 { + font-size: 1.8em; +} +.markdown-body h2 { + font-size: 1.6em; +} +.markdown-body h3 { + font-size: 1.4em; +} +.markdown-body h4, +.markdown-body h5 { + font-size: 1.2em; +} +.markdown-body h6 { + font-size: 1em; + font-weight: 400; +} + +.markdown-body h1, +.markdown-body h2 { + border-bottom: none; + padding-bottom: 0; +} + +.markdown-body .highlight pre, +.markdown-body pre { + background-color: #f6f8fa; + border: 1px solid #e5e5e5; + border-radius: 0; +} diff --git a/src/renderer/components/editorWithTabs/editor.vue b/src/renderer/components/editorWithTabs/editor.vue index 39042936..dee66f53 100644 --- a/src/renderer/components/editorWithTabs/editor.vue +++ b/src/renderer/components/editorWithTabs/editor.vue @@ -100,6 +100,7 @@ import { offsetToWordCursor, validateLineCursor, SpellChecker } from '@/spellche import { isOsx, animatedScrollTo } from '@/util' import { moveImageToFolder, uploadImage } from '@/util/fileSystem' import { guessClipboardFilePath } from '@/util/clipboard' +import { getCssForOptions } from '@/util/pdf' import { addCommonStyle, setEditorWidth } from '@/util/theme' import 'muya/themes/default.css' @@ -114,9 +115,6 @@ export default { Search }, props: { - filename: { - type: String - }, markdown: String, cursor: Object, textDirection: { @@ -534,7 +532,6 @@ export default { bus.$on('deleteParagraph', this.handleParagraph) bus.$on('insertParagraph', this.handleInsertParagraph) bus.$on('scroll-to-header', this.scrollToHeader) - bus.$on('print', this.handlePrint) bus.$on('screenshot-captured', this.handleScreenShot) bus.$on('switch-spellchecker-language', this.switchSpellcheckLanguage) @@ -878,27 +875,60 @@ export default { this.scrollToHighlight() }, - async handlePrint () { - // generate styled HTML with empty title and optimized for printing - const html = await this.editor.exportStyledHTML('', true) - this.printer.renderMarkdown(html, true) - this.$store.dispatch('PRINT_RESPONSE') - }, + async handleExport (options) { + const { + type, + header, + footer, + headerFooterStyled, + htmlTitle + } = options + if (!/^pdf|print|styledHtml$/.test(type)) { + throw new Error(`Invalid type to export: "${type}".`) + } - async handleExport (type) { - const markdown = this.editor.getMarkdown() + const extraCss = getCssForOptions(options) switch (type) { case 'styledHtml': { - const content = await this.editor.exportStyledHTML(this.filename) - this.$store.dispatch('EXPORT', { type, content, markdown }) + const content = await this.editor.exportStyledHTML({ + title: htmlTitle || '', + printOptimization: false, + extraCss + }) + this.$store.dispatch('EXPORT', { type, content }) break } - case 'pdf': { - // generate styled HTML with empty title and optimized for printing - const html = await this.editor.exportStyledHTML('', true) + // NOTE: We need to set page size via Electron. + const { pageSize, pageSizeWidth, pageSizeHeight, isLandscape } = options + const pageOptions = { + pageSize, pageSizeWidth, pageSizeHeight, isLandscape + } + + const html = await this.editor.exportStyledHTML({ + title: '', + printOptimization: true, + extraCss, + header, + footer, + headerFooterStyled + }) this.printer.renderMarkdown(html, true) - this.$store.dispatch('EXPORT', { type, markdown }) + this.$store.dispatch('EXPORT', { type, pageOptions }) + break + } + case 'print': { + // NOTE: Print doesn't support page size or orientation. + const html = await this.editor.exportStyledHTML({ + title: '', + printOptimization: true, + extraCss, + header, + footer, + headerFooterStyled + }) + this.printer.renderMarkdown(html, true) + this.$store.dispatch('PRINT_RESPONSE') break } } @@ -1021,7 +1051,6 @@ export default { bus.$off('deleteParagraph', this.handleParagraph) bus.$off('insertParagraph', this.handleInsertParagraph) bus.$off('scroll-to-header', this.scrollToHeader) - bus.$off('print', this.handlePrint) bus.$off('screenshot-captured', this.handleScreenShot) bus.$off('switch-spellchecker-language', this.switchSpellcheckLanguage) diff --git a/src/renderer/components/editorWithTabs/index.vue b/src/renderer/components/editorWithTabs/index.vue index 427e1735..ee7946bd 100644 --- a/src/renderer/components/editorWithTabs/index.vue +++ b/src/renderer/components/editorWithTabs/index.vue @@ -5,7 +5,6 @@
+ + + + + + + diff --git a/src/renderer/pages/app.vue b/src/renderer/pages/app.vue index b59a866d..50dad342 100644 --- a/src/renderer/pages/app.vue +++ b/src/renderer/pages/app.vue @@ -20,7 +20,6 @@ + @@ -44,6 +44,7 @@ import TitleBar from '@/components/titleBar' import SideBar from '@/components/sideBar' import Aidou from '@/components/aidou/aidou' import AboutDialog from '@/components/about' +import ExportSettingDialog from '@/components/exportSettings' import Rename from '@/components/rename' import Tweet from '@/components/tweet' import ImportModal from '@/components/import' @@ -61,6 +62,7 @@ export default { TitleBar, SideBar, AboutDialog, + ExportSettingDialog, Rename, Tweet, ImportModal @@ -118,7 +120,7 @@ export default { // module: listenForMain dispatch('LISTEN_FOR_EDIT') dispatch('LISTEN_FOR_VIEW') - dispatch('LISTEN_FOR_ABOUT_DIALOG') + dispatch('LISTEN_FOR_SHOW_DIALOG') dispatch('LISTEN_FOR_PARAGRAPH_INLINE_STYLE') // module: project dispatch('LISTEN_FOR_UPDATE_PROJECT') @@ -136,7 +138,6 @@ export default { dispatch('LISTEN_FOR_SET_PATHNAME') dispatch('LISTEN_FOR_BOOTSTRAP_WINDOW') dispatch('LISTEN_FOR_SAVE_CLOSE') - dispatch('LISTEN_FOR_EXPORT_PRINT') dispatch('LISTEN_FOR_RENAME') dispatch('LINTEN_FOR_SET_LINE_ENDING') dispatch('LISTEN_FOR_NEW_TAB') diff --git a/src/renderer/prefComponents/common/textBox/index.vue b/src/renderer/prefComponents/common/textBox/index.vue index 6b09a83b..7b6df3ff 100644 --- a/src/renderer/prefComponents/common/textBox/index.vue +++ b/src/renderer/prefComponents/common/textBox/index.vue @@ -43,9 +43,15 @@ export default { type: String, default: '' }, + emitTime: { + type: Number, + default: 800 + }, regexValidator: { type: RegExp, - default: /(.*?)/ + default () { + return /(.*?)/ + } } }, watch: { @@ -71,11 +77,17 @@ export default { clearTimeout(this.inputTimer) } + const { emitTime } = this + if (emitTime === 0) { + this.onChange(value) + return + } + // Setting delay a little bit higher to prevent continuously file writes when typing. this.inputTimer = setTimeout(() => { this.inputTimer = null this.onChange(value) - }, 800) + }, emitTime) } } } diff --git a/src/renderer/prefComponents/editor/index.vue b/src/renderer/prefComponents/editor/index.vue index de75761c..40a3e747 100644 --- a/src/renderer/prefComponents/editor/index.vue +++ b/src/renderer/prefComponents/editor/index.vue @@ -99,7 +99,7 @@ { - bus.$emit('export', type) - }) - ipcRenderer.on('AGANI::print', e => { - bus.$emit('print') - }) - }, - - EXPORT ({ commit, state }, { type, content, markdown }) { + EXPORT ({ state }, { type, content, pageOptions }) { if (!hasKeys(state.currentFile)) return + + // Extract title from TOC buffer. + let title = '' + const { listToc } = state + if (listToc && listToc.length > 0) { + let headerRef = listToc[0] + + // The main title should be at the beginning of the document. + const len = Math.min(listToc.length, 6) + for (let i = 1; i < len; ++i) { + if (headerRef.lvl === 1) { + break + } + + const header = listToc[i] + if (headerRef.lvl > header.lvl) { + headerRef = header + } + } + title = headerRef.content + } + const { filename, pathname } = state.currentFile - ipcRenderer.send('AGANI::response-export', { type, content, filename, pathname, markdown }) + ipcRenderer.send('AGANI::response-export', { + type, + title, + content, + filename, + pathname, + pageOptions + }) }, LINTEN_FOR_EXPORT_SUCCESS ({ commit }) { diff --git a/src/renderer/store/listenForMain.js b/src/renderer/store/listenForMain.js index 6719da36..4800a805 100644 --- a/src/renderer/store/listenForMain.js +++ b/src/renderer/store/listenForMain.js @@ -21,10 +21,13 @@ const actions = { }) }, - LISTEN_FOR_ABOUT_DIALOG ({ commit }) { + LISTEN_FOR_SHOW_DIALOG ({ commit }) { ipcRenderer.on('AGANI::about-dialog', e => { bus.$emit('aboutDialog') }) + ipcRenderer.on('mt::show-export-dialog', (e, type) => { + bus.$emit('showExportDialog', type) + }) }, LISTEN_FOR_PARAGRAPH_INLINE_STYLE ({ commit }) { diff --git a/src/renderer/util/pdf.js b/src/renderer/util/pdf.js new file mode 100644 index 00000000..b353225b --- /dev/null +++ b/src/renderer/util/pdf.js @@ -0,0 +1,116 @@ +import fs from 'fs-extra' +import path from 'path' +import createDOMPurify from 'dompurify' +import { isFile } from 'common/filesystem' +import academicTheme from '@/assets/themes/export/academic.theme.css' +import liberTheme from '@/assets/themes/export/liber.theme.css' + +const { sanitize } = createDOMPurify(window) + +export const getCssForOptions = options => { + const { + type, + pageMarginTop, + pageMarginRight, + pageMarginBottom, + pageMarginLeft, + fontFamily, + fontSize, + lineHeight, + autoNumberingHeadings, + showFrontMatter, + theme, + headerFooterFontSize + } = options + const isPrintable = type !== 'styledHtml' + + let output = '' + if (isPrintable) { + output += `@media print{@page{ + margin: ${pageMarginTop}mm ${pageMarginRight}mm ${pageMarginBottom}mm ${pageMarginLeft}mm;}` + } + + // Font options + output += '.markdown-body{' + if (fontFamily) { + output += `font-family:"${fontFamily}",${FALLBACK_FONT_FAMILIES};` + output = `.hf-container{font-family:"${fontFamily}",${FALLBACK_FONT_FAMILIES};}${output}` + } + if (fontSize) { + output += `font-size:${fontSize}px;` + } + if (lineHeight) { + output += `line-height:${lineHeight};` + } + output += '}' + + // Auto numbering headings via CSS + if (autoNumberingHeadings) { + output += autoNumberingHeadingsCss + } + + // Hide front matter + if (!showFrontMatter) { + output += 'pre.front-matter{display:none!important;}' + } + + if (theme) { + if (theme === 'academic') { + output += academicTheme + } else if (theme === 'liber') { + output += liberTheme + } else { + // Read theme from disk + const { userDataPath } = global.marktext.paths + const themePath = path.join(userDataPath, 'themes/export', theme) + if (isFile(themePath)) { + try { + const themeCSS = fs.readFileSync(themePath, 'utf8') + output += themeCSS + } catch (_) { + // No-op + } + } + } + } + + if (headerFooterFontSize) { + output += `.page-header .hf-container, + .page-footer-fake .hf-container, + .page-footer .hf-container { + font-size: ${headerFooterFontSize}px; + }` + } + + if (isPrintable) { + // Close @page + output += '}' + } + return sanitize(output, EXPORT_DOMPURIFY_CONFIG) +} + +const EXPORT_DOMPURIFY_CONFIG = { + FORBID_ATTR: ['contenteditable'], + ALLOW_DATA_ATTR: false, + USE_PROFILES: { + html: true, + svg: true, + svgFilters: true, + mathMl: true + } +} + +// Don't use "Noto Color Emoji" because it will result in PDF files with multiple MB and weird looking emojis. +const FALLBACK_FONT_FAMILIES = '"Open Sans","Segoe UI","Helvetica Neue",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"' + +const autoNumberingHeadingsCss = `body {counter-reset: h2} +h2 {counter-reset: h3} +h3 {counter-reset: h4} +h4 {counter-reset: h5} +h5 {counter-reset: h6} +h2:before {counter-increment: h2; content: counter(h2) ". "} +h3:before {counter-increment: h3; content: counter(h2) "." counter(h3) ". "} +h4:before {counter-increment: h4; content: counter(h2) "." counter(h3) "." counter(h4) ". "} +h5:before {counter-increment: h5; content: counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) ". "} +h6:before {counter-increment: h6; content: counter(h2) "." counter(h3) "." counter(h4) "." counter(h5) "." counter(h6) ". "} +h2.nocount:before, h3.nocount:before, h4.nocount:before, h5.nocount:before, h6.nocount:before { content: ""; counter-increment: none }`