feat: 增加导出面板

This commit is contained in:
purocean
2021-09-01 23:01:35 +08:00
parent ca82e67e8a
commit 84a1fbd642
13 changed files with 301 additions and 60 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ const convert = async (html: string, type: string) => {
const path = os.tmpdir() + `/yn_convert_${new Date().getTime()}.${type}`
return new Promise((resolve, reject) => {
const args = ['-f', 'html', '-o', path, '--reference-doc', docxTplPath]
const args = ['--self-contained', '-f', 'html', '-o', path, '--reference-doc', docxTplPath]
console.log(binPath, args)
const process = spawn(binPath, args)
+1 -1
View File
@@ -150,7 +150,7 @@ const convertFile = async (ctx: any, next: any) => {
const html = ctx.request.body.html
const type = ctx.request.body.type
ctx.type = mime.getType(`file.${type}`)
ctx.set('content-type', 'application/octet-stream')
ctx.body = await convert(html, type)
} else {
await next()
+262
View File
@@ -0,0 +1,262 @@
<template>
<XMask :show="showExport" @close="close" :maskCloseable="false">
<div class="editor-wrapper" @click.stop>
<h3>导出</h3>
<iframe width="0" height="0" hidden id="export-download" name="export-download" @loadedmetadata="close" />
<form ref="refExportForm" :action="`/api/convert/${convert.fileName}`" method="post" target="export-download">
<input type="hidden" name="html" :value="convert.html">
<div style="padding: 20px">
<label>
格式:
<select name="type" v-model="convert.type">
<option value="pdf">PDF</option>
<option value="docx">Word (docx)</option>
<option value="html">HTML</option>
<option value="rst">reStructuredText</option>
<option value="adoc">AsciiDoc</option>
</select>
</label>
<div v-if="convert.type === 'pdf'" style="margin-top: 20px">
<hr>
<div v-if="isElectron">
<div style="margin: 10px 0"><label>方向:<select name="type" v-model="convert.pdfOptions.landscape">
<option value="">纵向</option>
<option value="true">横向</option>
</select></label></div>
<div style="margin: 10px 0"><label>页面:<select name="type" v-model="convert.pdfOptions.pageSize">
<option value="A3">A3</option>
<option value="A4">A4</option>
<option value="A5">A5</option>
<option value="Legal">Legal</option>
<option value="Letter">Letter</option>
<option value="Tabloid">Tabloid</option>
</select></label></div>
<div style="margin: 10px 0"><label>缩放:<input v-model="convert.pdfOptions.scaleFactor" type="number" max="100" min="10" setp="1" style="display: inline-block;width: 4em"></label></div>
<div style="margin: 10px 0"><label><input type="checkbox" v-model="convert.pdfOptions.printBackground"> 包含背景</label></div>
</div>
<div v-else>
将使用浏览器打印功能
</div>
</div>
</div>
<div class="action">
<button class="btn" @click.stop.prevent="close">取消</button>
<button class="btn primary" @click.stop.prevent="ok">确定</button>
</div>
</form>
</div>
</XMask>
</template>
<script lang="ts">
import { useStore } from 'vuex'
import { computed, defineComponent, reactive, ref, toRefs } from 'vue'
import { isElectron, nodeRequire } from '@fe/utils/env'
import { getActionHandler } from '@fe/context/action'
import { FLAG_DEMO } from '@fe/support/global-args'
import { triggerHook } from '@fe/context/plugin'
import { useToast } from '@fe/support/toast'
import { sleep } from '@fe/utils'
import XMask from './Mask.vue'
export default defineComponent({
name: 'export-panel',
components: { XMask },
setup () {
const store = useStore()
const toast = useToast()
const refExportForm = ref<HTMLFormElement | null>(null)
const { showExport, currentFile } = toRefs(store.state)
const fileName = computed(() => currentFile.value?.name || 'export.md')
const convert = reactive({
fileName: '',
html: '',
type: 'pdf',
pdfOptions: {
landscape: '',
pageSize: 'A4',
scaleFactor: '100',
printBackground: true,
}
})
const close = () => store.commit('setShowExport', false)
function filterHtml (html: string) {
const div = document.createElement('div')
div.innerHTML = html
const filter = (node: HTMLElement) => {
if (node.classList.contains('no-print')) {
node.remove()
return
}
if (node.dataset) {
Object.keys(node.dataset).forEach(key => {
delete node.dataset[key]
})
}
node.classList.remove('source-line')
node.removeAttribute('title')
const len = node.children.length
for (let i = len - 1; i >= 0; i--) {
const ele = node.children[i]
filter(ele as HTMLElement)
}
}
filter(div)
return div.innerHTML
}
async function exportPdf (name: string) {
if (!isElectron) {
close()
await sleep(300)
window.print()
} else {
close()
toast.show('info', '转换中,请稍候……')
await sleep(300)
const content = nodeRequire('electron').remote.getCurrentWebContents()
const { landscape, pageSize, scaleFactor, printBackground } = convert.pdfOptions
const buffer: Buffer = await content.printToPDF({
pageSize,
printBackground,
landscape: Boolean(landscape),
scaleFactor: Number(scaleFactor)
})
const blob = new Blob([buffer], { type: 'application/pdf' })
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = name + '.pdf'
link.click()
}
}
async function exportDoc () {
triggerHook('ON_DOC_BEFORE_EXPORT')
if (convert.type === 'pdf') {
exportPdf(fileName.value)
return
}
if (FLAG_DEMO) {
toast.show('warning', 'DEMO 模式下该功能不可用')
return
}
// 下载文件后关闭面板
window.addEventListener('blur', close, { once: true })
toast.show('info', '转换中,请稍候……', 5000)
let baseUrl = location.origin + location.pathname.substring(0, location.pathname.lastIndexOf('/')) + '/'
// Windows 下偶尔解析 localhost 很耗时,这里直接用 ip 代替
if (/^(http|https):\/\/localhost/i.test(baseUrl)) {
baseUrl = baseUrl.replace(/localhost/i, '127.0.0.1')
}
const html = getActionHandler('view.get-content-html')().replace(/src="api/g, `src="${baseUrl}api`);
convert.fileName = `${fileName.value}.${convert.type}`
convert.html = filterHtml(html)
await sleep(300)
refExportForm.value!.submit()
}
async function ok () {
try {
await exportDoc()
} catch (error) {
toast.show('warning', error.message)
throw error
}
}
return { showExport, refExportForm, ok, close, convert, isElectron }
},
})
</script>
<style lang="scss" scoped>
.editor-wrapper {
width: 600px;
background: var(--g-color-95);
margin: auto;
padding: 10px;
color: var(--g-color-5);
box-shadow: rgba(0, 0, 0 , 0.3) 2px 2px 10px;
h3 {
margin-top: 0;
}
}
.editor {
max-height: 50vh;
overflow: auto;
&> ::v-deep(div > .je-header),
::v-deep(.je-object__controls){
display: none;
}
::v-deep(.je-header) {
margin: 0;
}
::v-deep(.row) {
margin-bottom: 10px;
}
::v-deep(.je-indented-panel) {
border: none;
margin-right: 0;
}
::v-deep(.form-control) {
display: flex;
align-content: center;
}
::v-deep(.je-form-input-label) {
width: 90px;
// text-align: right;
display: inline-block;
flex: none;
padding-right: 14px;
line-height: 30px;
height: 30px;
}
::v-deep(.je-table) {
width: 100%;
padding-bottom: 6px;
margin-bottom: 6px;
tr > td:first-child {
width: 100px;
}
tr > td:last-child {
width: 120px;
}
}
}
.action {
display: flex;
justify-content: flex-end;
padding-top: 10px;
}
</style>
+16 -40
View File
@@ -11,15 +11,9 @@
</div>
</div>
<div v-else></div>
<div class="convert">
<iframe width="0" height="0" hidden id="preview_download" name="preview_download"></iframe>
<form ref="refConvertForm" :action="`/api/convert/${convert.fileName}`" method="post" target="preview_download">
<input type="hidden" name="html" :value="convert.html">
<input type="hidden" name="type" :value="convert.type">
<!-- <button type="button" @click="convertFile('pdf')">pdf</button> -->
<button type="button" @click="print()">打印</button>
<button type="button" @click="convertFile('docx')">docx</button>
</form>
<div class="action-btns">
<button type="button" @click="print()">打印</button>
<button type="button" @click="showExport">导出</button>
</div>
</div>
</div>
@@ -48,8 +42,7 @@ import { isElectron } from '@fe/utils/env'
import { markdown } from '@fe/context/markdown'
import { triggerHook } from '@fe/context/plugin'
import { useBus } from '@fe/support/bus'
import { useToast } from '@fe/support/toast'
import { registerAction, removeAction } from '@fe/context/action'
import { getActionHandler, registerAction, removeAction } from '@fe/context/action'
import { revealLineInCenter } from '@fe/context/editor'
import Render from './Render.vue'
@@ -63,7 +56,6 @@ export default defineComponent({
setup () {
const bus = useBus()
const store = useStore()
const toast = useToast()
const { currentContent, currentFile, autoPreview, presentation } = toRefs(store.state)
const fileName = computed(() => currentFile.value?.name)
@@ -71,7 +63,6 @@ export default defineComponent({
const refViewWrapper = ref<HTMLElement | null>(null)
const refView = ref<HTMLElement | null>(null)
const refConvertForm = ref<HTMLFormElement | null>(null)
const renderContent = ref()
@@ -85,7 +76,6 @@ export default defineComponent({
level: number;
sourceLine: number;
}[]>([])
const convert = ref({ fileName: '', html: '', type: '' })
const todoCount = ref(0)
const todoDoneCount = ref(0)
const scrollTop = ref(0)
@@ -191,38 +181,25 @@ export default defineComponent({
}
}
function convertFile (type: string) {
triggerHook('ON_VIEW_BEFORE_CONVERT', { getViewDom })
toast.show('info', '转换中,请稍候……')
let baseUrl = location.origin + location.pathname.substring(0, location.pathname.lastIndexOf('/')) + '/'
// Windows 下偶尔解析 localhost 很耗时,这里直接用 ip 代替
if (/^(http|https):\/\/localhost/i.test(baseUrl)) {
baseUrl = baseUrl.replace(/localhost/i, '127.0.0.1')
}
convert.value = {
fileName: (fileName.value || '未命名') + `.${type}`,
html: refView.value!.outerHTML.replace(/src="api/g, `src="${baseUrl}api`),
type
}
setTimeout(() => {
refConvertForm.value!.submit()
}, 300)
}
function print () {
window.print()
}
function showExport () {
getActionHandler('doc.show-export')()
}
function getContentHtml () {
return refView.value?.outerHTML || ''
}
onMounted(() => {
nextTick(renderDebonce)
triggerHook('ON_VIEW_MOUNTED', { getViewDom })
registerAction({ name: 'view.refresh', handler: render })
registerAction({ name: 'view.reveal-line', handler: revealLine })
registerAction({ name: 'view.scroll-top-to', handler: scrollTopTo })
registerAction({ name: 'view.get-content-html', handler: getContentHtml })
window.addEventListener('keydown', keydownHandler, true)
bus.on('global.resize', resizeHandler)
resizeHandler()
@@ -232,6 +209,7 @@ export default defineComponent({
removeAction('view.refresh')
removeAction('view.reveal-line')
removeAction('view.scroll-top-to')
removeAction('view.get-content-html')
window.removeEventListener('keydown', keydownHandler)
bus.off('global.resize', resizeHandler)
})
@@ -245,7 +223,6 @@ export default defineComponent({
return {
refViewWrapper,
refConvertForm,
refView,
presentation,
renderContent,
@@ -253,12 +230,11 @@ export default defineComponent({
height,
scrollToTopStyle,
heads,
convert,
todoCount,
todoDoneCount,
scrollTop,
print,
convertFile,
showExport,
handleScroll,
handleRender,
handleRendered,
@@ -360,7 +336,7 @@ export default defineComponent({
background: var(--g-color-75);
}
.convert {
.action-btns {
font-size: 14px;
pointer-events: initial;
}
+1 -1
View File
@@ -11,7 +11,7 @@ import XMask from './Mask.vue'
import Setting from './Setting.vue'
export default defineComponent({
name: 'x-filter',
name: 'setting-panel',
components: { XMask, Setting },
setup () {
const store = useStore()
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div v-if="toast" :class="{toast: true, [`toast-${toast.type}`]: true}">{{toast.content}}</div>
<div v-if="toast" :class="{toast: true, 'no-print': true, [`toast-${toast.type}`]: true}">{{toast.content}}</div>
</template>
<script lang="ts">
+8
View File
@@ -20,6 +20,9 @@ export type ActionName = 'doc.create'
| 'doc.save'
| 'doc.switch'
| 'doc.show-help'
| 'doc.show-export'
export type HookType = 'ON_DOC_BEFORE_EXPORT'
const logger = getLogger('document')
const bus = useBus()
@@ -355,6 +358,10 @@ export async function showHelp (doc: string) {
})
}
export function showExport () {
store.commit('setShowExport', true)
}
registerAction({ name: 'doc.create', handler: createDoc })
registerAction({ name: 'doc.duplicate', handler: duplicateDoc })
registerAction({ name: 'doc.delete', handler: deleteDoc })
@@ -365,3 +372,4 @@ registerAction({ name: 'doc.open-in-os', handler: openInOS })
registerAction({ name: 'doc.save', handler: saveDoc })
registerAction({ name: 'doc.switch', handler: switchDoc })
registerAction({ name: 'doc.show-help', handler: showHelp })
registerAction({ name: 'doc.show-export', handler: showExport })
+1 -1
View File
@@ -18,7 +18,7 @@ import * as editor from './editor'
import * as theme from './theme'
import * as embed from './embed'
export type CtxHookType = view.HookType | tree.HookType
export type CtxHookType = view.HookType | tree.HookType | doc.HookType
const bus = useBus()
+1 -2
View File
@@ -8,9 +8,8 @@ export type HookType = 'ON_VIEW_ELEMENT_CLICK'
| 'ON_VIEW_RENDERED'
| 'ON_VIEW_MOUNTED'
| 'ON_VIEW_FILE_CHANGE'
| 'ON_VIEW_BEFORE_CONVERT'
export type ActionName = 'view.refresh' | 'view.reveal-line' | 'view.scroll-top-to'
export type ActionName = 'view.refresh' | 'view.reveal-line' | 'view.scroll-top-to' | 'view.get-content-html'
export function refresh () {
getActionHandler('view.refresh')()
+1 -1
View File
@@ -104,7 +104,7 @@ export default {
ctx.registerHook('ON_VIEW_RENDERED', () => {
update()
})
ctx.registerHook('ON_VIEW_BEFORE_CONVERT', () => {
ctx.registerHook('ON_DOC_BEFORE_EXPORT', () => {
preparePrint() // 转换成图片形式,设置主题
setTimeout(() => update(), 0) // 恢复
})
-11
View File
@@ -11,17 +11,6 @@ if (FLAG_DEMO) {
const xOpen = window.open
const cache: {[key: string]: string} = {}
window.addEventListener('click', e => {
const target = e.target as HTMLElement
if (target.parentElement?.tagName === 'FORM' && target.innerText.includes('docx')) {
const message = 'DEMO 模式下该功能不可用'
useToast().show('warning', message)
e.preventDefault()
e.stopPropagation()
}
}, true)
window.addEventListener('error', e => {
const target = e.target as HTMLImageElement
if (target.tagName === 'IMG') {
+4
View File
@@ -45,6 +45,7 @@ export default createStore({
showXterm: false,
autoPreview: true,
showSetting: false,
showExport: false,
presentation: false,
currentContent: '',
currentRepo: Storage.get('currentRepo'),
@@ -85,6 +86,9 @@ export default createStore({
setShowSetting (state, data) {
state.showSetting = data
},
setShowExport (state, data) {
state.showExport = data
},
setPresentation (state, data) {
state.presentation = data
},
+4 -1
View File
@@ -22,6 +22,7 @@
</Layout>
<XFilter />
<SettingPanel />
<ExportPanel />
</template>
<script lang="ts">
@@ -38,6 +39,7 @@ import Editor from '@fe/components/Editor.vue'
import Preview from '@fe/components/Preview.vue'
import SettingPanel from '@fe/components/SettingPanel.vue'
import ExportPanel from '@fe/components/ExportPanel.vue'
import XFilter from '@fe/components/Filter.vue'
export default defineComponent({
@@ -52,7 +54,8 @@ export default defineComponent({
Editor,
Preview,
XFilter,
SettingPanel
SettingPanel,
ExportPanel,
},
setup () {
onMounted(startup)