新增:Markdown 本地文件链接支持 + Shell 语法高亮
Markdown 预览增强: - 支持点击本地文件链接(相对路径)打开对应文件 - 支持链接文本中的加粗/斜体等内联语法 - 锚点链接保持页面内跳转,外部链接新窗口打开 代码高亮增强: - 添加 sh/bash/shell 语言别名映射 - 安装 @codemirror/legacy-modes 支持 .sh 文件语法高亮
This commit is contained in:
10
web/package-lock.json
generated
10
web/package-lock.json
generated
@@ -24,6 +24,7 @@
|
|||||||
"@codemirror/lang-sql": "^6.10.0",
|
"@codemirror/lang-sql": "^6.10.0",
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
"@codemirror/language": "^6.12.1",
|
"@codemirror/language": "^6.12.1",
|
||||||
|
"@codemirror/legacy-modes": "^6.5.2",
|
||||||
"@codemirror/state": "^6.5.3",
|
"@codemirror/state": "^6.5.3",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.39.8",
|
"@codemirror/view": "^6.39.8",
|
||||||
@@ -393,6 +394,15 @@
|
|||||||
"style-mod": "^4.0.0"
|
"style-mod": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/legacy-modes": {
|
||||||
|
"version": "6.5.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz",
|
||||||
|
"integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@codemirror/lint": {
|
"node_modules/@codemirror/lint": {
|
||||||
"version": "6.9.2",
|
"version": "6.9.2",
|
||||||
"resolved": "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.2.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"@codemirror/lang-sql": "^6.10.0",
|
"@codemirror/lang-sql": "^6.10.0",
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
"@codemirror/language": "^6.12.1",
|
"@codemirror/language": "^6.12.1",
|
||||||
|
"@codemirror/legacy-modes": "^6.5.2",
|
||||||
"@codemirror/state": "^6.5.3",
|
"@codemirror/state": "^6.5.3",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.39.8",
|
"@codemirror/view": "^6.39.8",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<template v-else-if="config.isMarkdownFile">📝 Markdown 预览</template>
|
<template v-else-if="config.isMarkdownFile">📝 Markdown 预览</template>
|
||||||
<template v-else-if="config.isExcelFile">📊 Excel 预览</template>
|
<template v-else-if="config.isExcelFile">📊 Excel 预览</template>
|
||||||
<template v-else-if="config.isWordFile">📄 Word 预览</template>
|
<template v-else-if="config.isWordFile">📄 Word 预览</template>
|
||||||
|
<template v-else-if="config.isCsvFile">📋 CSV 预览</template>
|
||||||
<template v-else>📝 文件内容</template>
|
<template v-else>📝 文件内容</template>
|
||||||
</span>
|
</span>
|
||||||
<div v-if="config.currentFileName" class="filename-with-copy">
|
<div v-if="config.currentFileName" class="filename-with-copy">
|
||||||
@@ -99,6 +100,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- CSV 预览 -->
|
||||||
|
<div v-else-if="config.isCsvFile" class="office-preview">
|
||||||
|
<div class="office-preview-container" ref="csvPreviewRef">
|
||||||
|
<a-spin v-if="config.officeLoading" :loading="true" tip="加载中...">
|
||||||
|
<div class="loading-placeholder"></div>
|
||||||
|
</a-spin>
|
||||||
|
<a-alert v-else-if="config.officeError" type="error" :message="config.officeError" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- HTML 预览/编辑 -->
|
<!-- HTML 预览/编辑 -->
|
||||||
<div v-else-if="config.isHtmlFile" class="html-preview-wrapper">
|
<div v-else-if="config.isHtmlFile" class="html-preview-wrapper">
|
||||||
<!-- 编辑模式/预览模式切换按钮 -->
|
<!-- 编辑模式/预览模式切换按钮 -->
|
||||||
@@ -203,7 +214,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 预览模式 -->
|
<!-- 预览模式 -->
|
||||||
<div v-if="!config.isEditMode" class="markdown-preview-content markdown-content" v-html="config.rendered"></div>
|
<div v-if="!config.isEditMode" ref="markdownPreviewRef" class="markdown-preview-content markdown-content" v-html="config.rendered"></div>
|
||||||
|
|
||||||
<!-- 编辑模式 -->
|
<!-- 编辑模式 -->
|
||||||
<div v-else class="markdown-edit-wrapper">
|
<div v-else class="markdown-edit-wrapper">
|
||||||
@@ -276,14 +287,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch, nextTick, defineAsyncComponent, ref, onMounted } from 'vue'
|
import { computed, watch, nextTick, defineAsyncComponent, ref, onUnmounted } from 'vue'
|
||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
import { IconSave, IconEdit, IconEye, IconUndo, IconCopy } from '@arco-design/web-vue/es/icon'
|
import { IconSave, IconEdit, IconEye, IconUndo, IconCopy } from '@arco-design/web-vue/es/icon'
|
||||||
import { getFileName } from '@/utils/fileUtils'
|
import { getFileName } from '@/utils/fileUtils'
|
||||||
import type { FileEditorPanelConfig } from '@/types/file-system'
|
import type { FileEditorPanelConfig } from '@/types/file-system'
|
||||||
import { renderMermaidDiagrams } from '@/utils/markedExtensions'
|
import { renderMermaidDiagrams } from '@/utils/markedExtensions'
|
||||||
import { previewExcel, previewWord } from '@/utils/filePreviewHandlers'
|
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
|
||||||
import { getFileServerURL } from '@/api/system'
|
|
||||||
|
|
||||||
// 异步加载 CodeEditor 组件,减少初始包大小
|
// 异步加载 CodeEditor 组件,减少初始包大小
|
||||||
const AsyncCodeEditor = defineAsyncComponent({
|
const AsyncCodeEditor = defineAsyncComponent({
|
||||||
@@ -295,6 +305,10 @@ const AsyncCodeEditor = defineAsyncComponent({
|
|||||||
// Office 预览容器引用
|
// Office 预览容器引用
|
||||||
const excelPreviewRef = ref<HTMLElement | null>(null)
|
const excelPreviewRef = ref<HTMLElement | null>(null)
|
||||||
const wordPreviewRef = ref<HTMLElement | null>(null)
|
const wordPreviewRef = ref<HTMLElement | null>(null)
|
||||||
|
const csvPreviewRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// Markdown 预览容器引用
|
||||||
|
const markdownPreviewRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -316,6 +330,7 @@ interface Emits {
|
|||||||
(e: 'contentUpdate', content: string): void
|
(e: 'contentUpdate', content: string): void
|
||||||
(e: 'imageLoad', dimensions: string): void
|
(e: 'imageLoad', dimensions: string): void
|
||||||
(e: 'imageError'): void
|
(e: 'imageError'): void
|
||||||
|
(e: 'openLocalFile', link: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
@@ -437,63 +452,56 @@ watch(() => props.config.isEditMode, async (newVal, oldVal) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 监听 Excel 文件变化,触发预览
|
// 监听 Excel 文件变化,触发预览
|
||||||
watch(() => [props.config.isExcelFile, props.config.currentFileFullPath], async ([isExcel, filePath]) => {
|
watch(() => [props.config.isExcelFile, props.config.currentFileFullPath] as const, async ([isExcel, filePath]) => {
|
||||||
if (isExcel && filePath && excelPreviewRef.value) {
|
if (isExcel && filePath && excelPreviewRef.value) {
|
||||||
await loadExcelPreview(filePath)
|
await loadExcelPreview(filePath)
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
// 监听 Word 文件变化,触发预览
|
// 监听 Word 文件变化,触发预览
|
||||||
watch(() => [props.config.isWordFile, props.config.currentFileFullPath], async ([isWord, filePath]) => {
|
watch(() => [props.config.isWordFile, props.config.currentFileFullPath] as const, async ([isWord, filePath]) => {
|
||||||
if (isWord && filePath && wordPreviewRef.value) {
|
if (isWord && filePath && wordPreviewRef.value) {
|
||||||
await loadWordPreview(filePath)
|
await loadWordPreview(filePath)
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
// Excel 预览加载
|
// 监听 CSV 文件变化,触发预览
|
||||||
|
watch(() => [props.config.isCsvFile, props.config.currentFileFullPath] as const, async ([isCsv, filePath]) => {
|
||||||
|
if (isCsv && filePath) {
|
||||||
|
await nextTick()
|
||||||
|
if (csvPreviewRef.value) {
|
||||||
|
await loadCsvPreview(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Excel 预览加载(直接使用本地文件服务器,秒开)
|
||||||
const loadExcelPreview = async (filePath: string) => {
|
const loadExcelPreview = async (filePath: string) => {
|
||||||
if (!excelPreviewRef.value) return
|
if (!excelPreviewRef.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 清空容器
|
excelPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
|
||||||
excelPreviewRef.value.innerHTML = ''
|
|
||||||
|
|
||||||
// 使用本地文件服务器获取文件内容
|
|
||||||
const serverURL = await getFileServerURL()
|
|
||||||
console.log('[loadExcelPreview] 文件路径:', filePath)
|
|
||||||
console.log('[loadExcelPreview] 服务器 URL:', serverURL)
|
|
||||||
|
|
||||||
// Windows 路径处理:将反斜杠替换为正斜杠
|
|
||||||
const normalizedPath = filePath.replace(/\\/g, '/')
|
|
||||||
console.log('[loadExcelPreview] 规范化路径:', normalizedPath)
|
|
||||||
|
|
||||||
const encodedPath = encodeURIComponent(normalizedPath)
|
|
||||||
const fullURL = `${serverURL}/localfs/${encodedPath}`
|
|
||||||
console.log('[loadExcelPreview] 完整 URL:', fullURL)
|
|
||||||
|
|
||||||
const response = await fetch(fullURL)
|
|
||||||
console.log('[loadExcelPreview] 响应状态:', response.status)
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`无法读取文件 (HTTP ${response.status})`)
|
|
||||||
|
|
||||||
|
// 直接从本地文件服务器获取(不走 base64)
|
||||||
|
const fileUrl = props.config.previewUrl
|
||||||
|
const response = await fetch(fileUrl)
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
console.log('[loadExcelPreview] Blob 大小:', blob.size)
|
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-excel' })
|
||||||
|
|
||||||
const file = new File([blob], getFileName(filePath), { type: 'application/vnd.ms-excel' })
|
|
||||||
|
|
||||||
const result = await previewExcel(file, excelPreviewRef.value)
|
const result = await previewExcel(file, excelPreviewRef.value)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || '预览失败')
|
throw new Error(result.error || '预览失败')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[FileEditorPanel] Excel 预览失败:', error)
|
console.error('[loadExcelPreview] 错误:', error)
|
||||||
excelPreviewRef.value.innerHTML = `
|
if (excelPreviewRef.value) {
|
||||||
<div class="preview-error">
|
excelPreviewRef.value.innerHTML = `
|
||||||
<p>❌ Excel 预览失败</p>
|
<div class="preview-error">
|
||||||
<p class="error-detail">${error.message}</p>
|
<p>❌ Excel 预览失败</p>
|
||||||
<p class="error-hint">💡 提示:尝试使用外部应用打开文件</p>
|
<p class="error-detail">${error?.message || '未知错误'}</p>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,45 +510,56 @@ const loadWordPreview = async (filePath: string) => {
|
|||||||
if (!wordPreviewRef.value) return
|
if (!wordPreviewRef.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 清空容器
|
wordPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
|
||||||
wordPreviewRef.value.innerHTML = ''
|
|
||||||
|
|
||||||
// 使用本地文件服务器获取文件内容
|
|
||||||
const serverURL = await getFileServerURL()
|
|
||||||
console.log('[loadWordPreview] 文件路径:', filePath)
|
|
||||||
console.log('[loadWordPreview] 服务器 URL:', serverURL)
|
|
||||||
|
|
||||||
// Windows 路径处理:将反斜杠替换为正斜杠
|
|
||||||
const normalizedPath = filePath.replace(/\\/g, '/')
|
|
||||||
console.log('[loadWordPreview] 规范化路径:', normalizedPath)
|
|
||||||
|
|
||||||
const encodedPath = encodeURIComponent(normalizedPath)
|
|
||||||
const fullURL = `${serverURL}/localfs/${encodedPath}`
|
|
||||||
console.log('[loadWordPreview] 完整 URL:', fullURL)
|
|
||||||
|
|
||||||
const response = await fetch(fullURL)
|
|
||||||
console.log('[loadWordPreview] 响应状态:', response.status)
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`无法读取文件 (HTTP ${response.status})`)
|
|
||||||
|
|
||||||
|
const fileUrl = props.config.previewUrl
|
||||||
|
const response = await fetch(fileUrl)
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
console.log('[loadWordPreview] Blob 大小:', blob.size)
|
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-word' })
|
||||||
|
|
||||||
const file = new File([blob], getFileName(filePath), { type: 'application/vnd.ms-word' })
|
|
||||||
|
|
||||||
const result = await previewWord(file, wordPreviewRef.value)
|
const result = await previewWord(file, wordPreviewRef.value)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || '预览失败')
|
throw new Error(result.error || '预览失败')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[FileEditorPanel] Word 预览失败:', error)
|
console.error('[loadWordPreview] 错误:', error)
|
||||||
wordPreviewRef.value.innerHTML = `
|
if (wordPreviewRef.value) {
|
||||||
<div class="preview-error">
|
wordPreviewRef.value.innerHTML = `
|
||||||
<p>❌ Word 预览失败</p>
|
<div class="preview-error">
|
||||||
<p class="error-detail">${error.message}</p>
|
<p>❌ Word 预览失败</p>
|
||||||
<p class="error-hint">💡 提示:尝试使用外部应用打开文件</p>
|
<p class="error-detail">${error?.message || '未知错误'}</p>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV 预览加载
|
||||||
|
const loadCsvPreview = async (filePath: string) => {
|
||||||
|
if (!csvPreviewRef.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
csvPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
|
||||||
|
|
||||||
|
const fileUrl = props.config.previewUrl
|
||||||
|
const response = await fetch(fileUrl)
|
||||||
|
const blob = await response.blob()
|
||||||
|
const file = new File([blob], getFileName(filePath), { type: 'text/csv' })
|
||||||
|
|
||||||
|
const result = await previewCsv(file, csvPreviewRef.value)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || '预览失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[loadCsvPreview] 错误:', error)
|
||||||
|
if (csvPreviewRef.value) {
|
||||||
|
csvPreviewRef.value.innerHTML = `
|
||||||
|
<div class="preview-error">
|
||||||
|
<p>❌ CSV 预览失败</p>
|
||||||
|
<p class="error-detail">${error?.message || String(error)}</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,6 +597,40 @@ const handleCopyPath = () => {
|
|||||||
Message.success('路径已复制')
|
Message.success('路径已复制')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理 Markdown 预览中的本地文件链接点击
|
||||||
|
const handleMarkdownLinkClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
const link = target.closest('a[data-local-link]') as HTMLAnchorElement | null
|
||||||
|
|
||||||
|
if (link) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
const localLink = link.getAttribute('data-local-link')
|
||||||
|
if (localLink) {
|
||||||
|
emit('openLocalFile', localLink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听预览区域的变化,添加/移除事件监听
|
||||||
|
watch([markdownPreviewRef, () => props.config.isEditMode], ([refVal, isEditMode], [oldRefVal]) => {
|
||||||
|
// 移除旧的监听器
|
||||||
|
if (oldRefVal) {
|
||||||
|
oldRefVal.removeEventListener('click', handleMarkdownLinkClick)
|
||||||
|
}
|
||||||
|
// 添加新的监听器(仅在预览模式且有 DOM 元素时)
|
||||||
|
if (refVal && !isEditMode) {
|
||||||
|
refVal.addEventListener('click', handleMarkdownLinkClick)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (markdownPreviewRef.value) {
|
||||||
|
markdownPreviewRef.value.removeEventListener('click', handleMarkdownLinkClick)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -798,6 +851,18 @@ const handleCopyPath = () => {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 本地文件链接样式 */
|
||||||
|
.markdown-preview-content :deep(a.local-file-link) {
|
||||||
|
color: rgb(var(--primary-6));
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px dashed rgb(var(--primary-6));
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview-content :deep(a.local-file-link:hover) {
|
||||||
|
background-color: rgba(var(--primary-6), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-preview-content :deep(code) {
|
.markdown-preview-content :deep(code) {
|
||||||
background: var(--color-fill-2);
|
background: var(--color-fill-2);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
@content-update="handleContentUpdate"
|
@content-update="handleContentUpdate"
|
||||||
@image-load="handleImageLoad"
|
@image-load="handleImageLoad"
|
||||||
@image-error="handleImageError"
|
@image-error="handleImageError"
|
||||||
|
@open-local-file="handleOpenLocalFile"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +121,7 @@ import { useCommonPaths } from './composables/useCommonPaths'
|
|||||||
// 导入工具函数
|
// 导入工具函数
|
||||||
import { getFileName, sortFileList } from '@/utils/fileUtils'
|
import { getFileName, sortFileList } from '@/utils/fileUtils'
|
||||||
import { getParentPath } from '@/utils/pathHelpers'
|
import { getParentPath } from '@/utils/pathHelpers'
|
||||||
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile } from '@/utils/fileTypeHelpers'
|
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile, isCsvFile } from '@/utils/fileTypeHelpers'
|
||||||
import { listDir } from '@/api/system'
|
import { listDir } from '@/api/system'
|
||||||
import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS } from '@/utils/constants'
|
import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS } from '@/utils/constants'
|
||||||
|
|
||||||
@@ -321,6 +322,7 @@ const fileEditorPanelConfig = computed(() => {
|
|||||||
isMarkdownFile: isMarkdownFile(currentFileName),
|
isMarkdownFile: isMarkdownFile(currentFileName),
|
||||||
isExcelFile: isExcelFile(currentFileName),
|
isExcelFile: isExcelFile(currentFileName),
|
||||||
isWordFile: isWordFile(currentFileName),
|
isWordFile: isWordFile(currentFileName),
|
||||||
|
isCsvFile: isCsvFile(currentFileName),
|
||||||
officeLoading: false,
|
officeLoading: false,
|
||||||
officeError: null,
|
officeError: null,
|
||||||
canSaveFile: canSaveFile.value,
|
canSaveFile: canSaveFile.value,
|
||||||
@@ -381,6 +383,54 @@ const handleOpenFile = async (path: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理 Markdown 预览中的本地文件链接点击
|
||||||
|
const handleOpenLocalFile = async (link: string) => {
|
||||||
|
if (!link) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
let targetPath = link
|
||||||
|
|
||||||
|
// 如果是相对路径,基于当前 MD 文件所在目录解析
|
||||||
|
if (!link.match(/^[A-Za-z]:/) && !link.startsWith('/')) {
|
||||||
|
// 使用当前预览文件的完整路径
|
||||||
|
const currentFilePath = fileEditorPanelConfig.value.currentFileFullPath
|
||||||
|
if (currentFilePath) {
|
||||||
|
// 获取当前文件所在目录
|
||||||
|
const currentDir = getParentPath(currentFilePath)
|
||||||
|
// 解析相对路径
|
||||||
|
targetPath = resolveRelativePath(currentDir, link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用打开文件处理
|
||||||
|
await handleOpenFile(targetPath)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('打开本地文件链接失败:', error)
|
||||||
|
Message.error(`无法打开文件: ${link}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析相对路径为绝对路径
|
||||||
|
const resolveRelativePath = (basePath: string, relativePath: string): string => {
|
||||||
|
// 统一使用 / 作为分隔符
|
||||||
|
const base = basePath.replace(/\\/g, '/')
|
||||||
|
const relative = relativePath.replace(/\\/g, '/')
|
||||||
|
|
||||||
|
// 处理 ./ 和 ../
|
||||||
|
const parts = base.split('/')
|
||||||
|
const segments = relative.split('/')
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (segment === '..') {
|
||||||
|
parts.pop()
|
||||||
|
} else if (segment !== '.' && segment !== '') {
|
||||||
|
parts.push(segment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('/')
|
||||||
|
}
|
||||||
|
|
||||||
const handleNavigateToZipDirectory = async (path: string) => {
|
const handleNavigateToZipDirectory = async (path: string) => {
|
||||||
// 暂时不处理 ZIP
|
// 暂时不处理 ZIP
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
javascript, json, yaml, html, css,
|
javascript, json, yaml, html, css,
|
||||||
cpp, rust, go, python, php, sql, markdown, java
|
cpp, rust, go, python, php, sql, markdown, java,
|
||||||
|
shell, StreamLanguage
|
||||||
} from './codemirrorExports'
|
} from './codemirrorExports'
|
||||||
|
|
||||||
const languageCache = new Map()
|
const languageCache = new Map()
|
||||||
@@ -68,6 +69,11 @@ export function loadLanguageExtension(language) {
|
|||||||
case 'java':
|
case 'java':
|
||||||
extension = java()
|
extension = java()
|
||||||
break
|
break
|
||||||
|
case 'shell':
|
||||||
|
case 'bash':
|
||||||
|
case 'sh':
|
||||||
|
extension = StreamLanguage.define(shell)
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -100,7 +106,8 @@ export function getLanguageFromExtension(extension) {
|
|||||||
php: 'php',
|
php: 'php',
|
||||||
sql: 'sql',
|
sql: 'sql',
|
||||||
markdown: 'markdown', md: 'markdown',
|
markdown: 'markdown', md: 'markdown',
|
||||||
java: 'java'
|
java: 'java',
|
||||||
|
sh: 'shell', bash: 'shell', shell: 'shell', zsh: 'shell'
|
||||||
}
|
}
|
||||||
|
|
||||||
return langMap[ext] || 'text'
|
return langMap[ext] || 'text'
|
||||||
|
|||||||
@@ -24,3 +24,6 @@ export { php } from '@codemirror/lang-php'
|
|||||||
export { sql } from '@codemirror/lang-sql'
|
export { sql } from '@codemirror/lang-sql'
|
||||||
export { markdown } from '@codemirror/lang-markdown'
|
export { markdown } from '@codemirror/lang-markdown'
|
||||||
export { java } from '@codemirror/lang-java'
|
export { java } from '@codemirror/lang-java'
|
||||||
|
|
||||||
|
// Legacy language modes (shell)
|
||||||
|
export { shell } from '@codemirror/legacy-modes/mode/shell'
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import hljs from 'highlight.js'
|
import hljs from 'highlight.js'
|
||||||
import 'highlight.js/lib/common'
|
import 'highlight.js/lib/common'
|
||||||
|
// 额外导入 common 包不包含的语言
|
||||||
|
import 'highlight.js/lib/languages/bash'
|
||||||
|
import 'highlight.js/lib/languages/go'
|
||||||
import 'highlight.js/styles/github-dark.css'
|
import 'highlight.js/styles/github-dark.css'
|
||||||
import 'highlight.js/styles/github.css'
|
import 'highlight.js/styles/github.css'
|
||||||
|
|
||||||
|
// 语言别名映射(sh -> bash 等)
|
||||||
|
const languageAliases: Record<string, string> = {
|
||||||
|
'sh': 'bash',
|
||||||
|
'shell': 'bash',
|
||||||
|
'zsh': 'bash',
|
||||||
|
'ksh': 'bash',
|
||||||
|
'ts': 'typescript',
|
||||||
|
'js': 'javascript',
|
||||||
|
'py': 'python',
|
||||||
|
'rb': 'ruby',
|
||||||
|
'yml': 'yaml',
|
||||||
|
'md': 'markdown'
|
||||||
|
}
|
||||||
|
|
||||||
let mermaidInstance: typeof import('mermaid').default | null = null
|
let mermaidInstance: typeof import('mermaid').default | null = null
|
||||||
|
|
||||||
async function loadMermaid() {
|
async function loadMermaid() {
|
||||||
@@ -30,7 +47,15 @@ renderer.code = function(token: any) {
|
|||||||
return `<pre class="mermaid">${token.text}</pre>`
|
return `<pre class="mermaid">${token.text}</pre>`
|
||||||
}
|
}
|
||||||
|
|
||||||
const lang = hljs.getLanguage(token.lang) ? token.lang : 'plaintext'
|
// 获取语言,支持别名
|
||||||
|
let lang = token.lang || 'plaintext'
|
||||||
|
lang = languageAliases[lang] || lang
|
||||||
|
|
||||||
|
// 检查语言是否支持
|
||||||
|
if (!hljs.getLanguage(lang)) {
|
||||||
|
lang = 'plaintext'
|
||||||
|
}
|
||||||
|
|
||||||
const highlighted = hljs.highlight(token.text, { language: lang }).value
|
const highlighted = hljs.highlight(token.text, { language: lang }).value
|
||||||
return `<pre><code class="hljs language-${lang}" data-theme="auto">${highlighted}</code></pre>`
|
return `<pre><code class="hljs language-${lang}" data-theme="auto">${highlighted}</code></pre>`
|
||||||
}
|
}
|
||||||
@@ -53,6 +78,40 @@ renderer.heading = function(token: any) {
|
|||||||
</h${depth}>`
|
</h${depth}>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 判断是否为本地文件链接(相对路径或本地绝对路径)
|
||||||
|
const isLocalFileLink = (href: string): boolean => {
|
||||||
|
if (!href) return false
|
||||||
|
// 排除 http/https/ftp/mailto 等外部链接
|
||||||
|
if (/^(https?|ftp|mailto|tel|data):/i.test(href)) return false
|
||||||
|
// 排除锚点链接
|
||||||
|
if (href.startsWith('#')) return false
|
||||||
|
// 相对路径或本地路径(如 ./file.md, ../file.md, /path/to/file, C:\path\file)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义链接渲染器 - 支持本地文件链接
|
||||||
|
renderer.link = function(token: any) {
|
||||||
|
const href = token.href || ''
|
||||||
|
// 解析链接文本中的内联元素(如加粗、斜体等)
|
||||||
|
const text = this.parser.parseInline(token.tokens) || token.text || ''
|
||||||
|
const title = token.title || ''
|
||||||
|
|
||||||
|
const titleAttr = title ? ` title="${title}"` : ''
|
||||||
|
|
||||||
|
// 锚点链接 - 保持原样,页面内跳转
|
||||||
|
if (href.startsWith('#')) {
|
||||||
|
return `<a href="${href}"${titleAttr}>${text}</a>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否为本地文件链接
|
||||||
|
if (isLocalFileLink(href)) {
|
||||||
|
return `<a href="javascript:void(0)" data-local-link="${href}" class="local-file-link"${titleAttr}>${text}</a>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 外部链接使用默认行为
|
||||||
|
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
|
||||||
|
}
|
||||||
|
|
||||||
marked.use({ renderer, breaks: true, gfm: true })
|
marked.use({ renderer, breaks: true, gfm: true })
|
||||||
|
|
||||||
export { marked }
|
export { marked }
|
||||||
|
|||||||
Reference in New Issue
Block a user