md2ui 1.0.2 → 1.0.7
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 +5 -3
- package/bin/md2ui.js +6 -3
- package/package.json +1 -1
- package/src/App.vue +137 -22
- package/src/api/docs.js +3 -1
- package/src/components/ImageZoom.vue +4 -2
- package/src/components/Logo.vue +3 -3
- package/src/components/TableOfContents.vue +9 -4
- package/src/components/TreeNode.vue +55 -2
- package/src/config.js +8 -0
- package/src/style.css +230 -410
- package/vite.config.js +2 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# md2ui
|
|
2
2
|
|
|
3
|
+
[](https://github.com/devsneed/md2ui)
|
|
4
|
+
|
|
3
5
|
一个轻量级的文档渲染系统,将本地 Markdown 文档转换为美观的 HTML 页面。
|
|
4
6
|
|
|
5
7
|
## 核心特性
|
|
@@ -63,7 +65,7 @@ your-docs/
|
|
|
63
65
|
|
|
64
66
|
```bash
|
|
65
67
|
# 克隆项目
|
|
66
|
-
git clone https://github.com/
|
|
68
|
+
git clone https://github.com/devsneed/md2ui.git
|
|
67
69
|
cd md2ui
|
|
68
70
|
|
|
69
71
|
# 安装依赖
|
|
@@ -81,8 +83,8 @@ pnpm dev
|
|
|
81
83
|
### 本地测试 CLI
|
|
82
84
|
|
|
83
85
|
```bash
|
|
84
|
-
#
|
|
85
|
-
|
|
86
|
+
# 安装到全局
|
|
87
|
+
npm install -g .
|
|
86
88
|
|
|
87
89
|
# 在任意目录测试
|
|
88
90
|
cd /path/to/test/docs
|
package/bin/md2ui.js
CHANGED
|
@@ -10,16 +10,19 @@ const __filename = fileURLToPath(import.meta.url)
|
|
|
10
10
|
const __dirname = dirname(__filename)
|
|
11
11
|
const pkgRoot = resolve(__dirname, '..')
|
|
12
12
|
|
|
13
|
+
// 导入共享配置
|
|
14
|
+
const { config } = await import(resolve(pkgRoot, 'src/config.js'))
|
|
15
|
+
|
|
13
16
|
// 用户执行命令的目录
|
|
14
17
|
const userDir = process.cwd()
|
|
15
18
|
|
|
16
19
|
// 解析命令行参数
|
|
17
20
|
const args = process.argv.slice(2)
|
|
18
|
-
let port =
|
|
21
|
+
let port = config.defaultPort
|
|
19
22
|
|
|
20
23
|
for (let i = 0; i < args.length; i++) {
|
|
21
24
|
if (args[i] === '-p' || args[i] === '--port') {
|
|
22
|
-
port = parseInt(args[i + 1]) ||
|
|
25
|
+
port = parseInt(args[i + 1]) || config.defaultPort
|
|
23
26
|
}
|
|
24
27
|
}
|
|
25
28
|
|
|
@@ -70,7 +73,7 @@ function scanDocs(dir, basePath = '', level = 0) {
|
|
|
70
73
|
order: match ? parseInt(match[1]) : 999,
|
|
71
74
|
type: 'folder',
|
|
72
75
|
level,
|
|
73
|
-
expanded:
|
|
76
|
+
expanded: config.folderExpanded,
|
|
74
77
|
children
|
|
75
78
|
})
|
|
76
79
|
}
|
package/package.json
CHANGED
package/src/App.vue
CHANGED
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
<div class="container">
|
|
3
3
|
<aside v-if="!sidebarCollapsed" class="sidebar" :style="{ width: sidebarWidth + 'px' }">
|
|
4
4
|
<div class="logo">
|
|
5
|
-
<
|
|
5
|
+
<div class="logo-group">
|
|
6
|
+
<Logo @go-home="loadFirstDoc" />
|
|
7
|
+
<a href="https://github.com/devsneed/md2ui" target="_blank" class="github-link" title="GitHub">
|
|
8
|
+
<Github :size="14" />
|
|
9
|
+
</a>
|
|
10
|
+
</div>
|
|
11
|
+
<button class="sidebar-toggle" @click="sidebarCollapsed = true" title="收起导航">
|
|
12
|
+
<PanelLeftClose :size="16" />
|
|
13
|
+
</button>
|
|
6
14
|
</div>
|
|
7
15
|
<nav class="nav-menu">
|
|
8
16
|
<div class="nav-section">
|
|
@@ -30,20 +38,14 @@
|
|
|
30
38
|
<button v-if="sidebarCollapsed" class="expand-btn expand-btn-left" @click="sidebarCollapsed = false" title="展开导航">
|
|
31
39
|
<PanelLeftOpen :size="16" />
|
|
32
40
|
</button>
|
|
33
|
-
<button v-else class="collapse-btn collapse-btn-left" @click="sidebarCollapsed = true" title="收起导航">
|
|
34
|
-
<PanelLeftClose :size="16" />
|
|
35
|
-
</button>
|
|
36
41
|
<main class="content" @scroll="handleScroll" @click="handleContentClick">
|
|
37
42
|
<article class="markdown-content" v-html="htmlContent"></article>
|
|
38
43
|
</main>
|
|
39
44
|
<div v-if="!tocCollapsed && tocItems.length > 0" class="resizer resizer-right" @mousedown="startResize('right', $event)"></div>
|
|
40
|
-
<TableOfContents :tocItems="tocItems" :activeHeading="activeHeading" :collapsed="tocCollapsed" :width="tocWidth" @scroll-to="scrollToHeading" />
|
|
45
|
+
<TableOfContents :tocItems="tocItems" :activeHeading="activeHeading" :collapsed="tocCollapsed" :width="tocWidth" @toggle="tocCollapsed = !tocCollapsed" @scroll-to="scrollToHeading" />
|
|
41
46
|
<button v-if="tocCollapsed && tocItems.length > 0" class="expand-btn expand-btn-right" @click="tocCollapsed = false" title="展开目录">
|
|
42
47
|
<PanelRightOpen :size="16" />
|
|
43
48
|
</button>
|
|
44
|
-
<button v-else-if="tocItems.length > 0" class="collapse-btn collapse-btn-right" @click="tocCollapsed = true" title="收起目录">
|
|
45
|
-
<PanelRightClose :size="16" />
|
|
46
|
-
</button>
|
|
47
49
|
<transition name="fade">
|
|
48
50
|
<button v-if="showBackToTop" class="back-to-top" @click="scrollToTop" title="返回顶部">
|
|
49
51
|
<ArrowUp :size="20" />
|
|
@@ -55,8 +57,9 @@
|
|
|
55
57
|
</template>
|
|
56
58
|
|
|
57
59
|
<script setup>
|
|
58
|
-
import { ref, onMounted } from 'vue'
|
|
59
|
-
import { ChevronsDownUp, ChevronsUpDown, ArrowUp, PanelLeftOpen, PanelLeftClose, PanelRightOpen,
|
|
60
|
+
import { ref, onMounted, nextTick } from 'vue'
|
|
61
|
+
import { ChevronsDownUp, ChevronsUpDown, ArrowUp, PanelLeftOpen, PanelLeftClose, PanelRightOpen, Github } from 'lucide-vue-next'
|
|
62
|
+
import MD5 from 'crypto-js/md5'
|
|
60
63
|
import Logo from './components/Logo.vue'
|
|
61
64
|
import TreeNode from './components/TreeNode.vue'
|
|
62
65
|
import TableOfContents from './components/TableOfContents.vue'
|
|
@@ -74,28 +77,53 @@ const zoomVisible = ref(false)
|
|
|
74
77
|
const zoomContent = ref('')
|
|
75
78
|
|
|
76
79
|
const { htmlContent, tocItems, renderMarkdown } = useMarkdown()
|
|
77
|
-
const { scrollProgress, showBackToTop, activeHeading, handleScroll, scrollToHeading, scrollToTop } = useScroll()
|
|
80
|
+
const { scrollProgress, showBackToTop, activeHeading, handleScroll: _handleScroll, scrollToHeading: _scrollToHeading, scrollToTop } = useScroll()
|
|
81
|
+
|
|
82
|
+
// 包装滚动处理,同步锚点到 URL
|
|
83
|
+
function handleScroll(e) {
|
|
84
|
+
_handleScroll(e)
|
|
85
|
+
// 滚动时更新 URL 锚点
|
|
86
|
+
if (activeHeading.value) {
|
|
87
|
+
updateHash(activeHeading.value)
|
|
88
|
+
} else {
|
|
89
|
+
updateHash('')
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 包装目录点击,同步锚点到 URL
|
|
94
|
+
function scrollToHeading(id) {
|
|
95
|
+
_scrollToHeading(id)
|
|
96
|
+
updateHash(id)
|
|
97
|
+
}
|
|
78
98
|
const { sidebarWidth, tocWidth, startResize } = useResize()
|
|
79
99
|
|
|
80
100
|
async function loadDocsList() {
|
|
81
101
|
docsList.value = await getDocsList()
|
|
82
102
|
}
|
|
83
103
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const content = await response.text()
|
|
90
|
-
await renderMarkdown(content)
|
|
91
|
-
}
|
|
92
|
-
} catch (error) {
|
|
93
|
-
console.error('加载首页失败:', error)
|
|
104
|
+
// 加载第一个文档
|
|
105
|
+
function loadFirstDoc() {
|
|
106
|
+
const firstDoc = findFirstDoc(docsList.value)
|
|
107
|
+
if (firstDoc) {
|
|
108
|
+
loadDoc(firstDoc.key)
|
|
94
109
|
}
|
|
95
110
|
}
|
|
96
111
|
|
|
112
|
+
// 根据 key 生成 hash
|
|
113
|
+
function docHash(key) {
|
|
114
|
+
return MD5(key).toString()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 更新 URL hash(文档hash + 可选锚点)
|
|
118
|
+
function updateHash(anchor) {
|
|
119
|
+
if (!currentDoc.value) return
|
|
120
|
+
const base = docHash(currentDoc.value)
|
|
121
|
+
window.location.hash = anchor ? `${base}/${anchor}` : base
|
|
122
|
+
}
|
|
123
|
+
|
|
97
124
|
async function loadDoc(key) {
|
|
98
125
|
currentDoc.value = key
|
|
126
|
+
updateHash('')
|
|
99
127
|
const doc = findDoc(docsList.value, key)
|
|
100
128
|
if (!doc) return
|
|
101
129
|
try {
|
|
@@ -164,8 +192,95 @@ function handleContentClick(event) {
|
|
|
164
192
|
}
|
|
165
193
|
}
|
|
166
194
|
|
|
195
|
+
// 查找第一个文档
|
|
196
|
+
function findFirstDoc(items) {
|
|
197
|
+
for (const item of items) {
|
|
198
|
+
if (item.type === 'file') return item
|
|
199
|
+
if (item.type === 'folder' && item.children) {
|
|
200
|
+
const found = findFirstDoc(item.children)
|
|
201
|
+
if (found) return found
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 显示无文档提示
|
|
208
|
+
function showEmptyMessage() {
|
|
209
|
+
renderMarkdown(`# 当前目录没有 Markdown 文档
|
|
210
|
+
|
|
211
|
+
请在当前目录下添加 \`.md\` 文件,然后刷新页面。
|
|
212
|
+
|
|
213
|
+
## 文档组织示例
|
|
214
|
+
|
|
215
|
+
\`\`\`
|
|
216
|
+
your-docs/
|
|
217
|
+
├── 00-快速开始.md
|
|
218
|
+
├── 01-功能特性.md
|
|
219
|
+
└── 02-进阶指南/
|
|
220
|
+
├── 01-目录结构.md
|
|
221
|
+
└── 02-自定义配置.md
|
|
222
|
+
\`\`\`
|
|
223
|
+
`)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 根据 hash 查找文档
|
|
227
|
+
function findDocByHash(items, hash) {
|
|
228
|
+
for (const item of items) {
|
|
229
|
+
if (item.type === 'file' && docHash(item.key) === hash) return item
|
|
230
|
+
if (item.type === 'folder' && item.children) {
|
|
231
|
+
const found = findDocByHash(item.children, hash)
|
|
232
|
+
if (found) return found
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 展开文档所在的所有父级文件夹
|
|
239
|
+
function expandParents(items, targetKey) {
|
|
240
|
+
for (const item of items) {
|
|
241
|
+
if (item.type === 'file' && item.key === targetKey) return true
|
|
242
|
+
if (item.type === 'folder' && item.children) {
|
|
243
|
+
if (expandParents(item.children, targetKey)) {
|
|
244
|
+
item.expanded = true
|
|
245
|
+
return true
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return false
|
|
250
|
+
}
|
|
251
|
+
|
|
167
252
|
onMounted(async () => {
|
|
168
253
|
await loadDocsList()
|
|
169
|
-
|
|
254
|
+
|
|
255
|
+
const rawHash = window.location.hash.replace('#', '')
|
|
256
|
+
const [hash, anchor] = rawHash.includes('/')
|
|
257
|
+
? [rawHash.split('/')[0], rawHash.split('/').slice(1).join('/')]
|
|
258
|
+
: [rawHash, '']
|
|
259
|
+
|
|
260
|
+
if (hash) {
|
|
261
|
+
const doc = findDocByHash(docsList.value, hash)
|
|
262
|
+
if (doc) {
|
|
263
|
+
expandParents(docsList.value, doc.key)
|
|
264
|
+
await loadDoc(doc.key)
|
|
265
|
+
// 恢复锚点位置
|
|
266
|
+
if (anchor) {
|
|
267
|
+
await nextTick()
|
|
268
|
+
// 等待 DOM 渲染完成后再滚动
|
|
269
|
+
setTimeout(() => {
|
|
270
|
+
_scrollToHeading(anchor)
|
|
271
|
+
updateHash(anchor)
|
|
272
|
+
}, 100)
|
|
273
|
+
}
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 没有 hash 或找不到对应文档,加载第一个
|
|
279
|
+
const firstDoc = findFirstDoc(docsList.value)
|
|
280
|
+
if (firstDoc) {
|
|
281
|
+
await loadDoc(firstDoc.key)
|
|
282
|
+
} else {
|
|
283
|
+
showEmptyMessage()
|
|
284
|
+
}
|
|
170
285
|
})
|
|
171
286
|
</script>
|
package/src/api/docs.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { config } from '../config.js'
|
|
2
|
+
|
|
1
3
|
// 构建目录树结构(支持多层嵌套)
|
|
2
4
|
function buildTree(files) {
|
|
3
5
|
const root = { children: [] }
|
|
@@ -23,7 +25,7 @@ function buildTree(files) {
|
|
|
23
25
|
order: match ? parseInt(match[1]) : 999,
|
|
24
26
|
level: i,
|
|
25
27
|
children: [],
|
|
26
|
-
expanded:
|
|
28
|
+
expanded: config.folderExpanded
|
|
27
29
|
}
|
|
28
30
|
currentLevel.children.push(folder)
|
|
29
31
|
}
|
|
@@ -112,8 +112,10 @@ function handleOverlayClick(event) {
|
|
|
112
112
|
function handleWheel(event) {
|
|
113
113
|
event.preventDefault()
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
const
|
|
115
|
+
// 使用比例缩放,每次滚轮缩放 2%,体感更平滑
|
|
116
|
+
const zoomFactor = 0.02
|
|
117
|
+
const direction = event.deltaY > 0 ? -1 : 1
|
|
118
|
+
const newScale = Math.max(minScale, Math.min(maxScale, scale.value * (1 + direction * zoomFactor)))
|
|
117
119
|
|
|
118
120
|
if (newScale !== scale.value) {
|
|
119
121
|
scale.value = newScale
|
package/src/components/Logo.vue
CHANGED
|
@@ -21,15 +21,15 @@ defineEmits(['go-home'])
|
|
|
21
21
|
gap: 10px;
|
|
22
22
|
color: #111827;
|
|
23
23
|
cursor: pointer;
|
|
24
|
-
transition:
|
|
24
|
+
transition: all 0.15s;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
.logo-container:hover {
|
|
28
|
-
|
|
28
|
+
color: #3eaf7c;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
.logo-container:active {
|
|
32
|
-
|
|
32
|
+
transform: scale(0.96);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
.logo-icon {
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<aside class="toc-sidebar" v-if="tocItems.length > 0 && !collapsed" :style="{ width: width + 'px' }">
|
|
3
3
|
<div class="toc-header">
|
|
4
|
-
<
|
|
5
|
-
|
|
4
|
+
<div class="toc-title">
|
|
5
|
+
<List :size="16" />
|
|
6
|
+
<span>目录</span>
|
|
7
|
+
</div>
|
|
8
|
+
<button class="toc-toggle" @click="$emit('toggle')" title="收起目录">
|
|
9
|
+
<PanelRightClose :size="16" />
|
|
10
|
+
</button>
|
|
6
11
|
</div>
|
|
7
12
|
<nav class="toc-nav">
|
|
8
13
|
<a
|
|
@@ -19,7 +24,7 @@
|
|
|
19
24
|
</template>
|
|
20
25
|
|
|
21
26
|
<script setup>
|
|
22
|
-
import { List } from 'lucide-vue-next'
|
|
27
|
+
import { List, PanelRightClose } from 'lucide-vue-next'
|
|
23
28
|
|
|
24
29
|
defineProps({
|
|
25
30
|
tocItems: {
|
|
@@ -40,5 +45,5 @@ defineProps({
|
|
|
40
45
|
}
|
|
41
46
|
})
|
|
42
47
|
|
|
43
|
-
defineEmits(['scroll-to'])
|
|
48
|
+
defineEmits(['scroll-to', 'toggle'])
|
|
44
49
|
</script>
|
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
:class="{ expanded: item.expanded }"
|
|
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
12
|
>
|
|
11
13
|
<ChevronRight v-if="!item.expanded" class="nav-icon chevron-icon" :size="16" />
|
|
12
14
|
<ChevronDown v-else class="nav-icon chevron-icon" :size="16" />
|
|
13
15
|
<Folder v-if="!item.expanded" class="nav-icon folder-icon" :size="16" />
|
|
14
16
|
<FolderOpen v-else class="nav-icon folder-icon" :size="16" />
|
|
15
|
-
<span>{{ item.label }}</span>
|
|
17
|
+
<span class="nav-label">{{ item.label }}</span>
|
|
16
18
|
</div>
|
|
17
19
|
|
|
18
20
|
<!-- 递归渲染子节点 -->
|
|
@@ -34,9 +36,11 @@
|
|
|
34
36
|
:class="{ active: currentDoc === item.key }"
|
|
35
37
|
:style="{ paddingLeft: `${20 + item.level * 16}px` }"
|
|
36
38
|
@click="$emit('select', item.key)"
|
|
39
|
+
@mouseenter="showTooltip($event, item.label)"
|
|
40
|
+
@mouseleave="hideTooltip"
|
|
37
41
|
>
|
|
38
42
|
<FileText class="nav-icon file-icon" :size="16" />
|
|
39
|
-
<span>{{ item.label }}</span>
|
|
43
|
+
<span class="nav-label">{{ item.label }}</span>
|
|
40
44
|
</div>
|
|
41
45
|
</div>
|
|
42
46
|
</template>
|
|
@@ -56,6 +60,55 @@ defineProps({
|
|
|
56
60
|
})
|
|
57
61
|
|
|
58
62
|
defineEmits(['toggle', 'select'])
|
|
63
|
+
|
|
64
|
+
let tooltipEl = null
|
|
65
|
+
|
|
66
|
+
function showTooltip(event, text) {
|
|
67
|
+
hideTooltip()
|
|
68
|
+
|
|
69
|
+
const target = event.currentTarget
|
|
70
|
+
const label = target.querySelector('.nav-label')
|
|
71
|
+
|
|
72
|
+
// 只有文字被截断时才显示 tooltip
|
|
73
|
+
if (label && label.scrollWidth <= label.clientWidth) return
|
|
74
|
+
|
|
75
|
+
tooltipEl = document.createElement('div')
|
|
76
|
+
tooltipEl.className = 'nav-tooltip'
|
|
77
|
+
tooltipEl.textContent = text
|
|
78
|
+
tooltipEl.style.cssText = `
|
|
79
|
+
position: fixed;
|
|
80
|
+
background: #1f2328;
|
|
81
|
+
color: #fff;
|
|
82
|
+
padding: 6px 10px;
|
|
83
|
+
border-radius: 6px;
|
|
84
|
+
font-size: 12px;
|
|
85
|
+
z-index: 9999;
|
|
86
|
+
max-width: 300px;
|
|
87
|
+
word-break: break-all;
|
|
88
|
+
user-select: text;
|
|
89
|
+
cursor: text;
|
|
90
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
91
|
+
`
|
|
92
|
+
|
|
93
|
+
document.body.appendChild(tooltipEl)
|
|
94
|
+
|
|
95
|
+
const rect = target.getBoundingClientRect()
|
|
96
|
+
tooltipEl.style.left = `${rect.right + 8}px`
|
|
97
|
+
tooltipEl.style.top = `${rect.top}px`
|
|
98
|
+
|
|
99
|
+
// 防止超出屏幕右侧
|
|
100
|
+
const tooltipRect = tooltipEl.getBoundingClientRect()
|
|
101
|
+
if (tooltipRect.right > window.innerWidth - 10) {
|
|
102
|
+
tooltipEl.style.left = `${rect.left - tooltipRect.width - 8}px`
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function hideTooltip() {
|
|
107
|
+
if (tooltipEl) {
|
|
108
|
+
tooltipEl.remove()
|
|
109
|
+
tooltipEl = null
|
|
110
|
+
}
|
|
111
|
+
}
|
|
59
112
|
</script>
|
|
60
113
|
|
|
61
114
|
|