mirror of
https://github.com/purocean/yn.git
synced 2026-09-24 15:46:02 +08:00
feat: 增加导出面板
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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')()
|
||||
|
||||
@@ -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,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') {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user