md2ui 1.0.18 → 1.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -58
- package/bin/build.js +95 -9
- package/bin/md2ui.js +102 -13
- package/package.json +24 -10
- package/public/docs/00-/345/277/253/351/200/237/345/274/200/345/247/213.md +48 -28
- package/public/docs/01-/345/212/237/350/203/275/347/211/271/346/200/247.md +55 -40
- package/public/docs/02-Markdown/346/270/262/346/237/223/00-/345/237/272/347/241/200/350/257/255/346/263/225.md +88 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/01-/344/273/243/347/240/201/345/235/227.md +91 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/02-/350/241/250/346/240/274.md +187 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/03-Mermaid/345/233/276/350/241/250.md +101 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/04-Frontmatter.md +32 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/05-/346/225/260/345/255/246/345/205/254/345/274/217.md +47 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/06-Mermaid/345/244/215/346/235/202/345/233/276/350/241/250/346/265/213/350/257/225.md +1376 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/assets/img-1777383093712.png +0 -0
- package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/00-/344/270/211/346/240/217/345/270/203/345/261/200.md +33 -0
- package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/01-/347/233/256/345/275/225/346/240/221/345/257/274/350/210/252.md +43 -0
- package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/02-/346/226/207/346/241/243/345/244/247/347/272/262.md +51 -0
- package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/03-/344/270/212/344/270/213/347/257/207/345/257/274/350/210/252.md +29 -0
- package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/04-/347/253/231/345/206/205/351/223/276/346/216/245.md +39 -0
- package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/05-/345/244/247/347/272/262/345/216/213/345/212/233/346/265/213/350/257/225.md +340 -0
- package/public/docs/04-/346/220/234/347/264/242/345/212/237/350/203/275/00-/345/205/250/346/226/207/346/220/234/347/264/242.md +46 -0
- package/public/docs/05-/347/274/226/350/276/221/345/212/237/350/203/275/00-/347/274/226/350/276/221/345/231/250/345/237/272/347/241/200.md +65 -0
- package/public/docs/05-/347/274/226/350/276/221/345/212/237/350/203/275/01-/350/207/252/345/212/250/344/277/235/345/255/230.md +38 -0
- package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/00-/351/230/205/350/257/273/350/277/233/345/272/246.md +43 -0
- package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/01-/345/233/276/347/211/207/346/224/276/345/244/247.md +40 -0
- package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/02-/350/277/224/345/233/236/351/241/266/351/203/250.md +38 -0
- package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/assets/img-1777261394722.png +0 -0
- package/public/docs/07-/347/247/273/345/212/250/347/253/257/351/200/202/351/205/215/00-/345/223/215/345/272/224/345/274/217/345/270/203/345/261/200.md +37 -0
- package/public/docs/08-/346/226/207/346/241/243/347/256/241/347/220/206/00-/346/226/260/345/273/272/344/270/216/345/210/240/351/231/244.md +47 -0
- package/public/docs/09-/345/257/274/345/207/272/345/212/237/350/203/275/00-/345/257/274/345/207/272Word.md +77 -0
- package/public/docs/10-/351/203/250/347/275/262/344/270/216/351/205/215/347/275/256/00-CLI/345/267/245/345/205/267.md +52 -0
- package/public/docs/10-/351/203/250/347/275/262/344/270/216/351/205/215/347/275/256/01-SSG/351/235/231/346/200/201/346/236/204/345/273/272.md +44 -0
- package/public/docs/10-/351/203/250/347/275/262/344/270/216/351/205/215/347/275/256/02-/350/207/252/345/256/232/344/271/211/351/205/215/347/275/256.md +58 -0
- package/public/docs/11-/345/244/232/347/272/247/347/233/256/345/275/225/346/265/213/350/257/225/00-/344/270/200/347/272/247/346/226/207/346/241/243.md +20 -0
- package/public/docs/11-/345/244/232/347/272/247/347/233/256/345/275/225/346/265/213/350/257/225/01-/345/255/220/347/233/256/345/275/225/00-/344/272/214/347/272/247/346/226/207/346/241/243.md +13 -0
- package/public/docs/11-/345/244/232/347/272/247/347/233/256/345/275/225/346/265/213/350/257/225/01-/345/255/220/347/233/256/345/275/225/01-/346/267/261/345/261/202/345/265/214/345/245/227/00-/344/270/211/347/272/247/346/226/207/346/241/243.md +23 -0
- package/src/App.vue +111 -12
- package/src/components/AppSidebar.vue +181 -21
- package/src/components/CodeBlockNodeView.vue +72 -0
- package/src/components/DocContent.vue +25 -14
- package/src/components/EditorContent.vue +257 -0
- package/src/components/EditorToolbar.vue +264 -0
- package/src/components/ImageZoom.vue +88 -5
- package/src/components/MathBlockNodeView.vue +160 -0
- package/src/components/MathInlineNodeView.vue +145 -0
- package/src/components/MermaidNodeView.vue +157 -0
- package/src/components/MobileSearch.vue +97 -0
- package/src/components/TableOfContents.vue +174 -32
- package/src/components/TopBar.vue +69 -4
- package/src/components/TreeNode.vue +232 -39
- package/src/components/WelcomePage.vue +2 -2
- package/src/composables/useDocHash.js +9 -1
- package/src/composables/useDocManager.js +452 -105
- package/src/composables/useDocTree.js +33 -2
- package/src/composables/useExportWord.js +73 -10
- package/src/composables/useFileWatcher.js +45 -0
- package/src/composables/useFrontmatter.js +2 -2
- package/src/composables/useMarkdown.js +450 -52
- package/src/composables/useMermaidCache.js +15 -0
- package/src/composables/useScroll.js +354 -27
- package/src/composables/useSearch.js +12 -11
- package/src/config.js +1 -4
- package/src/extensions/CodeBlockCustom.js +113 -0
- package/src/extensions/MathBlock.js +107 -0
- package/src/extensions/MathInline.js +100 -0
- package/src/extensions/MermaidBlock.js +73 -0
- package/src/extensions/TableControls.js +670 -0
- package/src/services/DocService.js +168 -0
- package/src/style.css +2416 -36
- package/src/utils/imageConverter.js +129 -0
- package/vite-plugin-doc-api.js +369 -0
- package/vite.config.js +7 -2
- package/public/docs/02-Mermaid/345/233/276/350/241/250.md +0 -102
- package/public/docs/03-/350/277/233/351/230/266/346/214/207/345/215/227/01-/347/233/256/345/275/225/347/273/223/346/236/204.md +0 -55
- package/public/docs/03-/350/277/233/351/230/266/346/214/207/345/215/227/02-/350/207/252/345/256/232/344/271/211/351/205/215/347/275/256.md +0 -63
- package/public/docs/03-/350/277/233/351/230/266/346/214/207/345/215/227/03-/351/203/250/347/275/262/346/226/271/346/241/210.md +0 -73
- package/public/docs/04-API/345/217/202/350/200/203/01-/347/273/204/344/273/266API.md +0 -80
- package/public/docs/04-API/345/217/202/350/200/203/02-Composables.md +0 -92
- package/src/api/docs.js +0 -106
- package/src/components/SearchPanel.vue +0 -90
|
@@ -5,45 +5,187 @@
|
|
|
5
5
|
<List :size="16" />
|
|
6
6
|
<span>目录</span>
|
|
7
7
|
</div>
|
|
8
|
-
<
|
|
9
|
-
<
|
|
10
|
-
|
|
8
|
+
<div class="toc-header-actions">
|
|
9
|
+
<button class="toc-action-btn" @click="expandAll" title="全部展开">
|
|
10
|
+
<ChevronsDownUp :size="14" />
|
|
11
|
+
</button>
|
|
12
|
+
<button class="toc-action-btn" @click="collapseAll" title="全部收起">
|
|
13
|
+
<ChevronsUpDown :size="14" />
|
|
14
|
+
</button>
|
|
15
|
+
<button class="toc-toggle" @click="$emit('toggle')" title="收起目录">
|
|
16
|
+
<ChevronRight :size="14" />
|
|
17
|
+
</button>
|
|
18
|
+
</div>
|
|
11
19
|
</div>
|
|
12
|
-
<nav class="toc-nav">
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
<nav ref="tocNavRef" class="toc-nav">
|
|
21
|
+
<!-- 左侧连续竖线 -->
|
|
22
|
+
<div class="toc-track"></div>
|
|
23
|
+
<!-- marker 滑块(平滑过渡) -->
|
|
24
|
+
<div class="toc-marker" :style="markerStyle"></div>
|
|
25
|
+
<template v-for="(item, index) in tocItems" :key="item.id">
|
|
26
|
+
<!-- 顶级标题(h1/h2):可点击折叠 -->
|
|
27
|
+
<a
|
|
28
|
+
v-if="isTopLevel(item)"
|
|
29
|
+
:href="`#${item.id}`"
|
|
30
|
+
:data-toc-id="item.id"
|
|
31
|
+
:class="['toc-item', `toc-level-${item.level}`, { active: activeHeading === item.id }]"
|
|
32
|
+
@click.prevent="$emit('scroll-to', item.id)"
|
|
33
|
+
>
|
|
34
|
+
<span class="toc-item-text">{{ item.text }}</span>
|
|
35
|
+
<button
|
|
36
|
+
v-if="hasChildren(index)"
|
|
37
|
+
class="toc-fold-btn"
|
|
38
|
+
@click.prevent.stop="toggleSection(item.id)"
|
|
39
|
+
:title="collapsedSections.has(item.id) ? '展开子目录' : '收起子目录'"
|
|
40
|
+
>
|
|
41
|
+
<ChevronRight :size="12" :class="{ 'toc-fold-icon-open': !collapsedSections.has(item.id) }" class="toc-fold-icon" />
|
|
42
|
+
</button>
|
|
43
|
+
</a>
|
|
44
|
+
<!-- 子标题(h3/h4):受父级折叠控制 -->
|
|
45
|
+
<a
|
|
46
|
+
v-else-if="!isSectionCollapsed(index)"
|
|
47
|
+
:href="`#${item.id}`"
|
|
48
|
+
:data-toc-id="item.id"
|
|
49
|
+
:class="['toc-item', `toc-level-${item.level}`, { active: activeHeading === item.id }]"
|
|
50
|
+
@click.prevent="$emit('scroll-to', item.id)"
|
|
51
|
+
>
|
|
52
|
+
{{ item.text }}
|
|
53
|
+
</a>
|
|
54
|
+
</template>
|
|
22
55
|
</nav>
|
|
23
56
|
</aside>
|
|
24
57
|
</template>
|
|
25
58
|
|
|
26
59
|
<script setup>
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
},
|
|
34
|
-
|
|
35
|
-
type: String,
|
|
36
|
-
default: ''
|
|
37
|
-
},
|
|
38
|
-
collapsed: {
|
|
39
|
-
type: Boolean,
|
|
40
|
-
default: false
|
|
41
|
-
},
|
|
42
|
-
width: {
|
|
43
|
-
type: Number,
|
|
44
|
-
default: 240
|
|
45
|
-
}
|
|
60
|
+
import { ref, watch, nextTick, reactive } from 'vue'
|
|
61
|
+
import { List, ChevronRight, ChevronsDownUp, ChevronsUpDown } from 'lucide-vue-next'
|
|
62
|
+
|
|
63
|
+
const props = defineProps({
|
|
64
|
+
tocItems: { type: Array, default: () => [] },
|
|
65
|
+
activeHeading: { type: String, default: '' },
|
|
66
|
+
collapsed: { type: Boolean, default: false },
|
|
67
|
+
width: { type: Number, default: 240 }
|
|
46
68
|
})
|
|
47
69
|
|
|
48
70
|
defineEmits(['scroll-to', 'toggle'])
|
|
71
|
+
|
|
72
|
+
// 目录导航容器 ref
|
|
73
|
+
const tocNavRef = ref(null)
|
|
74
|
+
|
|
75
|
+
// marker 滑块位置
|
|
76
|
+
const markerStyle = reactive({ top: '0px', height: '0px', opacity: 0 })
|
|
77
|
+
|
|
78
|
+
// 记录被收起的顶级标题 id
|
|
79
|
+
const collapsedSections = ref(new Set())
|
|
80
|
+
|
|
81
|
+
// 判断是否为顶级标题(h1 或 h2)
|
|
82
|
+
function isTopLevel(item) {
|
|
83
|
+
return item.level <= 2
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 判断某个顶级标题后面是否有子标题
|
|
87
|
+
function hasChildren(index) {
|
|
88
|
+
const next = props.tocItems[index + 1]
|
|
89
|
+
return next && next.level > props.tocItems[index].level
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 找到某个子标题对应的父级顶级标题
|
|
93
|
+
function findParentId(index) {
|
|
94
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
95
|
+
if (isTopLevel(props.tocItems[i])) {
|
|
96
|
+
return props.tocItems[i].id
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 判断某个子标题是否被折叠
|
|
103
|
+
function isSectionCollapsed(index) {
|
|
104
|
+
const parentId = findParentId(index)
|
|
105
|
+
return parentId ? collapsedSections.value.has(parentId) : false
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 切换某个顶级标题的折叠状态
|
|
109
|
+
function toggleSection(id) {
|
|
110
|
+
const next = new Set(collapsedSections.value)
|
|
111
|
+
if (next.has(id)) {
|
|
112
|
+
next.delete(id)
|
|
113
|
+
} else {
|
|
114
|
+
next.add(id)
|
|
115
|
+
}
|
|
116
|
+
collapsedSections.value = next
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 全部展开
|
|
120
|
+
function expandAll() {
|
|
121
|
+
collapsedSections.value = new Set()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 全部收起
|
|
125
|
+
function collapseAll() {
|
|
126
|
+
const ids = new Set()
|
|
127
|
+
props.tocItems.forEach((item, index) => {
|
|
128
|
+
if (isTopLevel(item) && hasChildren(index)) {
|
|
129
|
+
ids.add(item.id)
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
collapsedSections.value = ids
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 更新 marker 滑块位置
|
|
136
|
+
function updateMarker() {
|
|
137
|
+
if (!props.activeHeading || !tocNavRef.value) {
|
|
138
|
+
markerStyle.opacity = 0
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
const el = tocNavRef.value.querySelector(`[data-toc-id="${props.activeHeading}"]`)
|
|
142
|
+
if (!el) {
|
|
143
|
+
markerStyle.opacity = 0
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
const navRect = tocNavRef.value.getBoundingClientRect()
|
|
147
|
+
const elRect = el.getBoundingClientRect()
|
|
148
|
+
markerStyle.top = `${elRect.top - navRect.top + tocNavRef.value.scrollTop}px`
|
|
149
|
+
markerStyle.height = `${elRect.height}px`
|
|
150
|
+
markerStyle.opacity = 1
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 滚动时自动展开当前激活标题所在的折叠分组,并将目录项滚动到可视区域
|
|
154
|
+
watch(() => props.activeHeading, (id) => {
|
|
155
|
+
if (!id) {
|
|
156
|
+
markerStyle.opacity = 0
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
const index = props.tocItems.findIndex(item => item.id === id)
|
|
160
|
+
if (index === -1) return
|
|
161
|
+
// 如果激活的是子标题,找到其父级并展开
|
|
162
|
+
if (!isTopLevel(props.tocItems[index])) {
|
|
163
|
+
const parentId = findParentId(index)
|
|
164
|
+
if (parentId && collapsedSections.value.has(parentId)) {
|
|
165
|
+
const next = new Set(collapsedSections.value)
|
|
166
|
+
next.delete(parentId)
|
|
167
|
+
collapsedSections.value = next
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// 等 DOM 更新后,将激活项滚动到目录可视区域,并更新 marker
|
|
171
|
+
nextTick(() => {
|
|
172
|
+
const el = tocNavRef.value?.querySelector(`[data-toc-id="${id}"]`)
|
|
173
|
+
if (el && tocNavRef.value) {
|
|
174
|
+
// 只在 TOC 导航容器内滚动,避免 scrollIntoView 冒泡影响主内容区导致抖动
|
|
175
|
+
const navRect = tocNavRef.value.getBoundingClientRect()
|
|
176
|
+
const elRect = el.getBoundingClientRect()
|
|
177
|
+
const elCenter = elRect.top + elRect.height / 2
|
|
178
|
+
const navCenter = navRect.top + navRect.height / 2
|
|
179
|
+
const offset = elCenter - navCenter
|
|
180
|
+
tocNavRef.value.scrollBy({ top: offset, behavior: 'smooth' })
|
|
181
|
+
}
|
|
182
|
+
updateMarker()
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// 文档切换时重置折叠状态和 marker
|
|
187
|
+
watch(() => props.tocItems, () => {
|
|
188
|
+
collapsedSections.value = new Set()
|
|
189
|
+
markerStyle.opacity = 0
|
|
190
|
+
})
|
|
49
191
|
</script>
|
|
@@ -51,7 +51,48 @@
|
|
|
51
51
|
</div>
|
|
52
52
|
<!-- 右侧操作 -->
|
|
53
53
|
<div class="top-bar-actions">
|
|
54
|
-
<
|
|
54
|
+
<button
|
|
55
|
+
v-if="!showWelcome"
|
|
56
|
+
class="export-word-btn"
|
|
57
|
+
:disabled="exporting"
|
|
58
|
+
@click="handleExport"
|
|
59
|
+
title="导出为 Word 文档"
|
|
60
|
+
>
|
|
61
|
+
<FileDown :size="14" />
|
|
62
|
+
<span>{{ exporting ? '导出中...' : '导出 Word' }}</span>
|
|
63
|
+
</button>
|
|
64
|
+
<button
|
|
65
|
+
v-if="!showWelcome"
|
|
66
|
+
class="export-word-btn"
|
|
67
|
+
:disabled="copying"
|
|
68
|
+
@click="handleCopyMarkdown"
|
|
69
|
+
title="复制 Markdown 源码"
|
|
70
|
+
>
|
|
71
|
+
<ClipboardCopy :size="14" />
|
|
72
|
+
<span>{{ copying ? '已复制' : '复制 MD' }}</span>
|
|
73
|
+
</button>
|
|
74
|
+
<div class="mode-switch">
|
|
75
|
+
<button
|
|
76
|
+
class="mode-switch-btn"
|
|
77
|
+
:class="{ active: !editMode }"
|
|
78
|
+
@click="emit('toggle-edit', false)"
|
|
79
|
+
title="查看模式"
|
|
80
|
+
>
|
|
81
|
+
<Eye :size="14" />
|
|
82
|
+
<span>查看</span>
|
|
83
|
+
</button>
|
|
84
|
+
<button
|
|
85
|
+
class="mode-switch-btn"
|
|
86
|
+
:class="{ active: editMode, 'mode-edit': editMode }"
|
|
87
|
+
@click="emit('toggle-edit', true)"
|
|
88
|
+
title="编辑模式"
|
|
89
|
+
>
|
|
90
|
+
<Pencil :size="14" />
|
|
91
|
+
<span>编辑</span>
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
<span class="top-bar-divider"></span>
|
|
95
|
+
<a href="https://github.com/xiaoyaodev/md2ui" target="_blank" class="top-bar-github" title="GitHub">
|
|
55
96
|
<Github :size="15" />
|
|
56
97
|
</a>
|
|
57
98
|
</div>
|
|
@@ -60,15 +101,24 @@
|
|
|
60
101
|
|
|
61
102
|
<script setup>
|
|
62
103
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
|
63
|
-
import { Search, Github, FileText } from 'lucide-vue-next'
|
|
104
|
+
import { Search, Github, FileText, Eye, Pencil, FileDown, ClipboardCopy } from 'lucide-vue-next'
|
|
64
105
|
import Logo from './Logo.vue'
|
|
65
106
|
import { useSearch } from '../composables/useSearch.js'
|
|
107
|
+
import { useExportWord } from '../composables/useExportWord.js'
|
|
66
108
|
|
|
67
|
-
defineProps({
|
|
109
|
+
const props = defineProps({
|
|
110
|
+
editMode: { type: Boolean, default: false },
|
|
111
|
+
showWelcome: { type: Boolean, default: true },
|
|
112
|
+
docTitle: { type: String, default: '文档' },
|
|
113
|
+
rawMarkdown: { type: String, default: '' }
|
|
114
|
+
})
|
|
68
115
|
|
|
69
|
-
const emit = defineEmits(['go-home', 'select-search'])
|
|
116
|
+
const emit = defineEmits(['go-home', 'select-search', 'toggle-edit'])
|
|
70
117
|
|
|
71
118
|
const { searchQuery, searchResults, searchReady, indexBuilding, doSearch, openSearch, closeSearch } = useSearch()
|
|
119
|
+
const { exporting, exportToWord } = useExportWord()
|
|
120
|
+
|
|
121
|
+
const copying = ref(false)
|
|
72
122
|
|
|
73
123
|
const inputRef = ref(null)
|
|
74
124
|
const searchWrapperRef = ref(null)
|
|
@@ -126,6 +176,21 @@ function selectResult(item) {
|
|
|
126
176
|
inputRef.value?.blur()
|
|
127
177
|
}
|
|
128
178
|
|
|
179
|
+
function handleExport() {
|
|
180
|
+
exportToWord(props.docTitle)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function handleCopyMarkdown() {
|
|
184
|
+
if (!props.rawMarkdown) return
|
|
185
|
+
try {
|
|
186
|
+
await navigator.clipboard.writeText(props.rawMarkdown)
|
|
187
|
+
copying.value = true
|
|
188
|
+
setTimeout(() => { copying.value = false }, 1500)
|
|
189
|
+
} catch (e) {
|
|
190
|
+
console.error('复制失败:', e)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
129
194
|
// 全局快捷键 Ctrl+K
|
|
130
195
|
function handleGlobalKeydown(e) {
|
|
131
196
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
@@ -1,33 +1,64 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div>
|
|
2
|
+
<div class="tree-node-root">
|
|
3
3
|
<!-- 文件夹节点 -->
|
|
4
4
|
<div
|
|
5
5
|
v-if="item.type === 'folder'"
|
|
6
6
|
class="nav-item nav-folder"
|
|
7
|
-
:class="{ expanded: item.expanded }"
|
|
7
|
+
:class="{ expanded: item.expanded, 'drag-over': isDragOver }"
|
|
8
8
|
:style="{ paddingLeft: `${20 + item.level * 16}px` }"
|
|
9
9
|
@click="$emit('toggle', item)"
|
|
10
|
-
@mouseenter="
|
|
11
|
-
@mouseleave="
|
|
10
|
+
@mouseenter="onMouseEnter($event, item.label)"
|
|
11
|
+
@mouseleave="onMouseLeave"
|
|
12
|
+
@dragover.prevent="onFolderDragOver"
|
|
13
|
+
@dragleave="onFolderDragLeave"
|
|
14
|
+
@drop.prevent="onFolderDrop"
|
|
12
15
|
>
|
|
13
16
|
<ChevronRight v-if="!item.expanded" class="nav-icon chevron-icon" :size="16" />
|
|
14
17
|
<ChevronDown v-else class="nav-icon chevron-icon" :size="16" />
|
|
15
18
|
<Folder v-if="!item.expanded" class="nav-icon folder-icon" :size="16" />
|
|
16
19
|
<FolderOpen v-else class="nav-icon folder-icon" :size="16" />
|
|
17
20
|
<span class="nav-label">{{ item.label }}</span>
|
|
21
|
+
<span class="nav-item-actions" @click.stop>
|
|
22
|
+
<button class="nav-item-btn" @click="$emit('show-create', $event, item.key)" title="新建">
|
|
23
|
+
<Plus :size="12" />
|
|
24
|
+
</button>
|
|
25
|
+
<button class="nav-item-btn" @click="startRename($event)" title="重命名">
|
|
26
|
+
<Pencil :size="12" />
|
|
27
|
+
</button>
|
|
28
|
+
<button class="nav-item-btn nav-item-btn-danger" @click="startDelete($event)" title="删除">
|
|
29
|
+
<Trash2 :size="12" />
|
|
30
|
+
</button>
|
|
31
|
+
</span>
|
|
18
32
|
</div>
|
|
19
33
|
|
|
20
|
-
<!--
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
<!-- 递归渲染子节点(可拖拽排序) -->
|
|
35
|
+
<draggable
|
|
36
|
+
v-if="item.type === 'folder' && item.expanded && item.children"
|
|
37
|
+
:list="item.children"
|
|
38
|
+
:group="{ name: 'doc-tree', pull: true, put: true }"
|
|
39
|
+
item-key="key"
|
|
40
|
+
:animation="200"
|
|
41
|
+
ghost-class="drag-ghost"
|
|
42
|
+
chosen-class="drag-chosen"
|
|
43
|
+
drag-class="drag-active"
|
|
44
|
+
handle=".nav-item"
|
|
45
|
+
:fallback-on-body="true"
|
|
46
|
+
:swap-threshold="0.65"
|
|
47
|
+
@end="onDragEnd"
|
|
48
|
+
>
|
|
49
|
+
<template #item="{ element }">
|
|
50
|
+
<TreeNode
|
|
51
|
+
:item="element"
|
|
52
|
+
:currentDoc="currentDoc"
|
|
53
|
+
@toggle="$emit('toggle', $event)"
|
|
54
|
+
@select="$emit('select', $event)"
|
|
55
|
+
@show-create="(e, k) => $emit('show-create', e, k)"
|
|
56
|
+
@rename="(payload) => $emit('rename', payload)"
|
|
57
|
+
@delete="(payload) => $emit('delete', payload)"
|
|
58
|
+
@drag-end="$emit('drag-end', $event)"
|
|
59
|
+
/>
|
|
60
|
+
</template>
|
|
61
|
+
</draggable>
|
|
31
62
|
|
|
32
63
|
<!-- 文件节点 -->
|
|
33
64
|
<div
|
|
@@ -36,40 +67,200 @@
|
|
|
36
67
|
:class="{ active: currentDoc === item.key }"
|
|
37
68
|
:style="{ paddingLeft: `${20 + item.level * 16}px` }"
|
|
38
69
|
@click="$emit('select', item.key)"
|
|
39
|
-
@mouseenter="
|
|
40
|
-
@mouseleave="
|
|
70
|
+
@mouseenter="onMouseEnter($event, item.label)"
|
|
71
|
+
@mouseleave="onMouseLeave"
|
|
41
72
|
>
|
|
42
73
|
<FileText class="nav-icon file-icon" :size="16" />
|
|
43
74
|
<span class="nav-label">{{ item.label }}</span>
|
|
75
|
+
<span class="nav-item-actions" @click.stop>
|
|
76
|
+
<button class="nav-item-btn" @click="startRename($event)" title="重命名">
|
|
77
|
+
<Pencil :size="12" />
|
|
78
|
+
</button>
|
|
79
|
+
<button class="nav-item-btn nav-item-btn-danger" @click="startDelete($event)" title="删除">
|
|
80
|
+
<Trash2 :size="12" />
|
|
81
|
+
</button>
|
|
82
|
+
</span>
|
|
44
83
|
</div>
|
|
84
|
+
|
|
85
|
+
<!-- 重命名气泡 -->
|
|
86
|
+
<Teleport to="body">
|
|
87
|
+
<div v-if="renameVisible" class="popover-overlay" @click="cancelRename">
|
|
88
|
+
<div class="popover-bubble" :style="popoverStyle" @click.stop>
|
|
89
|
+
<input
|
|
90
|
+
ref="renameInputRef"
|
|
91
|
+
v-model="renameName"
|
|
92
|
+
class="popover-input"
|
|
93
|
+
placeholder="请输入新名称"
|
|
94
|
+
@keydown.enter="confirmRename"
|
|
95
|
+
@keydown.escape="cancelRename"
|
|
96
|
+
/>
|
|
97
|
+
<div v-if="renameError" class="popover-error">{{ renameError }}</div>
|
|
98
|
+
<div class="popover-actions">
|
|
99
|
+
<button class="popover-btn popover-btn-cancel" @click="cancelRename">取消</button>
|
|
100
|
+
<button class="popover-btn popover-btn-confirm" @click="confirmRename">确定</button>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</Teleport>
|
|
105
|
+
|
|
106
|
+
<!-- 删除确认气泡 -->
|
|
107
|
+
<Teleport to="body">
|
|
108
|
+
<div v-if="deleteVisible" class="popover-overlay" @click="deleteVisible = false">
|
|
109
|
+
<div class="popover-bubble" :style="popoverStyle" @click.stop>
|
|
110
|
+
<div class="popover-message">
|
|
111
|
+
确定删除「<strong>{{ item.label }}</strong>」?
|
|
112
|
+
<template v-if="item.type === 'folder'">
|
|
113
|
+
<br /><span class="popover-warning">目录下所有内容将一并删除</span>
|
|
114
|
+
</template>
|
|
115
|
+
</div>
|
|
116
|
+
<div class="popover-actions">
|
|
117
|
+
<button class="popover-btn popover-btn-cancel" @click="deleteVisible = false">取消</button>
|
|
118
|
+
<button class="popover-btn popover-btn-danger" @click="confirmDelete">删除</button>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</Teleport>
|
|
45
123
|
</div>
|
|
46
124
|
</template>
|
|
47
125
|
|
|
48
126
|
<script setup>
|
|
49
|
-
import {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
currentDoc: {
|
|
57
|
-
type: String,
|
|
58
|
-
required: true
|
|
59
|
-
}
|
|
127
|
+
import { ref, nextTick } from 'vue'
|
|
128
|
+
import draggable from 'vuedraggable'
|
|
129
|
+
import { ChevronRight, ChevronDown, Folder, FolderOpen, FileText, Plus, Pencil, Trash2 } from 'lucide-vue-next'
|
|
130
|
+
|
|
131
|
+
const props = defineProps({
|
|
132
|
+
item: { type: Object, required: true },
|
|
133
|
+
currentDoc: { type: String, required: true }
|
|
60
134
|
})
|
|
61
135
|
|
|
62
|
-
defineEmits(['toggle', 'select'])
|
|
136
|
+
const emit = defineEmits(['toggle', 'select', 'show-create', 'rename', 'delete', 'drag-end'])
|
|
137
|
+
|
|
138
|
+
// ===== 拖拽相关 =====
|
|
139
|
+
const isDragOver = ref(false)
|
|
140
|
+
let dragOverTimer = null
|
|
141
|
+
|
|
142
|
+
function onFolderDragOver() {
|
|
143
|
+
isDragOver.value = true
|
|
144
|
+
clearTimeout(dragOverTimer)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function onFolderDragLeave() {
|
|
148
|
+
dragOverTimer = setTimeout(() => { isDragOver.value = false }, 100)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function onFolderDrop() {
|
|
152
|
+
isDragOver.value = false
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function onDragEnd(evt) {
|
|
156
|
+
emit('drag-end', evt)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ===== 气泡定位 =====
|
|
160
|
+
const popoverStyle = ref({})
|
|
161
|
+
const bubbleRef = ref(null)
|
|
162
|
+
|
|
163
|
+
// 记录触发按钮的位置
|
|
164
|
+
let triggerRect = null
|
|
165
|
+
|
|
166
|
+
function calcPopoverPos(event) {
|
|
167
|
+
const btn = event.currentTarget
|
|
168
|
+
triggerRect = btn.getBoundingClientRect()
|
|
169
|
+
// 先设置一个不可见的初始位置,等渲染后再修正
|
|
170
|
+
popoverStyle.value = {
|
|
171
|
+
position: 'fixed',
|
|
172
|
+
left: `${Math.max(8, triggerRect.left - 80)}px`,
|
|
173
|
+
top: '0px',
|
|
174
|
+
zIndex: 9999,
|
|
175
|
+
visibility: 'hidden'
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function adjustPopoverPos() {
|
|
180
|
+
nextTick(() => {
|
|
181
|
+
const bubble = document.querySelector('.popover-bubble')
|
|
182
|
+
if (!bubble || !triggerRect) return
|
|
183
|
+
const bRect = bubble.getBoundingClientRect()
|
|
184
|
+
let left = Math.max(8, triggerRect.left - 80)
|
|
185
|
+
// 默认向上弹出
|
|
186
|
+
let top = triggerRect.top - bRect.height - 6
|
|
187
|
+
// 上方空间不够,改为向下
|
|
188
|
+
if (top < 10) {
|
|
189
|
+
top = triggerRect.bottom + 6
|
|
190
|
+
}
|
|
191
|
+
// 向下弹出后底部仍然溢出,贴底显示
|
|
192
|
+
if (top + bRect.height > window.innerHeight - 10) {
|
|
193
|
+
top = window.innerHeight - bRect.height - 10
|
|
194
|
+
}
|
|
195
|
+
// 右侧溢出修正
|
|
196
|
+
if (left + bRect.width > window.innerWidth - 10) {
|
|
197
|
+
left = window.innerWidth - bRect.width - 10
|
|
198
|
+
}
|
|
199
|
+
popoverStyle.value = {
|
|
200
|
+
position: 'fixed',
|
|
201
|
+
left: `${left}px`,
|
|
202
|
+
top: `${top}px`,
|
|
203
|
+
zIndex: 9999,
|
|
204
|
+
visibility: 'visible'
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ===== 重命名 =====
|
|
210
|
+
const renameVisible = ref(false)
|
|
211
|
+
const renameInputRef = ref(null)
|
|
212
|
+
const renameName = ref('')
|
|
213
|
+
const renameError = ref('')
|
|
63
214
|
|
|
215
|
+
function startRename(event) {
|
|
216
|
+
deleteVisible.value = false
|
|
217
|
+
renameName.value = props.item.label
|
|
218
|
+
renameError.value = ''
|
|
219
|
+
calcPopoverPos(event)
|
|
220
|
+
renameVisible.value = true
|
|
221
|
+
nextTick(() => {
|
|
222
|
+
adjustPopoverPos()
|
|
223
|
+
renameInputRef.value?.focus()
|
|
224
|
+
renameInputRef.value?.select()
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function confirmRename() {
|
|
229
|
+
const newName = renameName.value.trim()
|
|
230
|
+
if (!newName) { renameError.value = '名称不能为空'; return }
|
|
231
|
+
if (/[\\/:*?"<>|]/.test(newName)) { renameError.value = '名称包含非法字符'; return }
|
|
232
|
+
if (newName === props.item.label) { renameVisible.value = false; return }
|
|
233
|
+
renameError.value = ''
|
|
234
|
+
emit('rename', { item: props.item, newName })
|
|
235
|
+
renameVisible.value = false
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function cancelRename() {
|
|
239
|
+
renameVisible.value = false
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ===== 删除 =====
|
|
243
|
+
const deleteVisible = ref(false)
|
|
244
|
+
|
|
245
|
+
function startDelete(event) {
|
|
246
|
+
renameVisible.value = false
|
|
247
|
+
calcPopoverPos(event)
|
|
248
|
+
deleteVisible.value = true
|
|
249
|
+
adjustPopoverPos()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function confirmDelete() {
|
|
253
|
+
emit('delete', props.item)
|
|
254
|
+
deleteVisible.value = false
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ===== Tooltip =====
|
|
64
258
|
let tooltipEl = null
|
|
65
259
|
|
|
66
|
-
function
|
|
260
|
+
function onMouseEnter(event, text) {
|
|
67
261
|
hideTooltip()
|
|
68
|
-
|
|
69
262
|
const target = event.currentTarget
|
|
70
263
|
const label = target.querySelector('.nav-label')
|
|
71
|
-
|
|
72
|
-
// 只有文字被截断时才显示 tooltip
|
|
73
264
|
if (label && label.scrollWidth <= label.clientWidth) return
|
|
74
265
|
|
|
75
266
|
tooltipEl = document.createElement('div')
|
|
@@ -89,18 +280,22 @@ function showTooltip(event, text) {
|
|
|
89
280
|
cursor: text;
|
|
90
281
|
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
91
282
|
`
|
|
92
|
-
|
|
93
283
|
document.body.appendChild(tooltipEl)
|
|
94
|
-
|
|
95
284
|
const rect = target.getBoundingClientRect()
|
|
96
285
|
tooltipEl.style.left = `${rect.right + 8}px`
|
|
97
286
|
tooltipEl.style.top = `${rect.top}px`
|
|
98
|
-
|
|
99
|
-
// 防止超出屏幕右侧
|
|
100
287
|
const tooltipRect = tooltipEl.getBoundingClientRect()
|
|
101
288
|
if (tooltipRect.right > window.innerWidth - 10) {
|
|
102
289
|
tooltipEl.style.left = `${rect.left - tooltipRect.width - 8}px`
|
|
103
290
|
}
|
|
291
|
+
// 底部溢出修正
|
|
292
|
+
if (tooltipRect.bottom > window.innerHeight - 10) {
|
|
293
|
+
tooltipEl.style.top = `${window.innerHeight - tooltipRect.height - 10}px`
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function onMouseLeave() {
|
|
298
|
+
hideTooltip()
|
|
104
299
|
}
|
|
105
300
|
|
|
106
301
|
function hideTooltip() {
|
|
@@ -110,5 +305,3 @@ function hideTooltip() {
|
|
|
110
305
|
}
|
|
111
306
|
}
|
|
112
307
|
</script>
|
|
113
|
-
|
|
114
|
-
|
|
@@ -23,11 +23,11 @@
|
|
|
23
23
|
</button>
|
|
24
24
|
</div>
|
|
25
25
|
<!-- GitHub -->
|
|
26
|
-
<a class="welcome-github" href="https://github.com/
|
|
26
|
+
<a class="welcome-github" href="https://github.com/xiaoyaodev/md2ui" target="_blank">
|
|
27
27
|
<GitHubIcon :size="16" />
|
|
28
28
|
<span>GitHub</span>
|
|
29
29
|
<span class="welcome-github-sep"></span>
|
|
30
|
-
<span class="welcome-github-repo">
|
|
30
|
+
<span class="welcome-github-repo">xiaoyaodev/md2ui</span>
|
|
31
31
|
<ExternalLink :size="12" class="welcome-github-arrow" />
|
|
32
32
|
</a>
|
|
33
33
|
</div>
|
|
@@ -33,9 +33,17 @@ function toBase62(h1, h2) {
|
|
|
33
33
|
return result.padStart(8, '0').slice(0, 8)
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// 去掉路径中每一段的序号前缀(如 "01-快速开始" → "快速开始")
|
|
37
|
+
// 保证重编号不影响外链地址
|
|
38
|
+
export function stripOrderPrefix(key) {
|
|
39
|
+
return key.split('/').map(seg => seg.replace(/^\d+-/, '')).join('/')
|
|
40
|
+
}
|
|
41
|
+
|
|
36
42
|
// 根据文档 key 生成 8 位 base62 短 hash(同步、确定性、零依赖)
|
|
43
|
+
// 哈希计算前会去掉序号前缀,使得重编号不影响外链
|
|
37
44
|
export function docHash(key) {
|
|
38
|
-
const
|
|
45
|
+
const stripped = stripOrderPrefix(key)
|
|
46
|
+
const [h1, h2] = fnv1a64(stripped)
|
|
39
47
|
return toBase62(h1, h2)
|
|
40
48
|
}
|
|
41
49
|
|