新增: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-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/state": "^6.5.3",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.8",
|
||||
@@ -393,6 +394,15 @@
|
||||
"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": {
|
||||
"version": "6.9.2",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.2.tgz",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/state": "^6.5.3",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.8",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<template v-else-if="config.isMarkdownFile">📝 Markdown 预览</template>
|
||||
<template v-else-if="config.isExcelFile">📊 Excel 预览</template>
|
||||
<template v-else-if="config.isWordFile">📄 Word 预览</template>
|
||||
<template v-else-if="config.isCsvFile">📋 CSV 预览</template>
|
||||
<template v-else>📝 文件内容</template>
|
||||
</span>
|
||||
<div v-if="config.currentFileName" class="filename-with-copy">
|
||||
@@ -99,6 +100,16 @@
|
||||
</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 预览/编辑 -->
|
||||
<div v-else-if="config.isHtmlFile" class="html-preview-wrapper">
|
||||
<!-- 编辑模式/预览模式切换按钮 -->
|
||||
@@ -203,7 +214,7 @@
|
||||
</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">
|
||||
@@ -276,14 +287,13 @@
|
||||
</template>
|
||||
|
||||
<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 { IconSave, IconEdit, IconEye, IconUndo, IconCopy } from '@arco-design/web-vue/es/icon'
|
||||
import { getFileName } from '@/utils/fileUtils'
|
||||
import type { FileEditorPanelConfig } from '@/types/file-system'
|
||||
import { renderMermaidDiagrams } from '@/utils/markedExtensions'
|
||||
import { previewExcel, previewWord } from '@/utils/filePreviewHandlers'
|
||||
import { getFileServerURL } from '@/api/system'
|
||||
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
|
||||
|
||||
// 异步加载 CodeEditor 组件,减少初始包大小
|
||||
const AsyncCodeEditor = defineAsyncComponent({
|
||||
@@ -295,6 +305,10 @@ const AsyncCodeEditor = defineAsyncComponent({
|
||||
// Office 预览容器引用
|
||||
const excelPreviewRef = ref<HTMLElement | null>(null)
|
||||
const wordPreviewRef = ref<HTMLElement | null>(null)
|
||||
const csvPreviewRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Markdown 预览容器引用
|
||||
const markdownPreviewRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
@@ -316,6 +330,7 @@ interface Emits {
|
||||
(e: 'contentUpdate', content: string): void
|
||||
(e: 'imageLoad', dimensions: string): void
|
||||
(e: 'imageError'): void
|
||||
(e: 'openLocalFile', link: string): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
@@ -437,63 +452,56 @@ watch(() => props.config.isEditMode, async (newVal, oldVal) => {
|
||||
})
|
||||
|
||||
// 监听 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) {
|
||||
await loadExcelPreview(filePath)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听 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) {
|
||||
await loadWordPreview(filePath)
|
||||
}
|
||||
}, { 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) => {
|
||||
if (!excelPreviewRef.value) return
|
||||
|
||||
try {
|
||||
// 清空容器
|
||||
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})`)
|
||||
excelPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
|
||||
|
||||
// 直接从本地文件服务器获取(不走 base64)
|
||||
const fileUrl = props.config.previewUrl
|
||||
const response = await fetch(fileUrl)
|
||||
const blob = await response.blob()
|
||||
console.log('[loadExcelPreview] Blob 大小:', blob.size)
|
||||
|
||||
const file = new File([blob], getFileName(filePath), { type: 'application/vnd.ms-excel' })
|
||||
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-excel' })
|
||||
|
||||
const result = await previewExcel(file, excelPreviewRef.value)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '预览失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[FileEditorPanel] Excel 预览失败:', error)
|
||||
excelPreviewRef.value.innerHTML = `
|
||||
<div class="preview-error">
|
||||
<p>❌ Excel 预览失败</p>
|
||||
<p class="error-detail">${error.message}</p>
|
||||
<p class="error-hint">💡 提示:尝试使用外部应用打开文件</p>
|
||||
</div>
|
||||
`
|
||||
console.error('[loadExcelPreview] 错误:', error)
|
||||
if (excelPreviewRef.value) {
|
||||
excelPreviewRef.value.innerHTML = `
|
||||
<div class="preview-error">
|
||||
<p>❌ Excel 预览失败</p>
|
||||
<p class="error-detail">${error?.message || '未知错误'}</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,45 +510,56 @@ const loadWordPreview = async (filePath: string) => {
|
||||
if (!wordPreviewRef.value) return
|
||||
|
||||
try {
|
||||
// 清空容器
|
||||
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})`)
|
||||
wordPreviewRef.value.innerHTML = '<div class="loading-hint">加载中...</div>'
|
||||
|
||||
const fileUrl = props.config.previewUrl
|
||||
const response = await fetch(fileUrl)
|
||||
const blob = await response.blob()
|
||||
console.log('[loadWordPreview] Blob 大小:', blob.size)
|
||||
|
||||
const file = new File([blob], getFileName(filePath), { type: 'application/vnd.ms-word' })
|
||||
const file = new File([blob], getFileName(filePath), { type: blob.type || 'application/vnd.ms-word' })
|
||||
|
||||
const result = await previewWord(file, wordPreviewRef.value)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '预览失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[FileEditorPanel] Word 预览失败:', error)
|
||||
wordPreviewRef.value.innerHTML = `
|
||||
<div class="preview-error">
|
||||
<p>❌ Word 预览失败</p>
|
||||
<p class="error-detail">${error.message}</p>
|
||||
<p class="error-hint">💡 提示:尝试使用外部应用打开文件</p>
|
||||
</div>
|
||||
`
|
||||
console.error('[loadWordPreview] 错误:', error)
|
||||
if (wordPreviewRef.value) {
|
||||
wordPreviewRef.value.innerHTML = `
|
||||
<div class="preview-error">
|
||||
<p>❌ Word 预览失败</p>
|
||||
<p class="error-detail">${error?.message || '未知错误'}</p>
|
||||
</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('路径已复制')
|
||||
})
|
||||
}
|
||||
|
||||
// 处理 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>
|
||||
|
||||
<style scoped>
|
||||
@@ -798,6 +851,18 @@ const handleCopyPath = () => {
|
||||
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) {
|
||||
background: var(--color-fill-2);
|
||||
padding: 2px 6px;
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
@content-update="handleContentUpdate"
|
||||
@image-load="handleImageLoad"
|
||||
@image-error="handleImageError"
|
||||
@open-local-file="handleOpenLocalFile"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,7 +121,7 @@ import { useCommonPaths } from './composables/useCommonPaths'
|
||||
// 导入工具函数
|
||||
import { getFileName, sortFileList } from '@/utils/fileUtils'
|
||||
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 { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS } from '@/utils/constants'
|
||||
|
||||
@@ -321,6 +322,7 @@ const fileEditorPanelConfig = computed(() => {
|
||||
isMarkdownFile: isMarkdownFile(currentFileName),
|
||||
isExcelFile: isExcelFile(currentFileName),
|
||||
isWordFile: isWordFile(currentFileName),
|
||||
isCsvFile: isCsvFile(currentFileName),
|
||||
officeLoading: false,
|
||||
officeError: null,
|
||||
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) => {
|
||||
// 暂时不处理 ZIP
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
import {
|
||||
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'
|
||||
|
||||
const languageCache = new Map()
|
||||
@@ -68,6 +69,11 @@ export function loadLanguageExtension(language) {
|
||||
case 'java':
|
||||
extension = java()
|
||||
break
|
||||
case 'shell':
|
||||
case 'bash':
|
||||
case 'sh':
|
||||
extension = StreamLanguage.define(shell)
|
||||
break
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -100,7 +106,8 @@ export function getLanguageFromExtension(extension) {
|
||||
php: 'php',
|
||||
sql: 'sql',
|
||||
markdown: 'markdown', md: 'markdown',
|
||||
java: 'java'
|
||||
java: 'java',
|
||||
sh: 'shell', bash: 'shell', shell: 'shell', zsh: 'shell'
|
||||
}
|
||||
|
||||
return langMap[ext] || 'text'
|
||||
|
||||
@@ -24,3 +24,6 @@ export { php } from '@codemirror/lang-php'
|
||||
export { sql } from '@codemirror/lang-sql'
|
||||
export { markdown } from '@codemirror/lang-markdown'
|
||||
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 hljs from 'highlight.js'
|
||||
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.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
|
||||
|
||||
async function loadMermaid() {
|
||||
@@ -30,7 +47,15 @@ renderer.code = function(token: any) {
|
||||
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
|
||||
return `<pre><code class="hljs language-${lang}" data-theme="auto">${highlighted}</code></pre>`
|
||||
}
|
||||
@@ -53,6 +78,40 @@ renderer.heading = function(token: any) {
|
||||
</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 })
|
||||
|
||||
export { marked }
|
||||
|
||||
Reference in New Issue
Block a user