mirror of
https://github.com/pengzhanbo/vuepress-theme-plume.git
synced 2026-04-23 10:58:13 +08:00
* feat(plugin-md-power): refactor file-tree container * chore: tweak * chore: tweak
This commit is contained in:
parent
7e255412c1
commit
599e43fd3c
@ -1,66 +1,149 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`fileTreePlugin > should work with default options 1`] = `
|
||||
"<div class="vp-file-tree"><ul>
|
||||
<FileTreeItem type="folder" :expanded="true" :empty="false"><span class="tree-node folder"><VPIcon name="vscode-icons:folder-type-docs"></VPIcon><span class="name">docs</span></span>
|
||||
<ul>
|
||||
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="flat-color-icons:info"></VPIcon><span class="name">README.md</span></span></FileTreeItem>
|
||||
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:file-type-markdown"></VPIcon><span class="name">foo.md</span></span></FileTreeItem>
|
||||
</ul>
|
||||
</FileTreeItem>
|
||||
<FileTreeItem type="folder" :expanded="true" :empty="false"><span class="tree-node folder"><VPIcon name="vscode-icons:folder-type-src"></VPIcon><span class="name">src</span></span>
|
||||
<ul>
|
||||
<FileTreeItem type="folder" :expanded="true" :empty="false"><span class="tree-node folder"><VPIcon name="vscode-icons:folder-type-client"></VPIcon><span class="name">client</span></span>
|
||||
<ul>
|
||||
<FileTreeItem type="folder" :expanded="true" :empty="false"><span class="tree-node folder"><VPIcon name="vscode-icons:folder-type-component"></VPIcon><span class="name">components</span></span>
|
||||
<ul>
|
||||
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:file-type-vue"></VPIcon><span class="name focus"><strong>Navbar.vue</strong></span></span></FileTreeItem>
|
||||
</ul>
|
||||
</FileTreeItem>
|
||||
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:file-type-typescript"></VPIcon><span class="name">index.ts</span><span class="comment"># comment</span></span></FileTreeItem>
|
||||
</ul>
|
||||
</FileTreeItem>
|
||||
<FileTreeItem type="folder" :expanded="true" :empty="false"><span class="tree-node folder"><VPIcon name="vscode-icons:default-folder"></VPIcon><span class="name">node</span></span>
|
||||
<ul>
|
||||
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:file-type-typescript"></VPIcon><span class="name">index.ts</span></span></FileTreeItem>
|
||||
</ul>
|
||||
</FileTreeItem>
|
||||
</ul>
|
||||
</FileTreeItem>
|
||||
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:file-type-git"></VPIcon><span class="name">.gitignore</span></span></FileTreeItem>
|
||||
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:file-type-node"></VPIcon><span class="name">package.json</span></span></FileTreeItem>
|
||||
</ul>
|
||||
</div><div class="vp-file-tree"><p class="vp-file-tree-title">files</p><ul>
|
||||
<FileTreeItem type="folder" :expanded="true" :empty="false"><span class="tree-node folder"><VPIcon name="vscode-icons:folder-type-src"></VPIcon><span class="name">src</span></span>
|
||||
<ul>
|
||||
<FileTreeItem type="folder" :expanded="true" :empty="false"><span class="tree-node folder"><VPIcon name="vscode-icons:default-folder"></VPIcon><span class="name">js</span></span>
|
||||
<ul>
|
||||
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><span class="name">…</span></span></FileTreeItem>
|
||||
</ul>
|
||||
</FileTreeItem>
|
||||
<FileTreeItem type="folder" :expanded="false" :empty="true"><span class="tree-node folder"><VPIcon name="vscode-icons:default-folder"></VPIcon><span class="name">vue</span></span></FileTreeItem>
|
||||
<FileTreeItem type="folder" :expanded="false" :empty="true"><span class="tree-node folder"><VPIcon name="vscode-icons:folder-type-css"></VPIcon><span class="name">css</span></span></FileTreeItem>
|
||||
</ul>
|
||||
</FileTreeItem>
|
||||
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="flat-color-icons:info"></VPIcon><span class="name">README.md</span></span></FileTreeItem>
|
||||
</ul>
|
||||
</div><div class="vp-file-tree"><ul>
|
||||
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:default-file"></VPIcon><span class="name">docs</span></span></FileTreeItem>
|
||||
<FileTreeItem type="folder" :expanded="true" :empty="false"><span class="tree-node folder"><VPIcon name="vscode-icons:default-folder"></VPIcon><span class="name">src</span></span>
|
||||
<ul>
|
||||
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:default-file"></VPIcon><span class="name">a.js</span></span></FileTreeItem>
|
||||
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:default-file"></VPIcon><span class="name">b.ts</span></span></FileTreeItem>
|
||||
</ul>
|
||||
</FileTreeItem>
|
||||
<FileTreeItem type="file" :expanded="false" :empty="true"><span class="tree-node file"><VPIcon name="vscode-icons:default-file"></VPIcon><span class="name">README.md</span></span></FileTreeItem>
|
||||
</ul>
|
||||
</div><div class="vp-file-tree"><ul>
|
||||
<li></li>
|
||||
<li>
|
||||
<ul>
|
||||
<li></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div><div class="vp-file-tree"></div>"
|
||||
exports[`fileTree > parseFileTreeRawContent > should work 1`] = `
|
||||
[
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"children": [],
|
||||
"info": "README.md",
|
||||
"level": 1,
|
||||
},
|
||||
{
|
||||
"children": [],
|
||||
"info": "foo.md",
|
||||
"level": 1,
|
||||
},
|
||||
],
|
||||
"info": "docs",
|
||||
"level": 0,
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"children": [],
|
||||
"info": "**Navbar.vue**",
|
||||
"level": 3,
|
||||
},
|
||||
],
|
||||
"info": "components",
|
||||
"level": 2,
|
||||
},
|
||||
{
|
||||
"children": [],
|
||||
"info": "index.ts # comment",
|
||||
"level": 2,
|
||||
},
|
||||
],
|
||||
"info": "client",
|
||||
"level": 1,
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"children": [],
|
||||
"info": "index.ts",
|
||||
"level": 2,
|
||||
},
|
||||
],
|
||||
"info": "node",
|
||||
"level": 1,
|
||||
},
|
||||
],
|
||||
"info": "src",
|
||||
"level": 0,
|
||||
},
|
||||
{
|
||||
"children": [],
|
||||
"info": ".gitignore",
|
||||
"level": 0,
|
||||
},
|
||||
{
|
||||
"children": [],
|
||||
"info": "package.json",
|
||||
"level": 0,
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`fileTreePlugin > should work with default options 1`] = `
|
||||
"<div class="vp-file-tree"><FileTreeNode expanded type="folder" filename="docs">
|
||||
<template #icon><VPIcon name="vscode-icons:folder-type-docs" /></template><FileTreeNode type="file" filename="README.md">
|
||||
<template #icon><VPIcon name="flat-color-icons:info" /></template>
|
||||
</FileTreeNode>
|
||||
<FileTreeNode type="file" filename="foo.md">
|
||||
<template #icon><VPIcon name="vscode-icons:file-type-markdown" /></template>
|
||||
</FileTreeNode>
|
||||
</FileTreeNode>
|
||||
<FileTreeNode expanded type="folder" filename="src">
|
||||
<template #icon><VPIcon name="vscode-icons:folder-type-src" /></template><FileTreeNode expanded type="folder" filename="client">
|
||||
<template #icon><VPIcon name="vscode-icons:folder-type-client" /></template><FileTreeNode expanded type="folder" filename="components">
|
||||
<template #icon><VPIcon name="vscode-icons:folder-type-component" /></template><FileTreeNode focus type="file" filename="Navbar.vue">
|
||||
<template #icon><VPIcon name="vscode-icons:file-type-vue" /></template>
|
||||
</FileTreeNode>
|
||||
</FileTreeNode>
|
||||
<FileTreeNode type="file" filename="index.ts">
|
||||
<template #icon><VPIcon name="vscode-icons:file-type-typescript" /></template><template #comment># comment</template>
|
||||
</FileTreeNode>
|
||||
</FileTreeNode>
|
||||
<FileTreeNode expanded type="folder" filename="node">
|
||||
<template #icon><VPIcon name="vscode-icons:default-folder" /></template><FileTreeNode type="file" filename="index.ts">
|
||||
<template #icon><VPIcon name="vscode-icons:file-type-typescript" /></template>
|
||||
</FileTreeNode>
|
||||
</FileTreeNode>
|
||||
</FileTreeNode>
|
||||
<FileTreeNode type="file" filename=".gitignore">
|
||||
<template #icon><VPIcon name="vscode-icons:file-type-git" /></template>
|
||||
</FileTreeNode>
|
||||
<FileTreeNode type="file" filename="package.json">
|
||||
<template #icon><VPIcon name="vscode-icons:file-type-node" /></template>
|
||||
</FileTreeNode></div>
|
||||
<div class="vp-file-tree"><p class="vp-file-tree-title">files</p><FileTreeNode expanded type="folder" filename="src">
|
||||
<template #icon><VPIcon name="vscode-icons:folder-type-src" /></template><FileTreeNode expanded type="folder" filename="js">
|
||||
<template #icon><VPIcon name="vscode-icons:default-folder" /></template><FileTreeNode type="file" filename="…">
|
||||
|
||||
</FileTreeNode>
|
||||
</FileTreeNode>
|
||||
<FileTreeNode type="folder" filename="vue">
|
||||
<template #icon><VPIcon name="vscode-icons:default-folder" /></template><FileTreeNode type="file" filename="…">
|
||||
|
||||
</FileTreeNode>
|
||||
</FileTreeNode>
|
||||
<FileTreeNode type="folder" filename="css">
|
||||
<template #icon><VPIcon name="vscode-icons:folder-type-css" /></template><FileTreeNode type="file" filename="…">
|
||||
|
||||
</FileTreeNode>
|
||||
</FileTreeNode>
|
||||
</FileTreeNode>
|
||||
<FileTreeNode type="file" filename="README.md">
|
||||
<template #icon><VPIcon name="flat-color-icons:info" /></template>
|
||||
</FileTreeNode></div>
|
||||
<div class="vp-file-tree"><FileTreeNode type="file" filename="docs">
|
||||
<template #icon><VPIcon name="vscode-icons:default-file" /></template>
|
||||
</FileTreeNode>
|
||||
<FileTreeNode expanded type="folder" filename="src">
|
||||
<template #icon><VPIcon name="vscode-icons:default-folder" /></template><FileTreeNode type="file" filename="a.js">
|
||||
<template #icon><VPIcon name="vscode-icons:default-file" /></template>
|
||||
</FileTreeNode>
|
||||
<FileTreeNode type="file" filename="b.ts">
|
||||
<template #icon><VPIcon name="vscode-icons:default-file" /></template>
|
||||
</FileTreeNode>
|
||||
</FileTreeNode>
|
||||
<FileTreeNode type="file" filename="README.md">
|
||||
<template #icon><VPIcon name="vscode-icons:default-file" /></template>
|
||||
</FileTreeNode></div>
|
||||
<div class="vp-file-tree"><FileTreeNode type="file" filename="">
|
||||
<template #icon><VPIcon name="vscode-icons:default-file" /></template>
|
||||
</FileTreeNode>
|
||||
<FileTreeNode expanded type="folder" filename="">
|
||||
<template #icon><VPIcon name="vscode-icons:default-folder" /></template><FileTreeNode type="file" filename="">
|
||||
<template #icon><VPIcon name="vscode-icons:default-file" /></template>
|
||||
</FileTreeNode>
|
||||
</FileTreeNode></div>
|
||||
<div class="vp-file-tree"></div>
|
||||
"
|
||||
`;
|
||||
|
||||
@ -1,7 +1,59 @@
|
||||
import type { FileTreeOptions } from '../src/shared/fileTree.js'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fileTreePlugin } from '../src/node/container/fileTree.js'
|
||||
import { fileTreePlugin, parseFileTreeNodeInfo, parseFileTreeRawContent } from '../src/node/container/fileTree.js'
|
||||
|
||||
describe('fileTree > parseFileTreeRawContent', () => {
|
||||
it('should work', () => {
|
||||
const content = `\
|
||||
- docs
|
||||
- README.md
|
||||
- foo.md
|
||||
|
||||
- src
|
||||
- client
|
||||
- components
|
||||
- **Navbar.vue**
|
||||
- index.ts # comment
|
||||
- node
|
||||
- index.ts
|
||||
- .gitignore
|
||||
- package.json
|
||||
`
|
||||
const nodes = parseFileTreeRawContent(content)
|
||||
expect(nodes).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fileTree > parseFileTreeNodeInfo', () => {
|
||||
it('should work', () => {
|
||||
expect(parseFileTreeNodeInfo('README.md'))
|
||||
.toEqual({ filename: 'README.md', comment: '', focus: false, expanded: true, type: 'file' })
|
||||
|
||||
expect(parseFileTreeNodeInfo('README.md # comment'))
|
||||
.toEqual({ filename: 'README.md', comment: '# comment', focus: false, expanded: true, type: 'file' })
|
||||
|
||||
expect(parseFileTreeNodeInfo('**Navbar.vue**'))
|
||||
.toEqual({ filename: 'Navbar.vue', comment: '', focus: true, expanded: true, type: 'file' })
|
||||
|
||||
expect(parseFileTreeNodeInfo('**Navbar.vue** # comment'))
|
||||
.toEqual({ filename: 'Navbar.vue', comment: '# comment', focus: true, expanded: true, type: 'file' })
|
||||
})
|
||||
|
||||
it('should work with expanded', () => {
|
||||
expect(parseFileTreeNodeInfo('folder/'))
|
||||
.toEqual({ filename: 'folder', comment: '', focus: false, expanded: false, type: 'folder' })
|
||||
|
||||
expect(parseFileTreeNodeInfo('folder/ # comment'))
|
||||
.toEqual({ filename: 'folder', comment: '# comment', focus: false, expanded: false, type: 'folder' })
|
||||
|
||||
expect(parseFileTreeNodeInfo('**folder/**'))
|
||||
.toEqual({ filename: 'folder', comment: '', focus: true, expanded: false, type: 'folder' })
|
||||
|
||||
expect(parseFileTreeNodeInfo('**folder/** # comment'))
|
||||
.toEqual({ filename: 'folder', comment: '# comment', focus: true, expanded: false, type: 'folder' })
|
||||
})
|
||||
})
|
||||
|
||||
function createMarkdown(options?: FileTreeOptions) {
|
||||
return new MarkdownIt().use(fileTreePlugin, options)
|
||||
|
||||
@ -1,183 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'file' | 'folder'
|
||||
expanded: boolean
|
||||
empty: boolean
|
||||
}>()
|
||||
|
||||
const active = ref(!!props.expanded)
|
||||
const el = ref<HTMLElement>()
|
||||
|
||||
function toggle(e: HTMLElementEventMap['click']) {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.matches('.comment') || e.currentTarget === target)
|
||||
return
|
||||
active.value = !active.value
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!el.value || props.type !== 'folder')
|
||||
return
|
||||
|
||||
el.value.querySelector('.tree-node.folder')?.addEventListener(
|
||||
'click',
|
||||
toggle as EventListener,
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (!el.value || props.type !== 'folder')
|
||||
return
|
||||
|
||||
el.value.querySelector('.tree-node.folder')?.removeEventListener(
|
||||
'click',
|
||||
toggle as EventListener,
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li ref="el" class="file-tree-item" :class="{ expanded: active }">
|
||||
<slot />
|
||||
<ul v-if="props.type === 'folder' && props.empty">
|
||||
<li class="file-tree-item">
|
||||
<span class="tree-node file">
|
||||
<span class="name">…</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.vp-file-tree {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
background-color: var(--vp-c-bg-safe);
|
||||
border: solid 1px var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
transition: border var(--vp-t-color), background-color var(--vp-t-color);
|
||||
}
|
||||
|
||||
.vp-file-tree .vp-file-tree-title {
|
||||
padding-left: 16px;
|
||||
margin: -16px -16px 0;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
border-bottom: solid 1px var(--vp-c-divider);
|
||||
transition: color var(--vp-t-color), border-color var(--vp-t-color);
|
||||
}
|
||||
|
||||
.vp-file-tree ul {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
list-style: none !important;
|
||||
}
|
||||
|
||||
.file-tree-item {
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.vp-file-tree .file-tree-item {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.file-tree-item .tree-node {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.file-tree-item .tree-node .name {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.file-tree-item .tree-node.folder {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-tree-item .tree-node.folder > .name {
|
||||
color: var(--vp-c-text-1);
|
||||
cursor: pointer;
|
||||
transition: color var(--vp-t-color);
|
||||
}
|
||||
|
||||
.file-tree-item .tree-node.folder > .name:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.file-tree-item .tree-node.folder::before {
|
||||
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M5.536 21.886a1 1 0 0 0 1.033-.064l13-9a1 1 0 0 0 0-1.644l-13-9A1 1 0 0 0 5 3v18a1 1 0 0 0 .536.886'/%3E%3C/svg%3E");
|
||||
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
left: -14px;
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
color: var(--vp-c-text-3);
|
||||
cursor: pointer;
|
||||
content: "";
|
||||
background-color: currentcolor;
|
||||
-webkit-mask: var(--icon) no-repeat;
|
||||
mask: var(--icon) no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
transition: color var(--vp-t-color);
|
||||
}
|
||||
|
||||
.file-tree-item .tree-node .name.focus {
|
||||
position: relative;
|
||||
padding: 0 4px;
|
||||
margin: 0 -4px;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-bg);
|
||||
background-color: var(--vp-c-brand-2);
|
||||
border-radius: 4px;
|
||||
transition: color var(--vp-t-color), background-color var(--vp-t-color);
|
||||
}
|
||||
|
||||
.file-tree-item .tree-node .name.focus:hover {
|
||||
color: var(--vp-c-bg);
|
||||
background-color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.file-tree-item .tree-node .comment {
|
||||
margin-left: 20px;
|
||||
overflow: hidden;
|
||||
color: var(--vp-c-text-3);
|
||||
transition: color var(--vp-t-color);
|
||||
}
|
||||
|
||||
.file-tree-item .tree-node [class*="vpi-"] {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.file-tree-item .tree-node.folder [class*="vpi-"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vp-file-tree .file-tree-item > ul {
|
||||
padding-left: 8px !important;
|
||||
margin: 0 0 0 6px !important;
|
||||
border-left: solid 1px var(--vp-c-divider);
|
||||
transition: border-color var(--vp-t-color);
|
||||
}
|
||||
|
||||
.file-tree-item:not(.expanded) > ul {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-tree-item.expanded > .tree-node.folder::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
</style>
|
||||
211
plugins/plugin-md-power/src/client/components/FileTreeNode.vue
Normal file
211
plugins/plugin-md-power/src/client/components/FileTreeNode.vue
Normal file
@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
import type { Ref } from 'vue'
|
||||
import { FadeInExpandTransition } from '@vuepress/helper/client'
|
||||
import { inject, ref } from 'vue'
|
||||
|
||||
import '@vuepress/helper/transition/fade-in-height-expand.css'
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'file' | 'folder'
|
||||
filename: string
|
||||
expanded?: boolean
|
||||
focus?: boolean
|
||||
}>()
|
||||
|
||||
const activeFileTreeNode = inject<Ref<string>>('active-file-tree-node', ref(''))
|
||||
|
||||
const onNodeClick = inject<
|
||||
(filename: string, type: 'file' | 'folder') => void
|
||||
>('on-file-tree-node-click', () => {})
|
||||
|
||||
const active = ref(props.expanded)
|
||||
|
||||
function nodeClick() {
|
||||
if (props.filename === '…' || props.filename === '...')
|
||||
return
|
||||
|
||||
onNodeClick(props.filename, props.type)
|
||||
}
|
||||
|
||||
function toggle(ev: MouseEvent) {
|
||||
if (props.type === 'folder') {
|
||||
const el = ev.target as HTMLElement
|
||||
if (!el.matches('.comment, .comment *')) {
|
||||
active.value = !active.value
|
||||
nodeClick()
|
||||
}
|
||||
}
|
||||
else {
|
||||
nodeClick()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vp-file-tree-node">
|
||||
<p
|
||||
class="vp-file-tree-info" :class="{
|
||||
[type]: true,
|
||||
focus,
|
||||
expanded: type === 'folder' ? active : false,
|
||||
active: type === 'file' ? activeFileTreeNode === filename : false,
|
||||
}"
|
||||
@click="toggle"
|
||||
>
|
||||
<slot name="icon" />
|
||||
<span class="name" :class="[type]">{{ filename }}</span>
|
||||
<span v-if="$slots.comment" class="comment"><slot name="comment" /></span>
|
||||
</p>
|
||||
<FadeInExpandTransition>
|
||||
<div v-if="type === 'folder'" v-show="active" class="group">
|
||||
<slot />
|
||||
</div>
|
||||
</FadeInExpandTransition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.vp-file-tree {
|
||||
max-width: 100%;
|
||||
padding: 16px;
|
||||
overflow: auto hidden;
|
||||
font-size: 14px;
|
||||
background-color: var(--vp-c-bg-safe);
|
||||
border: solid 1px var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
transition: border var(--vp-t-color), background-color var(--vp-t-color);
|
||||
}
|
||||
|
||||
.vp-file-tree .vp-file-tree-title {
|
||||
padding: 8px 16px;
|
||||
margin: -16px -16px 8px;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
border-bottom: solid 1px var(--vp-c-divider);
|
||||
transition: color var(--vp-t-color), border-color var(--vp-t-color);
|
||||
}
|
||||
|
||||
.vp-file-tree .vp-file-tree-info {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
height: 28px;
|
||||
padding: 2px 0;
|
||||
margin: 0 0 0 16px;
|
||||
line-height: 24px;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
.vp-file-tree .vp-file-tree-info::after {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 0;
|
||||
bottom: 1px;
|
||||
left: -16px;
|
||||
z-index: 0;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
background-color: transparent;
|
||||
border-radius: 6px;
|
||||
transition: background-color var(--vp-t-color);
|
||||
}
|
||||
|
||||
.vp-file-tree .vp-file-tree-info.active::after,
|
||||
.vp-file-tree .vp-file-tree-info:hover::after {
|
||||
background-color: var(--vp-c-default-soft);
|
||||
}
|
||||
|
||||
.vp-file-tree .vp-file-tree-info.folder {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vp-file-tree .vp-file-tree-info.folder::before {
|
||||
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='%23000' d='m5.157 13.069l4.611-4.685a.546.546 0 0 0 0-.768L5.158 2.93a.55.55 0 0 1 0-.771a.53.53 0 0 1 .759 0l4.61 4.684a1.65 1.65 0 0 1 0 2.312l-4.61 4.684a.53.53 0 0 1-.76 0a.55.55 0 0 1 0-.771'/%3E%3C/svg%3E");
|
||||
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: -16px;
|
||||
display: block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
cursor: pointer;
|
||||
content: "";
|
||||
background-color: currentcolor;
|
||||
-webkit-mask: var(--icon) no-repeat;
|
||||
mask: var(--icon) no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
transition: color var(--vp-t-color), transform var(--vp-t-color);
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
.vp-file-tree .vp-file-tree-info.folder.expanded::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.vp-file-tree .vp-file-tree-info .name {
|
||||
position: relative;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.vp-file-tree .vp-file-tree-info.folder .name {
|
||||
color: var(--vp-c-text-1);
|
||||
transition: color var(--vp-t-color);
|
||||
}
|
||||
|
||||
.vp-file-tree .vp-file-tree-info.focus .name {
|
||||
padding: 0 4px;
|
||||
margin: 0 -4px;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-bg);
|
||||
background-color: var(--vp-c-brand-2);
|
||||
border-radius: 4px;
|
||||
transition: color var(--vp-t-color), background-color var(--vp-t-color);
|
||||
}
|
||||
|
||||
.vp-file-tree .vp-file-tree-info.active .name {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.vp-file-tree .vp-file-tree-info:not(.focus).folder .name:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.vp-file-tree .vp-file-tree-info .comment {
|
||||
display: inline-block;
|
||||
flex: 1 2;
|
||||
height: 28px;
|
||||
padding-right: 16px;
|
||||
padding-left: 20px;
|
||||
margin: -2px 0;
|
||||
color: var(--vp-c-text-3);
|
||||
cursor: auto;
|
||||
transition: color var(--vp-t-color);
|
||||
}
|
||||
|
||||
.vp-file-tree .vp-file-tree-node .group {
|
||||
position: relative;
|
||||
margin-left: 28px;
|
||||
}
|
||||
|
||||
.vp-file-tree .vp-file-tree-node .group::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -4px;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
content: "";
|
||||
background-color: var(--vp-c-divider);
|
||||
transition: background-color var(--vp-t-color);
|
||||
}
|
||||
|
||||
.vp-file-tree [class*="vpi-"] {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@ -1,17 +1,14 @@
|
||||
import type { Markdown } from 'vuepress/markdown'
|
||||
import type { FileTreeIconMode, FileTreeOptions } from '../../shared/index.js'
|
||||
import container from 'markdown-it-container'
|
||||
import Token from 'markdown-it/lib/token.mjs'
|
||||
import { removeEndingSlash, removeLeadingSlash } from 'vuepress/shared'
|
||||
import { removeEndingSlash } from 'vuepress/shared'
|
||||
import { defaultFile, defaultFolder, getFileIcon } from '../fileIcons/index.js'
|
||||
import { resolveAttrs } from '../utils/resolveAttrs.js'
|
||||
import { stringifyAttrs } from '../utils/stringifyAttrs.js'
|
||||
import { createContainerSyntaxPlugin } from './createContainer.js'
|
||||
|
||||
interface FileTreeNode {
|
||||
filename: string
|
||||
type: 'folder' | 'file'
|
||||
expanded: boolean
|
||||
focus: boolean
|
||||
empty: boolean
|
||||
info: string
|
||||
level: number
|
||||
children: FileTreeNode[]
|
||||
}
|
||||
|
||||
interface FileTreeAttrs {
|
||||
@ -19,11 +16,63 @@ interface FileTreeAttrs {
|
||||
icon?: FileTreeIconMode
|
||||
}
|
||||
|
||||
const type = 'file-tree'
|
||||
const closeType = `container_${type}_close`
|
||||
const componentName = 'FileTreeItem'
|
||||
const itemOpen = 'file_tree_item_open'
|
||||
const itemClose = 'file_tree_item_close'
|
||||
export function parseFileTreeRawContent(content: string): FileTreeNode[] {
|
||||
const root: FileTreeNode = { info: '', level: -1, children: [] }
|
||||
const stack: FileTreeNode[] = [root]
|
||||
const lines = content.trim().split('\n')
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^(\s*)-(.*)$/)
|
||||
if (!match)
|
||||
continue
|
||||
|
||||
const level = Math.floor(match[1].length / 2) // 每两个空格为一个层级
|
||||
const info = match[2].trim()
|
||||
|
||||
// 检索当前层级的父节点
|
||||
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
|
||||
stack.pop()
|
||||
}
|
||||
|
||||
const parent = stack[stack.length - 1]
|
||||
const node: FileTreeNode = { info, level, children: [] }
|
||||
parent.children.push(node)
|
||||
stack.push(node)
|
||||
}
|
||||
|
||||
return root.children
|
||||
}
|
||||
|
||||
const RE_FOCUS = /^\*\*(.*)\*\*(?:$|\s+)/
|
||||
|
||||
export function parseFileTreeNodeInfo(info: string) {
|
||||
let filename = ''
|
||||
let comment = ''
|
||||
let focus = false
|
||||
let expanded: boolean | undefined = true
|
||||
let type: 'folder' | 'file' = 'file'
|
||||
|
||||
info = info.replace(RE_FOCUS, (_, matched) => {
|
||||
filename = matched
|
||||
focus = true
|
||||
return ''
|
||||
})
|
||||
|
||||
if (filename === '' && !focus) {
|
||||
const spaceIndex = info.indexOf(' ')
|
||||
filename = info.slice(0, spaceIndex === -1 ? info.length : spaceIndex)
|
||||
info = spaceIndex === -1 ? '' : info.slice(spaceIndex)
|
||||
}
|
||||
|
||||
comment = info.trim()
|
||||
|
||||
if (filename.endsWith('/')) {
|
||||
type = 'folder'
|
||||
expanded = false
|
||||
filename = removeEndingSlash(filename)
|
||||
}
|
||||
|
||||
return { filename, comment, focus, expanded, type }
|
||||
}
|
||||
|
||||
export function fileTreePlugin(md: Markdown, options: FileTreeOptions = {}) {
|
||||
const getIcon = (filename: string, type: 'folder' | 'file', mode?: FileTreeIconMode): string => {
|
||||
@ -33,166 +82,44 @@ export function fileTreePlugin(md: Markdown, options: FileTreeOptions = {}) {
|
||||
return getFileIcon(filename, type)
|
||||
}
|
||||
|
||||
const render = (tokens: Token[], idx: number): string => {
|
||||
const { attrs } = resolveAttrs<FileTreeAttrs>(tokens[idx].info.slice(type.length - 1))
|
||||
const renderFileTree = (nodes: FileTreeNode[], meta: FileTreeAttrs): string =>
|
||||
nodes.map((node) => {
|
||||
const { info, level, children } = node
|
||||
const { filename, comment, focus, expanded, type } = parseFileTreeNodeInfo(info)
|
||||
const isOmit = filename === '…' || filename === '...' /* fallback */
|
||||
|
||||
if (tokens[idx].nesting === 1) {
|
||||
const hasRes: number[] = [] // level stack
|
||||
for (
|
||||
let i = idx + 1;
|
||||
!(tokens[i].nesting === -1
|
||||
&& tokens[i].type === closeType);
|
||||
++i
|
||||
) {
|
||||
const token = tokens[i]
|
||||
if (token.type === 'list_item_open') {
|
||||
const result = resolveTreeNodeInfo(tokens, token, i)
|
||||
if (result) {
|
||||
hasRes.push(token.level)
|
||||
const [info, inline] = result
|
||||
const { filename, type, expanded, empty } = info
|
||||
const icon = getIcon(filename, type, attrs.icon)
|
||||
|
||||
token.type = itemOpen
|
||||
token.tag = componentName
|
||||
token.attrSet('type', type)
|
||||
token.attrSet(':expanded', expanded ? 'true' : 'false')
|
||||
token.attrSet(':empty', empty ? 'true' : 'false')
|
||||
updateInlineToken(inline, info, icon)
|
||||
}
|
||||
else {
|
||||
hasRes.push(-1)
|
||||
}
|
||||
}
|
||||
else if (token.type === 'list_item_close') {
|
||||
if (token.level === hasRes.pop()) {
|
||||
token.type = itemClose
|
||||
token.tag = componentName
|
||||
}
|
||||
}
|
||||
if (children.length === 0 && type === 'folder') {
|
||||
children.push({ info: '…', level: level + 1, children: [] })
|
||||
}
|
||||
const title = attrs.title
|
||||
return `<div class="vp-file-tree">${title ? `<p class="vp-file-tree-title">${title}</p>` : ''}`
|
||||
}
|
||||
else {
|
||||
return '</div>'
|
||||
}
|
||||
}
|
||||
|
||||
md.use(container, type, { render })
|
||||
}
|
||||
|
||||
export function resolveTreeNodeInfo(
|
||||
tokens: Token[],
|
||||
current: Token,
|
||||
idx: number,
|
||||
): [FileTreeNode, Token] | undefined {
|
||||
let hasInline = false
|
||||
let hasChildren = false
|
||||
let inline!: Token
|
||||
for (
|
||||
let i = idx + 1;
|
||||
!(tokens[i].level === current.level && tokens[i].type === 'list_item_close');
|
||||
++i
|
||||
) {
|
||||
if (tokens[i].type === 'inline' && !hasInline) {
|
||||
inline = tokens[i]
|
||||
hasInline = true
|
||||
}
|
||||
else if (tokens[i].tag === 'ul') {
|
||||
hasChildren = true
|
||||
}
|
||||
|
||||
if (hasInline && hasChildren)
|
||||
break
|
||||
}
|
||||
|
||||
if (!hasInline)
|
||||
return undefined
|
||||
|
||||
const children = inline.children!.filter(token => (token.type === 'text' && token.content) || token.tag === 'strong')
|
||||
const filename = children.filter(token => token.type === 'text').map(token => token.content).join(' ').split(/\s+/)[0]
|
||||
const focus = children[0]?.tag === 'strong'
|
||||
const type = hasChildren || filename.endsWith('/') ? 'folder' : 'file'
|
||||
const info: FileTreeNode = {
|
||||
filename: removeLeadingSlash(removeEndingSlash(filename)),
|
||||
type,
|
||||
focus,
|
||||
empty: !hasChildren,
|
||||
expanded: type === 'folder' && !filename.endsWith('/'),
|
||||
}
|
||||
|
||||
return [info, inline] as const
|
||||
}
|
||||
|
||||
export function updateInlineToken(inline: Token, info: FileTreeNode, icon: string) {
|
||||
const children = inline.children!
|
||||
|
||||
const tokens: Token[] = []
|
||||
const wrapperOpen = new Token('span_open', 'span', 1)
|
||||
const wrapperClose = new Token('span_close', 'span', -1)
|
||||
|
||||
wrapperOpen.attrSet('class', `tree-node ${info.type}`)
|
||||
tokens.push(wrapperOpen)
|
||||
|
||||
if (info.filename !== '...' && info.filename !== '…') {
|
||||
const iconOpen = new Token('vp_iconify_open', 'VPIcon', 1)
|
||||
iconOpen.attrSet('name', icon)
|
||||
const iconClose = new Token('vp_iconify_close', 'VPIcon', -1)
|
||||
|
||||
tokens.push(iconOpen, iconClose)
|
||||
}
|
||||
|
||||
const fileOpen = new Token('span_open', 'span', 1)
|
||||
fileOpen.attrSet('class', `name${info.focus ? ' focus' : ''}`)
|
||||
tokens.push(fileOpen)
|
||||
|
||||
let isStrongTag = false
|
||||
while (children.length) {
|
||||
const token = children.shift()!
|
||||
if (token.type === 'text' && token.content) {
|
||||
if (token.content.includes(' ')) {
|
||||
const [first, ...other] = token.content.split(' ')
|
||||
const text = new Token('text', '', 0)
|
||||
text.content = removeEndingSlash(first)
|
||||
tokens.push(text)
|
||||
const comment = new Token('text', '', 0)
|
||||
comment.content = other.join(' ')
|
||||
children.unshift(comment)
|
||||
}
|
||||
else {
|
||||
token.content = removeEndingSlash(token.content)
|
||||
tokens.push(token)
|
||||
}
|
||||
if (!isStrongTag)
|
||||
break
|
||||
}
|
||||
else if (token.tag === 'strong') {
|
||||
token.content = removeEndingSlash(token.content)
|
||||
tokens.push(token)
|
||||
if (token.nesting === 1) {
|
||||
isStrongTag = true
|
||||
}
|
||||
else {
|
||||
break
|
||||
}
|
||||
}
|
||||
else {
|
||||
tokens.push(token)
|
||||
}
|
||||
}
|
||||
|
||||
const fileClose = new Token('span_close', 'span', -1)
|
||||
tokens.push(fileClose)
|
||||
|
||||
if (children.filter(token => token.type === 'text' && token.content.trim()).length) {
|
||||
const commentOpen = new Token('span_open', 'span', 1)
|
||||
commentOpen.attrSet('class', 'comment')
|
||||
const commentClose = new Token('span_close', 'span', -1)
|
||||
|
||||
tokens.push(commentOpen, ...children, commentClose)
|
||||
}
|
||||
|
||||
tokens.push(wrapperClose)
|
||||
inline.children = tokens
|
||||
const nodeType = children.length > 0 ? 'folder' : type
|
||||
const renderedComment = comment
|
||||
? `<template #comment>${md.renderInline(comment.replaceAll('#', '\#'))}</template>`
|
||||
: ''
|
||||
const renderedIcon = !isOmit
|
||||
? `<template #icon><VPIcon name="${getIcon(filename, nodeType, meta.icon)}" /></template>`
|
||||
: ''
|
||||
const props = {
|
||||
expanded: nodeType === 'folder' ? expanded : false,
|
||||
focus,
|
||||
type: nodeType,
|
||||
filename,
|
||||
}
|
||||
return `<FileTreeNode${stringifyAttrs(props)}>
|
||||
${renderedIcon}${renderedComment}${children.length > 0 ? renderFileTree(children, meta) : ''}
|
||||
</FileTreeNode>`
|
||||
}).join('\n')
|
||||
|
||||
return createContainerSyntaxPlugin(
|
||||
md,
|
||||
'file-tree',
|
||||
(tokens, index) => {
|
||||
const token = tokens[index]
|
||||
const nodes = parseFileTreeRawContent(token.content)
|
||||
const meta = token.meta as FileTreeAttrs
|
||||
return `<div class="vp-file-tree">${
|
||||
meta.title ? `<p class="vp-file-tree-title">${meta.title}</p>` : ''
|
||||
}${renderFileTree(nodes, meta)}</div>\n`
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -71,8 +71,8 @@ export async function prepareConfigFile(app: App, options: MarkdownPowerPluginOp
|
||||
}
|
||||
|
||||
if (options.fileTree) {
|
||||
imports.add(`import FileTreeItem from '${CLIENT_FOLDER}components/FileTreeItem.vue'`)
|
||||
enhances.add(`app.component('FileTreeItem', FileTreeItem)`)
|
||||
imports.add(`import FileTreeNode from '${CLIENT_FOLDER}components/FileTreeNode.vue'`)
|
||||
enhances.add(`app.component('FileTreeNode', FileTreeNode)`)
|
||||
}
|
||||
|
||||
if (options.artPlayer) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user