439 lines
9.1 KiB
Vue
439 lines
9.1 KiB
Vue
|
<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>
|