feat(思维导图助手):优化思维导图助手
parent
8509f351a4
commit
a598099fba
|
|
@ -14,6 +14,29 @@
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="导出分辨率" class="form-item">
|
||||||
|
<el-select v-model="exportResolutionPreset" style="width: 100%" class="select">
|
||||||
|
<el-option
|
||||||
|
v-for="option in exportResolutionOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-input-number
|
||||||
|
v-if="exportResolutionPreset === 'custom'"
|
||||||
|
v-model="draftConfig.exportResolution"
|
||||||
|
:min="720"
|
||||||
|
:max="16384"
|
||||||
|
:step="100"
|
||||||
|
controls-position="right"
|
||||||
|
class="export-resolution-input"
|
||||||
|
/>
|
||||||
|
<div class="form-item-description">
|
||||||
|
PNG/JPEG 导出长边上限;默认 8K,导出文件会按该最大长边生效。
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
<!-- 解析后端 -->
|
<!-- 解析后端 -->
|
||||||
<el-form-item :label="$t('config.backend')" class="form-item">
|
<el-form-item :label="$t('config.backend')" class="form-item">
|
||||||
<el-select
|
<el-select
|
||||||
|
|
@ -113,9 +136,9 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import type { DocumentConfig } from '@/composables/useDocumentProcessor'
|
import { DEFAULT_DOCUMENT_CONFIG, type DocumentConfig } from '@/composables/useDocumentProcessor'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: DocumentConfig
|
modelValue: DocumentConfig
|
||||||
|
|
@ -126,16 +149,45 @@ interface Props {
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'update:modelValue', value: DocumentConfig): void
|
(e: 'update:modelValue', value: DocumentConfig): void
|
||||||
(e: 'backendChange', backend: string): void
|
(e: 'backendChange', backend: string): void
|
||||||
|
(e: 'close'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const exportResolutionOptions = [
|
||||||
|
{ label: '1080P', value: 1080 },
|
||||||
|
{ label: '2K', value: 2560 },
|
||||||
|
{ label: '4K', value: 3840 },
|
||||||
|
{ label: '8K', value: 7680 },
|
||||||
|
{ label: '自定义', value: 'custom' }
|
||||||
|
] as const
|
||||||
|
|
||||||
const createDraft = (value: DocumentConfig): DocumentConfig => ({ ...value })
|
const createDraft = (value: DocumentConfig): DocumentConfig => ({ ...value })
|
||||||
const draftConfig = reactive<DocumentConfig>(createDraft(props.modelValue))
|
const draftConfig = reactive<DocumentConfig>(createDraft(props.modelValue))
|
||||||
|
|
||||||
|
const getExportResolutionPreset = (resolution: number): number | 'custom' => {
|
||||||
|
const isPreset = exportResolutionOptions.some((option) => {
|
||||||
|
return typeof option.value === 'number' && option.value === resolution
|
||||||
|
})
|
||||||
|
return isPreset ? resolution : 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportResolutionPreset = ref<number | 'custom'>(getExportResolutionPreset(props.modelValue.exportResolution))
|
||||||
|
|
||||||
|
watch(exportResolutionPreset, (value) => {
|
||||||
|
if (value === 'custom') {
|
||||||
|
if (getExportResolutionPreset(draftConfig.exportResolution) !== 'custom') {
|
||||||
|
draftConfig.exportResolution = 3840
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draftConfig.exportResolution = value
|
||||||
|
})
|
||||||
|
|
||||||
const syncDraft = (value: DocumentConfig) => {
|
const syncDraft = (value: DocumentConfig) => {
|
||||||
Object.assign(draftConfig, createDraft(value))
|
Object.assign(draftConfig, createDraft(value))
|
||||||
|
exportResolutionPreset.value = getExportResolutionPreset(value.exportResolution)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|
@ -159,7 +211,12 @@ const onBackendChange = (backend: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetDraft = () => {
|
const resetDraft = () => {
|
||||||
syncDraft(props.modelValue)
|
const defaults = createDraft(DEFAULT_DOCUMENT_CONFIG)
|
||||||
|
syncDraft(defaults)
|
||||||
|
emit('update:modelValue', defaults)
|
||||||
|
emit('backendChange', defaults.backend)
|
||||||
|
ElMessage.success('已恢复默认设置')
|
||||||
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmConfig = () => {
|
const confirmConfig = () => {
|
||||||
|
|
@ -167,6 +224,7 @@ const confirmConfig = () => {
|
||||||
emit('update:modelValue', confirmed)
|
emit('update:modelValue', confirmed)
|
||||||
emit('backendChange', confirmed.backend)
|
emit('backendChange', confirmed.backend)
|
||||||
ElMessage.success('设置已确认')
|
ElMessage.success('设置已确认')
|
||||||
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFormulaLabel = (backend: string) => {
|
const getFormulaLabel = (backend: string) => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="markdown-renderer">
|
<div class="markdown-renderer">
|
||||||
<template v-if="content">
|
<template v-if="content">
|
||||||
<div class="render-actions">
|
<div class="content-toolbar render-actions">
|
||||||
<div class="render-actions-left">
|
<div class="render-actions-left">
|
||||||
<el-segmented
|
<el-segmented
|
||||||
v-if="mode === 'markdown'"
|
v-if="mode === 'markdown'"
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="render-actions-right">
|
||||||
<el-button type="primary" size="small" class="action-button primary" @click="handleDownload()">
|
<el-button type="primary" size="small" class="action-button primary" @click="handleDownload()">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<el-icon><Download /></el-icon>
|
<el-icon><Download /></el-icon>
|
||||||
|
|
@ -23,6 +24,7 @@
|
||||||
下载
|
下载
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="render-content">
|
<div class="render-content">
|
||||||
<div v-if="mode === 'markdown'" class="render-shell">
|
<div v-if="mode === 'markdown'" class="render-shell">
|
||||||
<div ref="markdownRef" class="markdown-content rendered-html" v-html="renderedContent"></div>
|
<div ref="markdownRef" class="markdown-content rendered-html" v-html="renderedContent"></div>
|
||||||
|
|
@ -52,11 +54,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="render-shell">
|
<div v-else class="render-shell">
|
||||||
<div class="richtext-stage">
|
<div ref="richtextRef" class="markdown-content rendered-html richtext-content" v-html="renderedContent"></div>
|
||||||
<article ref="richtextRef" class="richtext-document">
|
|
||||||
<div class="markdown-content richtext-content" v-html="renderedContent"></div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -390,22 +388,35 @@ async function handleDownload() {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
|
box-shadow: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.render-actions {
|
.content-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 16px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
position: sticky;
|
}
|
||||||
top: 0;
|
|
||||||
z-index: 2;
|
.content-toolbar-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #343A40;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-toolbar-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #86909C;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-actions {
|
||||||
|
padding: 0 0 12px;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -414,6 +425,15 @@ async function handleDownload() {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-actions-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-button {
|
.action-button {
|
||||||
|
|
@ -447,7 +467,7 @@ async function handleDownload() {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0 16px 16px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.render-header {
|
.render-header {
|
||||||
|
|
@ -473,7 +493,7 @@ async function handleDownload() {
|
||||||
.markdown-content {
|
.markdown-content {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 10px;
|
border-radius: 4px;
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -483,10 +503,9 @@ async function handleDownload() {
|
||||||
|
|
||||||
.pdf-stage {
|
.pdf-stage {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 12px;
|
border: 1px solid #E9ECEF;
|
||||||
background:
|
border-radius: 4px;
|
||||||
radial-gradient(circle at top, rgba(22, 93, 255, 0.08), transparent 30%),
|
background: #F8F9FA;
|
||||||
#eef2f7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdf-preview-pages {
|
.pdf-preview-pages {
|
||||||
|
|
@ -522,17 +541,18 @@ async function handleDownload() {
|
||||||
|
|
||||||
.richtext-stage {
|
.richtext-stage {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 12px;
|
border: 1px solid #E9ECEF;
|
||||||
background: linear-gradient(180deg, #f3f4f6 0%, #eef2f7 100%);
|
border-radius: 4px;
|
||||||
|
background: #F8F9FA;
|
||||||
}
|
}
|
||||||
|
|
||||||
.richtext-document {
|
.richtext-document {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #dde3ea;
|
border: 1px solid #dde3ea;
|
||||||
border-radius: 12px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.richtext-content {
|
.richtext-content {
|
||||||
|
|
@ -633,9 +653,19 @@ async function handleDownload() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.render-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
.render-actions-left {
|
.render-actions-left {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-actions-right {
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdf-page {
|
.pdf-page {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="mindmap-container">
|
<div class="mindmap-container">
|
||||||
<div class="mindmap-actions">
|
<div class="content-toolbar mindmap-actions">
|
||||||
<span class="mindmap-subtitle" v-if="content">已生成 {{ nodeCount }} 个节点</span>
|
<div class="mindmap-actions-left">
|
||||||
|
<span class="content-toolbar-meta">{{ nodeSummary }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mindmap-actions-right">
|
||||||
<el-dropdown @command="handleDownload">
|
<el-dropdown @command="handleDownload">
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
|
|
@ -16,12 +19,9 @@
|
||||||
</el-button>
|
</el-button>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item command="full-svg">全部导图 SVG</el-dropdown-item>
|
<el-dropdown-item command="svg">SVG 格式</el-dropdown-item>
|
||||||
<el-dropdown-item command="full-png">全部导图 PNG</el-dropdown-item>
|
<el-dropdown-item command="png">PNG 格式</el-dropdown-item>
|
||||||
<el-dropdown-item command="full-jpeg">全部导图 JPEG</el-dropdown-item>
|
<el-dropdown-item command="jpeg">JPEG 格式</el-dropdown-item>
|
||||||
<el-dropdown-item divided command="viewport-svg">当前窗口 SVG</el-dropdown-item>
|
|
||||||
<el-dropdown-item command="viewport-png">当前窗口 PNG</el-dropdown-item>
|
|
||||||
<el-dropdown-item command="viewport-jpeg">当前窗口 JPEG</el-dropdown-item>
|
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
|
|
@ -37,6 +37,7 @@
|
||||||
重置视图
|
重置视图
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mindmap-content" @wheel.prevent="handleWheel" @click="handleNodeClick">
|
<div class="mindmap-content" @wheel.prevent="handleWheel" @click="handleNodeClick">
|
||||||
<div class="empty-state" v-if="!content">
|
<div class="empty-state" v-if="!content">
|
||||||
<el-icon class="empty-icon"><Document /></el-icon>
|
<el-icon class="empty-icon"><Document /></el-icon>
|
||||||
|
|
@ -65,6 +66,10 @@ const props = defineProps({
|
||||||
content: {
|
content: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
|
},
|
||||||
|
exportResolution: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -78,12 +83,66 @@ let renderTimer: number | undefined
|
||||||
const transformer = new Transformer()
|
const transformer = new Transformer()
|
||||||
const scale = ref(1)
|
const scale = ref(1)
|
||||||
const isRendering = ref(false)
|
const isRendering = ref(false)
|
||||||
|
const totalNodeCount = ref(0)
|
||||||
|
const hiddenNodeCount = ref(0)
|
||||||
|
|
||||||
const nodeCount = computed(() => {
|
type MarkmapNode = {
|
||||||
if (!props.content) return 0
|
content?: string
|
||||||
return (props.content.match(/^#{1,6}\s+/gm) || []).length
|
children?: MarkmapNode[]
|
||||||
|
payload?: {
|
||||||
|
fold?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeSummary = computed(() => {
|
||||||
|
if (!props.content) return '暂无节点'
|
||||||
|
return hiddenNodeCount.value > 0
|
||||||
|
? `已生成 ${totalNodeCount.value} 个节点,隐藏 ${hiddenNodeCount.value} 个节点`
|
||||||
|
: `已生成 ${totalNodeCount.value} 个节点`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const updateNodeStats = () => {
|
||||||
|
const root = mmInstance?.state?.data as MarkmapNode | undefined
|
||||||
|
if (!root || !props.content) {
|
||||||
|
totalNodeCount.value = 0
|
||||||
|
hiddenNodeCount.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const countSubtree = (node: MarkmapNode): number => {
|
||||||
|
const children = node.children || []
|
||||||
|
return 1 + children.reduce((sum, child) => sum + countSubtree(child), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const walkVisible = (node: MarkmapNode): { total: number; hidden: number } => {
|
||||||
|
const children = node.children || []
|
||||||
|
const total = 1 + children.reduce((sum, child) => sum + countSubtree(child), 0)
|
||||||
|
|
||||||
|
if (node.payload?.fold && children.length > 0) {
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
hidden: children.reduce((sum, child) => sum + countSubtree(child), 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const childStats = children.reduce(
|
||||||
|
(stats, child) => {
|
||||||
|
const childStat = walkVisible(child)
|
||||||
|
return {
|
||||||
|
total: stats.total + childStat.total,
|
||||||
|
hidden: stats.hidden + childStat.hidden
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ total: 1, hidden: 0 }
|
||||||
|
)
|
||||||
|
return childStats
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = walkVisible(root)
|
||||||
|
totalNodeCount.value = Math.max(0, stats.total - 1)
|
||||||
|
hiddenNodeCount.value = Math.max(0, stats.hidden)
|
||||||
|
}
|
||||||
|
|
||||||
const initMarkmap = async () => {
|
const initMarkmap = async () => {
|
||||||
if (!svgRef.value) return
|
if (!svgRef.value) return
|
||||||
|
|
||||||
|
|
@ -107,11 +166,12 @@ const initMarkmap = async () => {
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
const initialExpandLevel = nodeCount.value > 80 ? 2 : -1
|
const sourceNodeCount = (props.content.match(/^#{1,6}\s+/gm) || []).length
|
||||||
|
const initialExpandLevel = sourceNodeCount > 80 ? 2 : -1
|
||||||
|
|
||||||
if (mmInstance) {
|
if (mmInstance) {
|
||||||
if (typeof mmInstance.setData === 'function') {
|
if (typeof mmInstance.setData === 'function') {
|
||||||
mmInstance.setData(root)
|
await mmInstance.setData(root, { initialExpandLevel })
|
||||||
mmInstance.fit()
|
mmInstance.fit()
|
||||||
} else {
|
} else {
|
||||||
mmInstance = Markmap.create(svgRef.value, {
|
mmInstance = Markmap.create(svgRef.value, {
|
||||||
|
|
@ -132,6 +192,7 @@ const initMarkmap = async () => {
|
||||||
svgRef.value?.querySelectorAll('text').forEach((text) => {
|
svgRef.value?.querySelectorAll('text').forEach((text) => {
|
||||||
text.setAttribute('data-editable-node', 'true')
|
text.setAttribute('data-editable-node', 'true')
|
||||||
})
|
})
|
||||||
|
updateNodeStats()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing markmap:', error)
|
console.error('Error initializing markmap:', error)
|
||||||
mmInstance = null
|
mmInstance = null
|
||||||
|
|
@ -180,7 +241,13 @@ function downloadBlob(blob: Blob, fileName: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExportFormat = 'svg' | 'png' | 'jpeg'
|
type ExportFormat = 'svg' | 'png' | 'jpeg'
|
||||||
type ExportScope = 'full' | 'viewport'
|
|
||||||
|
interface ExportBox {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
function getViewportSize(svg: SVGElement) {
|
function getViewportSize(svg: SVGElement) {
|
||||||
const rect = svg.getBoundingClientRect()
|
const rect = svg.getBoundingClientRect()
|
||||||
|
|
@ -197,6 +264,84 @@ function cleanExportSvg(svg: SVGElement) {
|
||||||
svg.querySelectorAll('[style*="cursor"]').forEach((node) => node.removeAttribute('style'))
|
svg.querySelectorAll('[style*="cursor"]').forEach((node) => node.removeAttribute('style'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBoxFromSvgRect(rect: DOMRect): ExportBox {
|
||||||
|
return {
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformBox(box: ExportBox, matrix: DOMMatrix): ExportBox {
|
||||||
|
const points = [
|
||||||
|
new DOMPoint(box.x, box.y).matrixTransform(matrix),
|
||||||
|
new DOMPoint(box.x + box.width, box.y).matrixTransform(matrix),
|
||||||
|
new DOMPoint(box.x, box.y + box.height).matrixTransform(matrix),
|
||||||
|
new DOMPoint(box.x + box.width, box.y + box.height).matrixTransform(matrix)
|
||||||
|
]
|
||||||
|
const xs = points.map((point) => point.x)
|
||||||
|
const ys = points.map((point) => point.y)
|
||||||
|
const minX = Math.min(...xs)
|
||||||
|
const minY = Math.min(...ys)
|
||||||
|
const maxX = Math.max(...xs)
|
||||||
|
const maxY = Math.max(...ys)
|
||||||
|
return {
|
||||||
|
x: minX,
|
||||||
|
y: minY,
|
||||||
|
width: maxX - minX,
|
||||||
|
height: maxY - minY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIntersection(a: ExportBox, b: ExportBox): ExportBox | null {
|
||||||
|
const x = Math.max(a.x, b.x)
|
||||||
|
const y = Math.max(a.y, b.y)
|
||||||
|
const right = Math.min(a.x + a.width, b.x + b.width)
|
||||||
|
const bottom = Math.min(a.y + a.height, b.y + b.height)
|
||||||
|
if (right <= x || bottom <= y) return null
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: right - x,
|
||||||
|
height: bottom - y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function padBox(box: ExportBox, padding: number, boundary?: ExportBox): ExportBox {
|
||||||
|
const padded = {
|
||||||
|
x: box.x - padding,
|
||||||
|
y: box.y - padding,
|
||||||
|
width: box.width + padding * 2,
|
||||||
|
height: box.height + padding * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!boundary) return padded
|
||||||
|
const clipped = getIntersection(padded, boundary)
|
||||||
|
return clipped || box
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBox(box: ExportBox): ExportBox {
|
||||||
|
const x = Math.min(box.x, box.x + box.width)
|
||||||
|
const y = Math.min(box.y, box.y + box.height)
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: Math.abs(box.width),
|
||||||
|
height: Math.abs(box.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCompleteInViewport(contentBox: ExportBox, viewportBox: ExportBox) {
|
||||||
|
const tolerance = 4
|
||||||
|
return (
|
||||||
|
contentBox.x >= viewportBox.x - tolerance &&
|
||||||
|
contentBox.y >= viewportBox.y - tolerance &&
|
||||||
|
contentBox.x + contentBox.width <= viewportBox.x + viewportBox.width + tolerance &&
|
||||||
|
contentBox.y + contentBox.height <= viewportBox.y + viewportBox.height + tolerance
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function createFullExportSvg() {
|
function createFullExportSvg() {
|
||||||
const svg = svgRef.value
|
const svg = svgRef.value
|
||||||
if (!svg) return null
|
if (!svg) return null
|
||||||
|
|
@ -227,17 +372,40 @@ function createFullExportSvg() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createViewportExportSvg() {
|
function createVisibleExportSvg() {
|
||||||
const svg = svgRef.value
|
const svg = svgRef.value
|
||||||
if (!svg) return null
|
if (!svg) return null
|
||||||
|
|
||||||
|
const contentGroup = svg.querySelector('g') as SVGGElement | null
|
||||||
|
if (!contentGroup) return createFullExportSvg()
|
||||||
|
|
||||||
|
const viewport = getViewportSize(svg)
|
||||||
|
const viewportBox = { x: 0, y: 0, width: viewport.width, height: viewport.height }
|
||||||
|
const ctm = contentGroup.getCTM()
|
||||||
|
if (!ctm) return createFullExportSvg()
|
||||||
|
|
||||||
|
const contentBox = transformBox(getBoxFromSvgRect(contentGroup.getBBox()), ctm)
|
||||||
|
if (isCompleteInViewport(contentBox, viewportBox)) {
|
||||||
|
return createFullExportSvg()
|
||||||
|
}
|
||||||
|
|
||||||
const svgCopy = svg.cloneNode(true) as SVGElement
|
const svgCopy = svg.cloneNode(true) as SVGElement
|
||||||
cleanExportSvg(svgCopy)
|
cleanExportSvg(svgCopy)
|
||||||
|
|
||||||
const { width, height } = getViewportSize(svg)
|
const screenVisibleBox = padBox(getIntersection(contentBox, viewportBox) || viewportBox, 24, viewportBox)
|
||||||
|
const localVisibleBox = normalizeBox(transformBox(screenVisibleBox, ctm.inverse()))
|
||||||
|
const localContentBox = getBoxFromSvgRect(contentGroup.getBBox())
|
||||||
|
const clippedLocalBox = getIntersection(localVisibleBox, localContentBox) || localVisibleBox
|
||||||
|
const visibleBox = padBox(clippedLocalBox, 24)
|
||||||
|
const width = Math.ceil(Math.max(visibleBox.width, 320))
|
||||||
|
const height = Math.ceil(Math.max(visibleBox.height, 240))
|
||||||
|
const viewBox = `${Math.floor(visibleBox.x)} ${Math.floor(visibleBox.y)} ${width} ${height}`
|
||||||
|
|
||||||
|
const viewportGroupCopy = svgCopy.querySelector('g')
|
||||||
|
viewportGroupCopy?.removeAttribute('transform')
|
||||||
svgCopy.setAttribute('width', String(width))
|
svgCopy.setAttribute('width', String(width))
|
||||||
svgCopy.setAttribute('height', String(height))
|
svgCopy.setAttribute('height', String(height))
|
||||||
svgCopy.setAttribute('viewBox', svg.getAttribute('viewBox') || `0 0 ${width} ${height}`)
|
svgCopy.setAttribute('viewBox', viewBox)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: new XMLSerializer().serializeToString(svgCopy),
|
data: new XMLSerializer().serializeToString(svgCopy),
|
||||||
|
|
@ -247,19 +415,22 @@ function createViewportExportSvg() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载思维导图
|
// 下载思维导图
|
||||||
const downloadMindMap = async (format: ExportFormat = 'svg', scope: ExportScope = 'full') => {
|
const downloadMindMap = async (format: ExportFormat = 'svg') => {
|
||||||
const exportSvg = scope === 'viewport' ? createViewportExportSvg() : createFullExportSvg()
|
const exportSvg = createVisibleExportSvg()
|
||||||
if (!exportSvg) return
|
if (!exportSvg) return
|
||||||
|
|
||||||
const time = new Date().getTime()
|
const time = new Date().getTime()
|
||||||
const scopeName = scope === 'viewport' ? 'viewport' : 'full'
|
|
||||||
if (format === 'svg') {
|
if (format === 'svg') {
|
||||||
downloadBlob(new Blob([exportSvg.data], { type: 'image/svg+xml;charset=utf-8' }), `mindmap_${scopeName}_${time}.svg`)
|
downloadBlob(new Blob([exportSvg.data], { type: 'image/svg+xml;charset=utf-8' }), `mindmap_${time}.svg`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxCanvasPixels = scope === 'viewport' ? 16_000_000 : 64_000_000
|
const baseScale = 3
|
||||||
const scaleFactor = Math.min(3, Math.max(1, Math.sqrt(maxCanvasPixels / (exportSvg.width * exportSvg.height))))
|
const maxResolution = Number(props.exportResolution) || 0
|
||||||
|
const longEdge = Math.max(exportSvg.width, exportSvg.height)
|
||||||
|
const scaleFactor = maxResolution > 0
|
||||||
|
? Math.min(baseScale, Math.max(0.1, maxResolution / longEdge))
|
||||||
|
: baseScale
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
const ctx = canvas.getContext('2d')
|
const ctx = canvas.getContext('2d')
|
||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
|
|
@ -276,7 +447,7 @@ const downloadMindMap = async (format: ExportFormat = 'svg', scope: ExportScope
|
||||||
const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png'
|
const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png'
|
||||||
canvas.toBlob((blob) => {
|
canvas.toBlob((blob) => {
|
||||||
if (!blob) return
|
if (!blob) return
|
||||||
downloadBlob(blob, `mindmap_${scopeName}_${time}.${format === 'jpeg' ? 'jpg' : 'png'}`)
|
downloadBlob(blob, `mindmap_${time}.${format === 'jpeg' ? 'jpg' : 'png'}`)
|
||||||
}, mimeType, 0.96)
|
}, mimeType, 0.96)
|
||||||
}
|
}
|
||||||
img.onerror = () => ElMessage.error('导出图片失败')
|
img.onerror = () => ElMessage.error('导出图片失败')
|
||||||
|
|
@ -285,9 +456,8 @@ const downloadMindMap = async (format: ExportFormat = 'svg', scope: ExportScope
|
||||||
|
|
||||||
// 处理下载命令
|
// 处理下载命令
|
||||||
const handleDownload = (command: string) => {
|
const handleDownload = (command: string) => {
|
||||||
const [scope, format] = command.split('-') as [ExportScope, ExportFormat]
|
if (command === 'svg' || command === 'png' || command === 'jpeg') {
|
||||||
if ((scope === 'full' || scope === 'viewport') && (format === 'svg' || format === 'png' || format === 'jpeg')) {
|
downloadMindMap(command)
|
||||||
downloadMindMap(format, scope)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -295,6 +465,7 @@ const handleDownload = (command: string) => {
|
||||||
const resetView = () => {
|
const resetView = () => {
|
||||||
mmInstance?.fit()
|
mmInstance?.fit()
|
||||||
scale.value = 1
|
scale.value = 1
|
||||||
|
window.setTimeout(updateNodeStats, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 放大
|
// 放大
|
||||||
|
|
@ -333,6 +504,7 @@ const handleWheel = (event: WheelEvent) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNodeClick = async (event: MouseEvent) => {
|
const handleNodeClick = async (event: MouseEvent) => {
|
||||||
|
window.setTimeout(updateNodeStats, 0)
|
||||||
const textNode = (event.target as Element | null)?.closest?.('text')
|
const textNode = (event.target as Element | null)?.closest?.('text')
|
||||||
const oldText = textNode?.textContent?.trim()
|
const oldText = textNode?.textContent?.trim()
|
||||||
if (!oldText || !props.content || oldText === '文档思维导图') return
|
if (!oldText || !props.content || oldText === '文档思维导图') return
|
||||||
|
|
@ -360,28 +532,52 @@ const handleNodeClick = async (event: MouseEvent) => {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background-color: #FFFFFF;
|
background-color: transparent;
|
||||||
border-radius: 8px;
|
border-radius: 0;
|
||||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
|
box-shadow: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mindmap-actions {
|
.content-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 16px;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mindmap-subtitle {
|
.mindmap-actions {
|
||||||
|
padding: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-actions-left,
|
||||||
|
.mindmap-actions-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-actions-left {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-actions-right {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-toolbar-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #343A40;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-toolbar-meta {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #86909C;
|
color: #86909C;
|
||||||
background-color: #F2F3F5;
|
white-space: nowrap;
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-button {
|
.action-button {
|
||||||
|
|
@ -423,6 +619,8 @@ const handleNodeClick = async (event: MouseEvent) => {
|
||||||
background-color: #F9FAFC;
|
background-color: #F9FAFC;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
border: 1px solid #E9ECEF;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mindmap-content:active {
|
.mindmap-content:active {
|
||||||
|
|
@ -490,16 +688,18 @@ const handleNodeClick = async (event: MouseEvent) => {
|
||||||
|
|
||||||
/* 响应式设计 */
|
/* 响应式设计 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.mindmap-header {
|
.mindmap-actions {
|
||||||
padding: 12px 16px;
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mindmap-title {
|
.mindmap-actions-left,
|
||||||
font-size: 14px;
|
.mindmap-actions-right {
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mindmap-subtitle {
|
.content-toolbar-meta {
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-button {
|
.action-button {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export interface DocumentConfig {
|
||||||
maxPages: number
|
maxPages: number
|
||||||
backend: string
|
backend: string
|
||||||
serverUrl: string
|
serverUrl: string
|
||||||
|
exportResolution: number
|
||||||
tableEnable: boolean
|
tableEnable: boolean
|
||||||
formulaEnable: boolean
|
formulaEnable: boolean
|
||||||
language: string
|
language: string
|
||||||
|
|
@ -26,11 +27,22 @@ export interface ProcessResult {
|
||||||
downloadUrl?: string
|
downloadUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG_STORAGE_KEY = 'mineru.documentProcessor.config'
|
export const DOCUMENT_CONFIG_STORAGE_KEY = 'mineru.documentProcessor.config.v4'
|
||||||
|
|
||||||
|
export const DEFAULT_DOCUMENT_CONFIG: DocumentConfig = {
|
||||||
|
maxPages: 1000,
|
||||||
|
backend: 'hybrid-auto-engine',
|
||||||
|
serverUrl: 'http://localhost:30000',
|
||||||
|
exportResolution: 7680,
|
||||||
|
tableEnable: true,
|
||||||
|
formulaEnable: true,
|
||||||
|
language: 'ch',
|
||||||
|
forceOcr: false
|
||||||
|
}
|
||||||
|
|
||||||
function loadCachedConfig(): Partial<DocumentConfig> {
|
function loadCachedConfig(): Partial<DocumentConfig> {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(CONFIG_STORAGE_KEY)
|
const raw = window.localStorage.getItem(DOCUMENT_CONFIG_STORAGE_KEY)
|
||||||
return raw ? JSON.parse(raw) : {}
|
return raw ? JSON.parse(raw) : {}
|
||||||
} catch {
|
} catch {
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -43,17 +55,8 @@ export function useDocumentProcessor() {
|
||||||
const isUploading = ref(false)
|
const isUploading = ref(false)
|
||||||
|
|
||||||
// 配置相关
|
// 配置相关
|
||||||
const defaultConfig: DocumentConfig = {
|
|
||||||
maxPages: 1000,
|
|
||||||
backend: 'hybrid-auto-engine',
|
|
||||||
serverUrl: 'http://localhost:30000',
|
|
||||||
tableEnable: true,
|
|
||||||
formulaEnable: true,
|
|
||||||
language: 'ch',
|
|
||||||
forceOcr: false
|
|
||||||
}
|
|
||||||
const config = reactive<DocumentConfig>({
|
const config = reactive<DocumentConfig>({
|
||||||
...defaultConfig,
|
...DEFAULT_DOCUMENT_CONFIG,
|
||||||
...loadCachedConfig()
|
...loadCachedConfig()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -61,7 +64,7 @@ export function useDocumentProcessor() {
|
||||||
config,
|
config,
|
||||||
(value) => {
|
(value) => {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(value))
|
window.localStorage.setItem(DOCUMENT_CONFIG_STORAGE_KEY, JSON.stringify(value))
|
||||||
} catch {
|
} catch {
|
||||||
// 浏览器禁用本地存储时不影响当前页面使用。
|
// 浏览器禁用本地存储时不影响当前页面使用。
|
||||||
}
|
}
|
||||||
|
|
@ -120,15 +123,16 @@ export function useDocumentProcessor() {
|
||||||
|
|
||||||
console.log('Processing file:', fileName, 'type:', fileType)
|
console.log('Processing file:', fileName, 'type:', fileType)
|
||||||
|
|
||||||
// 验证文件类型 - 支持 PDF、图片和 Word 文档
|
// 验证文件类型 - 支持 PDF、Word 和后端可转换为 PDF 的图片格式
|
||||||
const isImage = fileType.startsWith('image/')
|
const supportedImageExtensions = ['.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tiff', '.gif', '.jp2']
|
||||||
|
const isImage = fileType.startsWith('image/') && supportedImageExtensions.some((ext) => fileName.endsWith(ext))
|
||||||
const isPdf = fileType === 'application/pdf'
|
const isPdf = fileType === 'application/pdf'
|
||||||
const isWord = fileName.endsWith('.docx') || fileName.endsWith('.doc') || fileType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
const isWord = fileName.endsWith('.docx') || fileName.endsWith('.doc') || fileType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||||
|
|
||||||
console.log('File type checks - isImage:', isImage, 'isPdf:', isPdf, 'isWord:', isWord)
|
console.log('File type checks - isImage:', isImage, 'isPdf:', isPdf, 'isWord:', isWord)
|
||||||
|
|
||||||
if (!isImage && !isPdf && !isWord) {
|
if (!isImage && !isPdf && !isWord) {
|
||||||
error.value = '不支持的文件类型'
|
error.value = '不支持的文件类型,请选择 PDF、Word、JPG、PNG、WEBP、BMP、TIFF、GIF 或 JP2 文件'
|
||||||
console.log('Unsupported file type:', fileName, fileType)
|
console.log('Unsupported file type:', fileName, fileType)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,15 @@
|
||||||
@dragleave="isDragging = false"
|
@dragleave="isDragging = false"
|
||||||
@click="triggerUpload"
|
@click="triggerUpload"
|
||||||
>
|
>
|
||||||
<div class="upload-content" v-show="!isUploadAreaCollapsed">
|
<div class="upload-content" v-if="uploadedFiles.length === 0">
|
||||||
<el-icon class="upload-icon"><Folder /></el-icon>
|
<el-icon class="upload-icon"><Folder /></el-icon>
|
||||||
|
<div class="upload-copy">
|
||||||
<div class="upload-text">文件导入</div>
|
<div class="upload-text">文件导入</div>
|
||||||
<div class="upload-hint">支持 PDF、Word、PNG 格式单个文件,可转换为 Markdown 和思维导图,也可手动粘贴 Markdown 源码</div>
|
<div class="upload-hint">支持 PDF、Word、JPG、PNG、WEBP、BMP、TIFF、GIF、JP2 单个文件</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="uploaded-files">
|
<div class="uploaded-files" v-else>
|
||||||
<div class="file-item" v-for="(file, index) in uploadedFiles" :key="index">
|
<div class="file-item" v-for="(file, index) in uploadedFiles" :key="index">
|
||||||
<el-icon class="file-icon"><Document /></el-icon>
|
<el-icon class="file-icon"><Document /></el-icon>
|
||||||
<span class="file-name">{{ file.name }}</span>
|
<span class="file-name">{{ file.name }}</span>
|
||||||
|
|
@ -33,14 +35,14 @@
|
||||||
@click.stop="removeFile(index)"
|
@click.stop="removeFile(index)"
|
||||||
class="remove-button"
|
class="remove-button"
|
||||||
/>
|
/>
|
||||||
</div>
|
<el-button
|
||||||
</div>
|
type="primary"
|
||||||
|
:loading="isProcessing || isUploading"
|
||||||
<div class="upload-actions" v-if="uploadedFiles.length > 0">
|
@click.stop="handleProcessDocument"
|
||||||
<el-button type="primary" :loading="isProcessing || isUploading" @click.stop="handleProcessDocument">
|
>
|
||||||
开始转换
|
开始转换
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button @click.stop="clearAllFiles">清空</el-button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -52,10 +54,12 @@
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<ConfigPanel
|
<ConfigPanel
|
||||||
v-model="config"
|
:model-value="config"
|
||||||
:backend-options="backendOptions"
|
:backend-options="backendOptions"
|
||||||
:language-options="languageOptions"
|
:language-options="languageOptions"
|
||||||
|
@update:model-value="applyConfig"
|
||||||
@backend-change="handleBackendChange"
|
@backend-change="handleBackendChange"
|
||||||
|
@close="closeSettings"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -136,7 +140,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="activeTab === 'source'" class="source-content">
|
<div v-show="activeTab === 'source'" class="source-content">
|
||||||
<div class="source-toolbar">
|
<div class="content-toolbar source-toolbar">
|
||||||
|
<div v-if="templateRenderError" class="template-error-inline">
|
||||||
|
{{ templateRenderError }}
|
||||||
|
</div>
|
||||||
|
<div v-else></div>
|
||||||
|
<div class="content-toolbar-actions">
|
||||||
<el-button
|
<el-button
|
||||||
:type="hasActiveReplacementRules ? 'primary' : 'default'"
|
:type="hasActiveReplacementRules ? 'primary' : 'default'"
|
||||||
class="replacement-config-button"
|
class="replacement-config-button"
|
||||||
|
|
@ -144,8 +153,6 @@
|
||||||
>
|
>
|
||||||
{{ hasActiveReplacementRules ? `动态模板渲染(已配置${activeReplacementRuleCount}条)` : '动态模板渲染' }}
|
{{ hasActiveReplacementRules ? `动态模板渲染(已配置${activeReplacementRuleCount}条)` : '动态模板渲染' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<div v-if="templateRenderError" class="template-error-inline">
|
|
||||||
{{ templateRenderError }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -163,7 +170,11 @@
|
||||||
|
|
||||||
<div v-show="activeTab === 'mindmap'" class="mindmap-content">
|
<div v-show="activeTab === 'mindmap'" class="mindmap-content">
|
||||||
<div class="mindmap-box">
|
<div class="mindmap-box">
|
||||||
<MindMapRenderer :content="renderedMindmapContent" @node-edit="handleMindmapNodeEdit" />
|
<MindMapRenderer
|
||||||
|
:content="renderedMindmapContent"
|
||||||
|
:export-resolution="config.exportResolution"
|
||||||
|
@node-edit="handleMindmapNodeEdit"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -174,7 +185,7 @@
|
||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".pdf,.doc,.docx,.png"
|
accept=".pdf,.doc,.docx,.png,.jpg,.jpeg,.webp,.bmp,.tiff,.gif,.jp2"
|
||||||
style="position: absolute; width: 0; height: 0; overflow: hidden;"
|
style="position: absolute; width: 0; height: 0; overflow: hidden;"
|
||||||
@change="handleFileInputChange"
|
@change="handleFileInputChange"
|
||||||
/>
|
/>
|
||||||
|
|
@ -224,7 +235,7 @@ import { ElMessage } from 'element-plus'
|
||||||
import ConfigPanel from '@/components/ConfigPanel.vue'
|
import ConfigPanel from '@/components/ConfigPanel.vue'
|
||||||
import MarkdownRenderer from '@/components/MarkdownRenderer.vue'
|
import MarkdownRenderer from '@/components/MarkdownRenderer.vue'
|
||||||
import MindMapRenderer from '@/components/MindMapRenderer.vue'
|
import MindMapRenderer from '@/components/MindMapRenderer.vue'
|
||||||
import { useDocumentProcessor } from '@/composables/useDocumentProcessor'
|
import { DOCUMENT_CONFIG_STORAGE_KEY, useDocumentProcessor } from '@/composables/useDocumentProcessor'
|
||||||
import { markdownToAstString } from '@/utils/markdownAst'
|
import { markdownToAstString } from '@/utils/markdownAst'
|
||||||
import { buildMindmapMarkdown, replaceFirstMindmapText } from '@/utils/mindmapMarkdown'
|
import { buildMindmapMarkdown, replaceFirstMindmapText } from '@/utils/mindmapMarkdown'
|
||||||
import { createEmptyReplacementRule, renderMarkdownTemplate, type ReplacementRule } from '@/utils/markdownTemplate'
|
import { createEmptyReplacementRule, renderMarkdownTemplate, type ReplacementRule } from '@/utils/markdownTemplate'
|
||||||
|
|
@ -359,6 +370,12 @@ const toggleSettings = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const closeSettings = () => {
|
||||||
|
if (showSettings.value) {
|
||||||
|
toggleSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
const settingsPanel = document.querySelector('.settings-panel')
|
const settingsPanel = document.querySelector('.settings-panel')
|
||||||
const settingsButton = document.querySelector('.settings-button')
|
const settingsButton = document.querySelector('.settings-button')
|
||||||
|
|
@ -383,6 +400,15 @@ const handleBackendChange = (backend: string) => {
|
||||||
console.log('Backend changed to:', backend)
|
console.log('Backend changed to:', backend)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyConfig = (nextConfig: typeof config) => {
|
||||||
|
Object.assign(config, nextConfig)
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(DOCUMENT_CONFIG_STORAGE_KEY, JSON.stringify(nextConfig))
|
||||||
|
} catch {
|
||||||
|
// 浏览器禁用本地存储时不影响当前页面使用。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleProcessDocument = async () => {
|
const handleProcessDocument = async () => {
|
||||||
await processDocument()
|
await processDocument()
|
||||||
if (error.value) {
|
if (error.value) {
|
||||||
|
|
@ -550,7 +576,7 @@ onUnmounted(() => {
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 24px;
|
padding: 0 24px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -565,19 +591,21 @@ onUnmounted(() => {
|
||||||
.drag-upload-area {
|
.drag-upload-area {
|
||||||
border: 2px dashed #CED4DA;
|
border: 2px dashed #CED4DA;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 20px 24px;
|
padding: 8px 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag-upload-area.collapsed {
|
.drag-upload-area.collapsed {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag-upload-area:hover,
|
.drag-upload-area:hover,
|
||||||
|
|
@ -588,54 +616,48 @@ onUnmounted(() => {
|
||||||
|
|
||||||
.upload-content {
|
.upload-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-icon {
|
.upload-icon {
|
||||||
font-size: 48px;
|
font-size: 24px;
|
||||||
color: #165DFF;
|
color: #165DFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-copy {
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-text {
|
.upload-text {
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #343A40;
|
color: #343A40;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-hint {
|
.upload-hint {
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
color: #6C757D;
|
color: #6C757D;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
max-width: 400px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.uploaded-files {
|
.uploaded-files {
|
||||||
margin-top: 24px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-upload-area.collapsed .uploaded-files {
|
|
||||||
margin-top: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-item {
|
.file-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 10px;
|
padding: 0;
|
||||||
background-color: #F8F9FA;
|
background-color: #F8F9FA;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 0;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
@ -653,15 +675,16 @@ onUnmounted(() => {
|
||||||
|
|
||||||
.file-name {
|
.file-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #343A40;
|
color: #343A40;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
max-width: calc(100% - 40px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove-button {
|
.remove-button {
|
||||||
|
flex-shrink: 0;
|
||||||
color: #6C757D;
|
color: #6C757D;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
@ -738,6 +761,10 @@ onUnmounted(() => {
|
||||||
border-bottom: 1px solid #E9ECEF;
|
border-bottom: 1px solid #E9ECEF;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.result-tabs :deep(.el-tabs__header) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.result-tabs :deep(.el-tabs__nav) {
|
.result-tabs :deep(.el-tabs__nav) {
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
}
|
}
|
||||||
|
|
@ -885,23 +912,21 @@ onUnmounted(() => {
|
||||||
.markdown-box {
|
.markdown-box {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border: 1px solid #E9ECEF;
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
padding: 16px;
|
overflow: hidden;
|
||||||
overflow-y: auto;
|
background-color: transparent;
|
||||||
overflow-x: hidden;
|
|
||||||
background-color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mindmap-box {
|
.mindmap-box {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border: 1px solid #E9ECEF;
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: white;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.source-content {
|
.source-content {
|
||||||
|
|
@ -920,6 +945,35 @@ onUnmounted(() => {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-toolbar-info,
|
||||||
|
.content-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-toolbar-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-toolbar-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-toolbar-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #343A40;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-toolbar-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #86909C;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.replacement-config-button {
|
.replacement-config-button {
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
@ -1022,7 +1076,10 @@ onUnmounted(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.main-content,
|
.main-content {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.result-content {
|
.result-content {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
@ -1032,11 +1089,11 @@ onUnmounted(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag-upload-area {
|
.drag-upload-area {
|
||||||
padding: 32px 16px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-icon {
|
.upload-icon {
|
||||||
font-size: 32px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-text {
|
.upload-text {
|
||||||
|
|
@ -1061,6 +1118,14 @@ onUnmounted(() => {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-toolbar-info {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-toolbar-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.replacement-dialog-body {
|
.replacement-dialog-body {
|
||||||
max-height: none;
|
max-height: none;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue