439 lines
9.1 KiB
Vue
Raw Normal View History

2025-04-26 13:15:30 +08:00
<template>
<div class="markdown-viewer">
<!-- 目录侧边栏 -->
<aside class="toc-sidebar" ref="tocSidebar">
<div class="toc-header">
<h2>目录</h2>
</div>
<div class="toc-content">
<MDOutlineTree :data="tocTreeData"/>
</div>
</aside>
<!-- 主内容区域 -->
<main class="content" ref="content">
<div v-if="loading" class="loading-indicator">加载中...</div>
<div v-else-if="error" class="error-message">{{ error }}</div>
<div v-else v-html="renderedContent" class="markdown-content"></div>
</main>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick, onBeforeMount, unref } from 'vue';
import { marked } from 'marked';
import { throttle } from 'lodash-es';
import MDOutlineTree from './MDOutlineTree.vue'
let hljs;
const hljsPms = import('highlight.js').then(res => {
hljs = window.hljs = res.default;
return import('highlightjs-line-numbers.js');
}).then(() => {
hljs.initLineNumbersOnLoad();
return hljs;
});
const props = defineProps({
filePath: {
type: String,
required: true
},
debounceTime: {
type: Number,
default: 100
}
});
// 响应式数据
const loading = ref(true);
const error = ref(null);
const markdownText = ref('');
const renderedContent = ref('');
const toc = ref([]);
const activeSection = ref('');
const content = ref(null);
const tocSidebar = ref(null);
const tocTreeData = ref([])
// 配置marked
const renderer = new marked.Renderer();
renderer.code = (code, language) => {
const highlighted = hljs.highlightAuto(code).value;
return `
<div class="code-block">
<pre class="hljs"><code>${highlighted}</code></pre>
</div>
`;
};
marked.setOptions({ renderer });
// 渲染h头
renderer.heading = ({ text, depth }) => {
const escapedText = toc.value.length
// text.toLowerCase().replace(/[^\w]+/g, '-');
toc.value.push({
id: escapedText,
level: depth,
title: text,
anchor: escapedText
})
const node = {
key: escapedText,
title: text
}
let parent;
let currParent;
let data;
let loopData = data = unref(tocTreeData.value);
let d = depth;
while (d >= 1) {
if (d === 1) {
loopData.push(node);
}
parent = loopData.at(-1);
if (!parent) {
loopData.push(parent = { key: Math.rendom(), children: [] });
}
loopData = parent.children = parent.children ?? [];
d--;
}
return `
<h${depth} id="${escapedText}">
<a name="${escapedText}" class="anchor" href="#${escapedText}">
<span class="header-link"></span>
</a>
${text}
</h${depth}>`;
};
let renderList = renderer.listitem
renderer.listitem = (src)=>{
return renderList.call(renderer, src);
}
marked.setOptions({
renderer,
highlight: (code, lang) => {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(lang, code).value;
}
return hljs.highlightAuto(code).value;
},
pedantic: false,
gfm: true,
breaks: false,
sanitize: false,
smartLists: true,
smartypants: false,
xhtml: false
});
// 加载Markdown文件
const loadMarkdownFile = async () => {
try {
const filePath = props.filePath
loading.value = true;
error.value = null;
let fetchMdPms
if(filePath.startsWith('__localWebViewPath/')){
if(!window.vscodeApi){
console.error('获取__localWebViewPath的md文档时, 无法获取VSCode API')
return
}
window.vscodeApi.postMessage({
type: 'log',
message: '[MarkdownViewer] 获取本地markdown...' + filePath
});
window.vscodeApi.postMessage({
type: 'loadMd',
mdPath: filePath
});
window.addEventListener('message', event => {
if (event.data.type === 'mdContent') {
window.vscodeApi.postMessage({
type: 'log',
message: '[MarkdownViewer] 获取本地markdown成功'
});
markdownText.value = event.data.data
}
});
}else{
fetchMdPms = fetch(props.filePath);
const response = await fetchMdPms
if (!response.ok) throw new Error('无法加载Markdown文件');
markdownText.value = await response.text();
}
await hljsPms;
// 初始化高亮和行号插件
hljs.highlightAll();
renderMarkdown();
} catch (err) {
error.value = err.message;
console.error('加载Markdown文件失败:', err);
} finally {
loading.value = false;
}
};
// 渲染Markdown
const renderMarkdown = () => {
console.log(markdownText.value);
toc.value = []; // 清空目录
renderedContent.value = marked(markdownText.value);
// 等待DOM更新后初始化高亮
nextTick(() => {
document.querySelectorAll('pre code').forEach(block => {
hljs.highlightElement(block);
});
// setupHeadingObservers();
});
};
// 设置IntersectionObserver来检测当前可见的标题
let observer = null;
const setupHeadingObservers = () => {
// 先清理之前的observer
if (observer) {
observer.disconnect();
}
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
activeSection.value = entry.target.id;
}
});
},
{
root: null,
rootMargin: '0px 0px -50% 0px',
threshold: [0, 0.5, 1]
}
);
headings.forEach(heading => {
observer.observe(heading);
});
};
// 滚动到指定标题
const scrollToHeading = (anchor) => {
const element = document.getElementById(anchor);
if (element) {
// 平滑滚动
element.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
// 更新URL哈希而不触发滚动
history.replaceState(null, null, `#${anchor}`);
}
};
// 处理窗口大小变化
const handleResize = throttle(() => {
if (window.innerWidth >= 768) {
}
}, props.debounceTime);
// 生命周期钩子
onBeforeMount(()=>{
loadMarkdownFile();
})
onMounted(() => {
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
if (observer) {
observer.disconnect();
}
});
</script>
<style scoped>
/* 行号容器样式 */
.hljs-ln-numbers {
text-align: center;
border-right: 1px solid;
/* 右侧分割线 */
padding-right: 10px;
}
/* 代码块容器 */
.code-block {
position: relative;
padding-left: 3em;
/* 留出行号空间 */
}
/* 行号与代码对齐 */
.hljs-ln td:first-child {
vertical-align: top;
}
.markdown-viewer {
display: flex;
/* min-height: 100vh; */
position: relative;
}
.toc-sidebar {
width: min(30vw, 280px);
height: calc(100vh - 100px);
overflow-y: auto;
padding: 20px;
box-sizing: border-box;
border-right: 1px solid;
position: sticky;
top: 0;
transition: transform 0.3s ease;
z-index: 10;
}
.toc-header {
padding-bottom: 15px;
border-bottom: 1px solid;
margin-bottom: 15px;
}
.toc-header h2 {
margin: 0;
font-size: 1.2rem;
}
.toc-content {
height: calc(100vh - 200px);
}
.toc-content ul {
list-style: none;
padding: 0;
margin: 0;
}
.toc-content li {
margin: 6px 0;
line-height: 1.4;
}
.toc-content a {
display: block;
padding: 4px 8px;
text-decoration: none;
border-radius: 3px;
transition: all 0.2s;
font-size: 0.9rem;
}
.toc-content a.active {
font-weight: 600;
}
.content {
flex: 1;
height: calc(100vh - 100px);
padding: 16px;
overflow-y: auto;
max-width: 800px;
margin: 0 auto;
}
.loading-indicator,
.error-message {
padding: 20px;
text-align: center;
}
/* Markdown内容样式 */
.markdown-content {
line-height: 1.6;
}
.markdown-content :deep(h1),
.markdown-content :deep(h2),
.markdown-content :deep(h3),
.markdown-content :deep(h4),
.markdown-content :deep(h5),
.markdown-content :deep(h6) {
margin-top: 1.5em;
margin-bottom: 0.8em;
position: relative;
}
.markdown-content :deep(h1) {
font-size: 2em;
padding-bottom: 0.3em;
}
.markdown-content :deep(h2) {
font-size: 1.5em;
padding-bottom: 0.3em;
}
.markdown-content :deep(.anchor) {
position: absolute;
left: -20px;
opacity: 0;
transition: opacity 0.2s;
}
.markdown-content :deep(h1:hover .anchor),
.markdown-content :deep(h2:hover .anchor),
.markdown-content :deep(h3:hover .anchor),
.markdown-content :deep(h4:hover .anchor),
.markdown-content :deep(h5:hover .anchor),
.markdown-content :deep(h6:hover .anchor) {
opacity: 1;
}
/* 移动端样式 */
@media (max-width: 400px) {
.mobile-menu-button {
display: block;
}
.toc-sidebar {
position: fixed;
top: 100px;
bottom: 0;
left: 0;
transform: translateX(-100%);
width: 30%;
max-width: 300px;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
}
.markdown-viewer.mobile-menu-open .toc-sidebar {
transform: translateX(0);
}
.content {
padding: 20px;
width: 100%;
max-width: none;
}
}
/* 代码块样式 */
.markdown-content :deep(pre) {
border-radius: 6px;
padding: 16px;
overflow: auto;
line-height: 1.45;
margin-bottom: 16px;
}
.markdown-content :deep(code) {
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 85%;
}
</style>