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.
Files changed (80) hide show
  1. package/README.md +51 -58
  2. package/bin/build.js +95 -9
  3. package/bin/md2ui.js +102 -13
  4. package/package.json +24 -10
  5. package/public/docs/00-/345/277/253/351/200/237/345/274/200/345/247/213.md +48 -28
  6. package/public/docs/01-/345/212/237/350/203/275/347/211/271/346/200/247.md +55 -40
  7. 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
  8. package/public/docs/02-Markdown/346/270/262/346/237/223/01-/344/273/243/347/240/201/345/235/227.md +91 -0
  9. package/public/docs/02-Markdown/346/270/262/346/237/223/02-/350/241/250/346/240/274.md +187 -0
  10. package/public/docs/02-Markdown/346/270/262/346/237/223/03-Mermaid/345/233/276/350/241/250.md +101 -0
  11. package/public/docs/02-Markdown/346/270/262/346/237/223/04-Frontmatter.md +32 -0
  12. 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
  13. 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
  14. package/public/docs/02-Markdown/346/270/262/346/237/223/assets/img-1777383093712.png +0 -0
  15. 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
  16. 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
  17. 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
  18. 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
  19. 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
  20. 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
  21. 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
  22. 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
  23. 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
  24. 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
  25. 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
  26. 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
  27. package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/assets/img-1777261394722.png +0 -0
  28. 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
  29. 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
  30. 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
  31. 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
  32. 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
  33. 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
  34. 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
  35. 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
  36. 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
  37. package/src/App.vue +111 -12
  38. package/src/components/AppSidebar.vue +181 -21
  39. package/src/components/CodeBlockNodeView.vue +72 -0
  40. package/src/components/DocContent.vue +25 -14
  41. package/src/components/EditorContent.vue +257 -0
  42. package/src/components/EditorToolbar.vue +264 -0
  43. package/src/components/ImageZoom.vue +88 -5
  44. package/src/components/MathBlockNodeView.vue +160 -0
  45. package/src/components/MathInlineNodeView.vue +145 -0
  46. package/src/components/MermaidNodeView.vue +157 -0
  47. package/src/components/MobileSearch.vue +97 -0
  48. package/src/components/TableOfContents.vue +174 -32
  49. package/src/components/TopBar.vue +69 -4
  50. package/src/components/TreeNode.vue +232 -39
  51. package/src/components/WelcomePage.vue +2 -2
  52. package/src/composables/useDocHash.js +9 -1
  53. package/src/composables/useDocManager.js +452 -105
  54. package/src/composables/useDocTree.js +33 -2
  55. package/src/composables/useExportWord.js +73 -10
  56. package/src/composables/useFileWatcher.js +45 -0
  57. package/src/composables/useFrontmatter.js +2 -2
  58. package/src/composables/useMarkdown.js +450 -52
  59. package/src/composables/useMermaidCache.js +15 -0
  60. package/src/composables/useScroll.js +354 -27
  61. package/src/composables/useSearch.js +12 -11
  62. package/src/config.js +1 -4
  63. package/src/extensions/CodeBlockCustom.js +113 -0
  64. package/src/extensions/MathBlock.js +107 -0
  65. package/src/extensions/MathInline.js +100 -0
  66. package/src/extensions/MermaidBlock.js +73 -0
  67. package/src/extensions/TableControls.js +670 -0
  68. package/src/services/DocService.js +168 -0
  69. package/src/style.css +2416 -36
  70. package/src/utils/imageConverter.js +129 -0
  71. package/vite-plugin-doc-api.js +369 -0
  72. package/vite.config.js +7 -2
  73. package/public/docs/02-Mermaid/345/233/276/350/241/250.md +0 -102
  74. 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
  75. 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
  76. 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
  77. package/public/docs/04-API/345/217/202/350/200/203/01-/347/273/204/344/273/266API.md +0 -80
  78. package/public/docs/04-API/345/217/202/350/200/203/02-Composables.md +0 -92
  79. package/src/api/docs.js +0 -106
  80. package/src/components/SearchPanel.vue +0 -90
@@ -5,45 +5,187 @@
5
5
  <List :size="16" />
6
6
  <span>目录</span>
7
7
  </div>
8
- <button class="toc-toggle" @click="$emit('toggle')" title="收起目录">
9
- <ChevronRight :size="14" />
10
- </button>
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
- <a
14
- v-for="item in tocItems"
15
- :key="item.id"
16
- :href="`#${item.id}`"
17
- :class="['toc-item', `toc-level-${item.level}`, { active: activeHeading === item.id }]"
18
- @click.prevent="$emit('scroll-to', item.id)"
19
- >
20
- {{ item.text }}
21
- </a>
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 { List, ChevronRight } from 'lucide-vue-next'
28
-
29
- defineProps({
30
- tocItems: {
31
- type: Array,
32
- default: () => []
33
- },
34
- activeHeading: {
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
- <a href="https://github.com/devsneed/md2ui" target="_blank" class="top-bar-btn" title="GitHub">
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="showTooltip($event, item.label)"
11
- @mouseleave="hideTooltip"
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
- <template v-if="item.type === 'folder' && item.expanded && item.children">
22
- <TreeNode
23
- v-for="child in item.children"
24
- :key="child.key"
25
- :item="child"
26
- :currentDoc="currentDoc"
27
- @toggle="$emit('toggle', $event)"
28
- @select="$emit('select', $event)"
29
- />
30
- </template>
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="showTooltip($event, item.label)"
40
- @mouseleave="hideTooltip"
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 { ChevronRight, ChevronDown, Folder, FolderOpen, FileText } from 'lucide-vue-next'
50
-
51
- defineProps({
52
- item: {
53
- type: Object,
54
- required: true
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 showTooltip(event, text) {
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/devsneed/md2ui" target="_blank">
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">devsneed/md2ui</span>
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 [h1, h2] = fnv1a64(key)
45
+ const stripped = stripOrderPrefix(key)
46
+ const [h1, h2] = fnv1a64(stripped)
39
47
  return toBase62(h1, h2)
40
48
  }
41
49