md2ui 1.0.18 → 1.0.19
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 +3 -55
- package/bin/build.js +82 -7
- package/bin/md2ui.js +80 -4
- package/package.json +23 -9
- 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 +86 -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/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/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 +130 -6
- 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 +199 -2
- package/src/components/MathBlockNodeView.vue +160 -0
- package/src/components/MathInlineNodeView.vue +145 -0
- package/src/components/MermaidNodeView.vue +149 -0
- package/src/components/TableBubbleMenu.vue +177 -0
- package/src/components/TableOfContents.vue +138 -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 +325 -68
- package/src/composables/useDocTree.js +56 -1
- package/src/composables/useExportPdf.js +102 -0
- 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 +529 -42
- package/src/composables/useScroll.js +47 -5
- package/src/config.js +1 -1
- 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 +184 -0
- package/src/style.css +2194 -39
- package/vite-plugin-doc-api.js +368 -0
- package/vite.config.js +2 -1
- 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
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# 自定义配置
|
|
2
|
+
|
|
3
|
+
验证用户自定义配置的加载和生效。
|
|
4
|
+
|
|
5
|
+
## 配置文件
|
|
6
|
+
|
|
7
|
+
在文档目录下创建 `md2ui.config.js` 或 `.md2uirc.json`:
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
// md2ui.config.js
|
|
11
|
+
export default {
|
|
12
|
+
title: '我的文档站',
|
|
13
|
+
port: 8080,
|
|
14
|
+
folderExpanded: true,
|
|
15
|
+
themeColor: '#3eaf7c',
|
|
16
|
+
github: 'https://github.com/xiaoyaodev/md2ui',
|
|
17
|
+
footer: 'Copyright © 2025'
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## 配置项
|
|
22
|
+
|
|
23
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
24
|
+
|--------|------|--------|------|
|
|
25
|
+
| title | string | `'md2ui'` | 站点标题,显示在顶部 Logo 旁 |
|
|
26
|
+
| port | number | `3000` | 开发服务器端口 |
|
|
27
|
+
| folderExpanded | boolean | `false` | 文件夹默认是否展开 |
|
|
28
|
+
| themeColor | string | `'#3eaf7c'` | 主题色,影响全局强调色 |
|
|
29
|
+
| github | string | `''` | GitHub 仓库链接,显示在顶部 |
|
|
30
|
+
| footer | string | `''` | 页脚内容 |
|
|
31
|
+
|
|
32
|
+
## JSON 格式
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"title": "我的文档站",
|
|
37
|
+
"port": 8080,
|
|
38
|
+
"folderExpanded": true,
|
|
39
|
+
"themeColor": "#3eaf7c",
|
|
40
|
+
"github": "https://github.com/xiaoyaodev/md2ui",
|
|
41
|
+
"footer": "Copyright © 2025"
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 优先级
|
|
46
|
+
|
|
47
|
+
配置合并优先级(从低到高):
|
|
48
|
+
|
|
49
|
+
1. 内置默认值
|
|
50
|
+
2. 配置文件(md2ui.config.js 或 .md2uirc.json)
|
|
51
|
+
3. 命令行参数(如 `-p 8080`)
|
|
52
|
+
|
|
53
|
+
## 验证要点
|
|
54
|
+
|
|
55
|
+
1. 创建配置文件后重启服务,配置应生效
|
|
56
|
+
2. 修改 title 后页面标题应更新
|
|
57
|
+
3. 修改 themeColor 后主题色应变化
|
|
58
|
+
4. 命令行 `-p` 参数应覆盖配置文件中的 port
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# 一级文档
|
|
2
|
+
|
|
3
|
+
这是多级目录测试的一级文档,用于验证目录树的多级嵌套显示。
|
|
4
|
+
|
|
5
|
+
## 目录结构
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
11-多级目录测试/
|
|
9
|
+
├── 01-一级文档.md ← 当前文档
|
|
10
|
+
└── 01-子目录/
|
|
11
|
+
├── 01-二级文档.md
|
|
12
|
+
└── 01-深层嵌套/
|
|
13
|
+
└── 01-三级文档.md
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## 验证要点
|
|
17
|
+
|
|
18
|
+
1. 左侧导航应正确显示三级嵌套结构
|
|
19
|
+
2. 文件夹节点应可展开/收起
|
|
20
|
+
3. 各级文档应可正常点击加载
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# 三级文档
|
|
2
|
+
|
|
3
|
+
这是多级目录测试的三级文档,位于 `01-子目录/01-深层嵌套` 下。
|
|
4
|
+
|
|
5
|
+
## 深层嵌套验证
|
|
6
|
+
|
|
7
|
+
本文档验证三级及以上目录嵌套的正确性:
|
|
8
|
+
|
|
9
|
+
- 导航树应正确缩进显示
|
|
10
|
+
- 文件夹展开/收起应正常工作
|
|
11
|
+
- 上下篇导航应正确计算
|
|
12
|
+
|
|
13
|
+
## 站内链接测试
|
|
14
|
+
|
|
15
|
+
- [返回二级文档](../01-二级文档.md)
|
|
16
|
+
- [返回一级文档](../../01-一级文档.md)
|
|
17
|
+
- [跳转到快速开始](../../../00-快速开始.md)
|
|
18
|
+
|
|
19
|
+
## 验证要点
|
|
20
|
+
|
|
21
|
+
1. 本文档应在导航树的第三级显示
|
|
22
|
+
2. 多级返回链接应可正常跳转
|
|
23
|
+
3. 跨多级目录的链接应正确解析
|
package/src/App.vue
CHANGED
|
@@ -10,8 +10,13 @@
|
|
|
10
10
|
<!-- 桌面端顶栏 -->
|
|
11
11
|
<TopBar
|
|
12
12
|
v-if="!isMobile"
|
|
13
|
+
:editMode="editMode"
|
|
14
|
+
:showWelcome="showWelcome"
|
|
15
|
+
:docTitle="currentDocTitle"
|
|
16
|
+
:rawMarkdown="rawMarkdown"
|
|
13
17
|
@select-search="handleSearchSelect"
|
|
14
18
|
@go-home="goHome"
|
|
19
|
+
@toggle-edit="onToggleEdit"
|
|
15
20
|
/>
|
|
16
21
|
<!-- 移动端遮罩 -->
|
|
17
22
|
<transition name="fade">
|
|
@@ -34,23 +39,44 @@
|
|
|
34
39
|
@collapse-all="onCollapseAll"
|
|
35
40
|
@toggle-folder="toggleFolder"
|
|
36
41
|
@select-doc="handleDocSelect"
|
|
42
|
+
@create-doc="createDoc"
|
|
43
|
+
@delete-doc="deleteDoc"
|
|
44
|
+
@rename-doc="({ item, newName }) => renameDoc(item, newName)"
|
|
45
|
+
@reorder="reorderDocs"
|
|
37
46
|
/>
|
|
38
47
|
<!-- 左侧拖拽条 & 展开按钮(桌面端) -->
|
|
39
48
|
<div v-if="!isMobile && !sidebarCollapsed" class="resizer resizer-left" @mousedown="startResize('left', $event)"></div>
|
|
40
49
|
<button v-if="!isMobile && sidebarCollapsed" class="expand-btn expand-btn-left" @click="sidebarCollapsed = false" title="展开导航">
|
|
41
50
|
<ChevronRight :size="14" />
|
|
42
51
|
</button>
|
|
43
|
-
<!--
|
|
52
|
+
<!-- 内容区:查看模式 / 编辑模式 -->
|
|
44
53
|
<DocContent
|
|
54
|
+
v-if="!editMode"
|
|
45
55
|
:showWelcome="showWelcome"
|
|
46
56
|
:htmlContent="htmlContent"
|
|
47
57
|
:prevDoc="prevDoc"
|
|
48
58
|
:nextDoc="nextDoc"
|
|
49
59
|
:docTitle="currentDocTitle"
|
|
60
|
+
:lastModified="lastModified"
|
|
61
|
+
@scroll="handleScroll"
|
|
62
|
+
@content-click="onContentClick"
|
|
63
|
+
@start="loadFirstDoc"
|
|
64
|
+
@load-doc="loadDoc"
|
|
65
|
+
/>
|
|
66
|
+
<EditorContentVue
|
|
67
|
+
v-else
|
|
68
|
+
:showWelcome="showWelcome"
|
|
69
|
+
:markdownContent="rawMarkdown"
|
|
70
|
+
:prevDoc="prevDoc"
|
|
71
|
+
:nextDoc="nextDoc"
|
|
72
|
+
:docTitle="currentDocTitle"
|
|
73
|
+
:currentDocPath="currentDocFilePath"
|
|
50
74
|
@scroll="handleScroll"
|
|
51
75
|
@content-click="onContentClick"
|
|
52
76
|
@start="loadFirstDoc"
|
|
53
77
|
@load-doc="loadDoc"
|
|
78
|
+
@save="onSaveDoc"
|
|
79
|
+
@update:markdownContent="onEditorUpdate"
|
|
54
80
|
/>
|
|
55
81
|
<!-- 桌面端 TOC -->
|
|
56
82
|
<div v-if="!isMobile && !tocCollapsed && tocItems.length > 0" class="resizer resizer-right" @mousedown="startResize('right', $event)"></div>
|
|
@@ -84,25 +110,28 @@
|
|
|
84
110
|
</template>
|
|
85
111
|
|
|
86
112
|
<script setup>
|
|
87
|
-
import { ref, onMounted } from 'vue'
|
|
113
|
+
import { ref, onMounted, nextTick, watch } from 'vue'
|
|
88
114
|
import { ArrowUp, ChevronRight, ChevronLeft } from 'lucide-vue-next'
|
|
89
115
|
import MobileHeader from './components/MobileHeader.vue'
|
|
90
116
|
import TopBar from './components/TopBar.vue'
|
|
91
117
|
import AppSidebar from './components/AppSidebar.vue'
|
|
92
118
|
import DocContent from './components/DocContent.vue'
|
|
119
|
+
import EditorContentVue from './components/EditorContent.vue'
|
|
93
120
|
import TableOfContents from './components/TableOfContents.vue'
|
|
94
121
|
import MobileToc from './components/MobileToc.vue'
|
|
95
122
|
import ImageZoom from './components/ImageZoom.vue'
|
|
96
123
|
import { useDocManager } from './composables/useDocManager.js'
|
|
97
124
|
import { useResize } from './composables/useResize.js'
|
|
125
|
+
import { useFileWatcher } from './composables/useFileWatcher.js'
|
|
126
|
+
import { resetContentEtag } from './services/DocService.js'
|
|
98
127
|
|
|
99
128
|
import { useSearch } from './composables/useSearch.js'
|
|
100
129
|
import { useMobile } from './composables/useMobile.js'
|
|
101
130
|
|
|
102
131
|
|
|
103
|
-
// UI
|
|
104
|
-
const sidebarCollapsed = ref(
|
|
105
|
-
const tocCollapsed = ref(
|
|
132
|
+
// UI 状态(从 sessionStorage 恢复)
|
|
133
|
+
const sidebarCollapsed = ref(sessionStorage.getItem('sidebarCollapsed') === 'true')
|
|
134
|
+
const tocCollapsed = ref(sessionStorage.getItem('tocCollapsed') === 'true')
|
|
106
135
|
const zoomVisible = ref(false)
|
|
107
136
|
const zoomContent = ref('')
|
|
108
137
|
const zoomImages = ref([])
|
|
@@ -111,12 +140,15 @@ const zoomIndex = ref(0)
|
|
|
111
140
|
// composables
|
|
112
141
|
const {
|
|
113
142
|
docsList, currentDoc, currentDocTitle, showWelcome, htmlContent, tocItems,
|
|
143
|
+
editMode, rawMarkdown, currentDocFilePath, lastModified,
|
|
114
144
|
scrollProgress, showBackToTop, activeHeading,
|
|
115
145
|
handleScroll, scrollToHeading, scrollToTop,
|
|
116
146
|
loadDocsList, loadFromUrl, goHome, loadDoc, loadFirstDoc,
|
|
117
147
|
handleDocSelect, handleContentClick, handleSearchSelect,
|
|
148
|
+
toggleEditMode, reloadDocsList, reloadCurrentDoc, saveDoc, getCurrentDocPath,
|
|
118
149
|
toggleFolder, onExpandAll, onCollapseAll,
|
|
119
|
-
prevDoc, nextDoc
|
|
150
|
+
prevDoc, nextDoc,
|
|
151
|
+
createDoc, deleteDoc, renameDoc, reorderDocs
|
|
120
152
|
} = useDocManager()
|
|
121
153
|
|
|
122
154
|
const { sidebarWidth, tocWidth, startResize } = useResize()
|
|
@@ -124,6 +156,94 @@ const { sidebarWidth, tocWidth, startResize } = useResize()
|
|
|
124
156
|
const { openSearch } = useSearch()
|
|
125
157
|
const { isMobile, mobileDrawerOpen, mobileTocOpen } = useMobile()
|
|
126
158
|
|
|
159
|
+
// 文件监听(DocService 统一处理模式检测和 ETag)
|
|
160
|
+
useFileWatcher({
|
|
161
|
+
getCurrentDocPath,
|
|
162
|
+
onDocsListChange: (newTree) => reloadDocsList(newTree),
|
|
163
|
+
onDocContentChange: (content) => reloadCurrentDoc(content)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// 切换编辑模式(基于锚点/标题文本保持阅读位置)
|
|
167
|
+
function onToggleEdit(isEdit) {
|
|
168
|
+
const contentEl = document.querySelector('.content')
|
|
169
|
+
let anchorId = ''
|
|
170
|
+
let headingText = ''
|
|
171
|
+
let scrollRatio = 0
|
|
172
|
+
|
|
173
|
+
if (contentEl) {
|
|
174
|
+
const headings = contentEl.querySelectorAll('.markdown-content h1, .markdown-content h2, .markdown-content h3, .markdown-content h4, .markdown-content h5, .markdown-content h6')
|
|
175
|
+
const contentRect = contentEl.getBoundingClientRect()
|
|
176
|
+
const scrollTop = contentEl.scrollTop
|
|
177
|
+
|
|
178
|
+
// 找到当前视口中最近的标题(最后一个已滚过顶部的)
|
|
179
|
+
for (const heading of headings) {
|
|
180
|
+
const rect = heading.getBoundingClientRect()
|
|
181
|
+
const offsetFromTop = rect.top - contentRect.top
|
|
182
|
+
if (offsetFromTop <= 80) {
|
|
183
|
+
anchorId = heading.id || ''
|
|
184
|
+
// 提取纯文本(去掉锚点图标等)
|
|
185
|
+
const clone = heading.cloneNode(true)
|
|
186
|
+
clone.querySelectorAll('.heading-anchor').forEach(a => a.remove())
|
|
187
|
+
headingText = clone.textContent.trim()
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 记录滚动比例作为兜底
|
|
192
|
+
const scrollHeight = contentEl.scrollHeight - contentEl.clientHeight
|
|
193
|
+
scrollRatio = scrollHeight > 0 ? scrollTop / scrollHeight : 0
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
editMode.value = isEdit
|
|
197
|
+
sessionStorage.setItem('editMode', isEdit)
|
|
198
|
+
|
|
199
|
+
nextTick(() => {
|
|
200
|
+
const newContentEl = document.querySelector('.content')
|
|
201
|
+
if (!newContentEl) return
|
|
202
|
+
|
|
203
|
+
// 策略1:通过 id 定位(查看模式有 id)
|
|
204
|
+
if (anchorId) {
|
|
205
|
+
const target = document.getElementById(anchorId)
|
|
206
|
+
if (target) {
|
|
207
|
+
target.scrollIntoView({ block: 'start' })
|
|
208
|
+
activeHeading.value = anchorId
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 策略2:通过标题文本匹配(编辑模式无 id,但文本一致)
|
|
214
|
+
if (headingText) {
|
|
215
|
+
const headings = newContentEl.querySelectorAll('h1, h2, h3, h4, h5, h6')
|
|
216
|
+
for (const heading of headings) {
|
|
217
|
+
const clone = heading.cloneNode(true)
|
|
218
|
+
clone.querySelectorAll('.heading-anchor').forEach(a => a.remove())
|
|
219
|
+
if (clone.textContent.trim() === headingText) {
|
|
220
|
+
heading.scrollIntoView({ block: 'start' })
|
|
221
|
+
if (heading.id) activeHeading.value = heading.id
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 策略3:按滚动比例恢复
|
|
228
|
+
const scrollHeight = newContentEl.scrollHeight - newContentEl.clientHeight
|
|
229
|
+
if (scrollHeight > 0) {
|
|
230
|
+
newContentEl.scrollTop = scrollRatio * scrollHeight
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 编辑器内容更新
|
|
236
|
+
function onEditorUpdate(md) {
|
|
237
|
+
rawMarkdown.value = md
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 保存文档
|
|
241
|
+
async function onSaveDoc({ path, content }) {
|
|
242
|
+
const ok = await saveDoc({ path, content })
|
|
243
|
+
if (ok) {
|
|
244
|
+
resetContentEtag()
|
|
245
|
+
}
|
|
246
|
+
}
|
|
127
247
|
|
|
128
248
|
// 内容区点击:委托给 docManager,图片放大回调在这里处理
|
|
129
249
|
function onContentClick(event) {
|
|
@@ -139,6 +259,10 @@ function onContentClick(event) {
|
|
|
139
259
|
// 全局快捷键
|
|
140
260
|
window.addEventListener('popstate', () => loadFromUrl())
|
|
141
261
|
|
|
262
|
+
// 持久化 UI 状态
|
|
263
|
+
watch(sidebarCollapsed, (v) => sessionStorage.setItem('sidebarCollapsed', v))
|
|
264
|
+
watch(tocCollapsed, (v) => sessionStorage.setItem('tocCollapsed', v))
|
|
265
|
+
|
|
142
266
|
onMounted(async () => {
|
|
143
267
|
await loadDocsList()
|
|
144
268
|
await loadFromUrl()
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
:class="{ 'sidebar-drawer': isMobile, 'drawer-open': drawerOpen }"
|
|
5
5
|
:style="!isMobile ? { width: width + 'px' } : undefined"
|
|
6
6
|
>
|
|
7
|
-
<!-- 移动端显示 Logo + 关闭按钮,桌面端只显示收起按钮 -->
|
|
8
7
|
<div class="sidebar-header">
|
|
9
8
|
<Logo v-if="isMobile" @go-home="$emit('go-home')" />
|
|
10
9
|
<div class="sidebar-header-actions">
|
|
@@ -20,6 +19,9 @@
|
|
|
20
19
|
<div class="nav-section">
|
|
21
20
|
<span>文档目录</span>
|
|
22
21
|
<div class="nav-actions">
|
|
22
|
+
<button class="action-btn" @click="showCreateMenu($event, '')" title="新建">
|
|
23
|
+
<Plus :size="14" />
|
|
24
|
+
</button>
|
|
23
25
|
<button class="action-btn" @click="$emit('expand-all')" title="全部展开">
|
|
24
26
|
<ChevronsDownUp :size="14" />
|
|
25
27
|
</button>
|
|
@@ -28,37 +30,106 @@
|
|
|
28
30
|
</button>
|
|
29
31
|
</div>
|
|
30
32
|
</div>
|
|
31
|
-
<!-- 过滤输入框 -->
|
|
32
33
|
<div class="nav-filter">
|
|
33
34
|
<Filter :size="12" class="nav-filter-icon" />
|
|
34
|
-
<input
|
|
35
|
-
v-model="filterText"
|
|
36
|
-
type="text"
|
|
37
|
-
class="nav-filter-input"
|
|
38
|
-
placeholder="过滤文档..."
|
|
39
|
-
/>
|
|
35
|
+
<input v-model="filterText" type="text" class="nav-filter-input" placeholder="过滤文档..." />
|
|
40
36
|
<button v-if="filterText" class="nav-filter-clear" @click="filterText = ''">
|
|
41
37
|
<X :size="12" />
|
|
42
38
|
</button>
|
|
43
39
|
</div>
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
:
|
|
48
|
-
:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
40
|
+
<!-- 无过滤时使用拖拽排序 -->
|
|
41
|
+
<draggable
|
|
42
|
+
v-if="!filterText"
|
|
43
|
+
:list="docsList"
|
|
44
|
+
:group="{ name: 'doc-tree', pull: true, put: true }"
|
|
45
|
+
item-key="key"
|
|
46
|
+
:animation="200"
|
|
47
|
+
ghost-class="drag-ghost"
|
|
48
|
+
chosen-class="drag-chosen"
|
|
49
|
+
drag-class="drag-active"
|
|
50
|
+
handle=".nav-item"
|
|
51
|
+
:fallback-on-body="true"
|
|
52
|
+
:swap-threshold="0.65"
|
|
53
|
+
@end="onDragEnd"
|
|
54
|
+
>
|
|
55
|
+
<template #item="{ element }">
|
|
56
|
+
<TreeNode
|
|
57
|
+
:item="element"
|
|
58
|
+
:currentDoc="currentDoc"
|
|
59
|
+
@toggle="$emit('toggle-folder', $event)"
|
|
60
|
+
@select="$emit('select-doc', $event)"
|
|
61
|
+
@show-create="showCreateMenu"
|
|
62
|
+
@rename="(payload) => $emit('rename-doc', payload)"
|
|
63
|
+
@delete="(payload) => $emit('delete-doc', payload)"
|
|
64
|
+
@drag-end="onDragEnd"
|
|
65
|
+
/>
|
|
66
|
+
</template>
|
|
67
|
+
</draggable>
|
|
68
|
+
<!-- 过滤模式下禁用拖拽 -->
|
|
69
|
+
<template v-else>
|
|
70
|
+
<TreeNode
|
|
71
|
+
v-for="item in filteredDocs"
|
|
72
|
+
:key="item.key"
|
|
73
|
+
:item="item"
|
|
74
|
+
:currentDoc="currentDoc"
|
|
75
|
+
@toggle="$emit('toggle-folder', $event)"
|
|
76
|
+
@select="$emit('select-doc', $event)"
|
|
77
|
+
@show-create="showCreateMenu"
|
|
78
|
+
@rename="(payload) => $emit('rename-doc', payload)"
|
|
79
|
+
@delete="(payload) => $emit('delete-doc', payload)"
|
|
80
|
+
/>
|
|
81
|
+
</template>
|
|
52
82
|
<div v-if="filterText && filteredDocs.length === 0" class="nav-filter-empty">
|
|
53
83
|
没有匹配的文档
|
|
54
84
|
</div>
|
|
55
85
|
</nav>
|
|
86
|
+
|
|
87
|
+
<!-- 新建菜单 -->
|
|
88
|
+
<Teleport to="body">
|
|
89
|
+
<div v-if="createMenuVisible" class="ctx-menu-overlay" @click="createMenuVisible = false">
|
|
90
|
+
<div class="ctx-menu" :style="createMenuStyle" @click.stop>
|
|
91
|
+
<div class="ctx-menu-item" @click="startCreate('file')">
|
|
92
|
+
<FileText :size="14" />
|
|
93
|
+
<span>新建文档</span>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="ctx-menu-item" @click="startCreate('folder')">
|
|
96
|
+
<FolderPlus :size="14" />
|
|
97
|
+
<span>新建目录</span>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</Teleport>
|
|
102
|
+
|
|
103
|
+
<!-- 新建输入弹窗 -->
|
|
104
|
+
<Teleport to="body">
|
|
105
|
+
<div v-if="createInputVisible" class="modal-overlay" @click="createInputVisible = false">
|
|
106
|
+
<div class="modal-dialog" @click.stop>
|
|
107
|
+
<div class="modal-title">{{ createType === 'file' ? '新建文档' : '新建目录' }}</div>
|
|
108
|
+
<input
|
|
109
|
+
ref="createInputRef"
|
|
110
|
+
v-model="createName"
|
|
111
|
+
class="modal-input"
|
|
112
|
+
:placeholder="createType === 'file' ? '请输入文档名称' : '请输入目录名称'"
|
|
113
|
+
@keydown.enter="confirmCreate"
|
|
114
|
+
@keydown.escape="createInputVisible = false"
|
|
115
|
+
/>
|
|
116
|
+
<div v-if="createError" class="modal-error">{{ createError }}</div>
|
|
117
|
+
<div class="modal-actions">
|
|
118
|
+
<button class="modal-btn modal-btn-cancel" @click="createInputVisible = false">取消</button>
|
|
119
|
+
<button class="modal-btn modal-btn-confirm" @click="confirmCreate">确定</button>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</Teleport>
|
|
124
|
+
|
|
125
|
+
|
|
56
126
|
</aside>
|
|
57
127
|
</template>
|
|
58
128
|
|
|
59
129
|
<script setup>
|
|
60
|
-
import { ref, computed } from 'vue'
|
|
61
|
-
import { ChevronLeft, X, ChevronsDownUp, ChevronsUpDown, Filter } from 'lucide-vue-next'
|
|
130
|
+
import { ref, computed, nextTick, watch } from 'vue'
|
|
131
|
+
import { ChevronLeft, X, ChevronsDownUp, ChevronsUpDown, Filter, Plus, FileText, FolderPlus } from 'lucide-vue-next'
|
|
132
|
+
import draggable from 'vuedraggable'
|
|
62
133
|
import Logo from './Logo.vue'
|
|
63
134
|
import TreeNode from './TreeNode.vue'
|
|
64
135
|
|
|
@@ -70,15 +141,16 @@ const props = defineProps({
|
|
|
70
141
|
width: { type: Number, default: 320 }
|
|
71
142
|
})
|
|
72
143
|
|
|
73
|
-
defineEmits([
|
|
144
|
+
const emit = defineEmits([
|
|
74
145
|
'go-home', 'close-drawer', 'collapse',
|
|
75
146
|
'expand-all', 'collapse-all',
|
|
76
|
-
'toggle-folder', 'select-doc'
|
|
147
|
+
'toggle-folder', 'select-doc',
|
|
148
|
+
'create-doc', 'delete-doc', 'rename-doc',
|
|
149
|
+
'reorder'
|
|
77
150
|
])
|
|
78
151
|
|
|
79
152
|
const filterText = ref('')
|
|
80
153
|
|
|
81
|
-
// 递归过滤文档树:保留匹配的文件和包含匹配文件的文件夹
|
|
82
154
|
function filterTree(items, keyword) {
|
|
83
155
|
if (!keyword) return items
|
|
84
156
|
const lower = keyword.toLowerCase()
|
|
@@ -99,4 +171,92 @@ function filterTree(items, keyword) {
|
|
|
99
171
|
}
|
|
100
172
|
|
|
101
173
|
const filteredDocs = computed(() => filterTree(props.docsList, filterText.value))
|
|
174
|
+
|
|
175
|
+
// 当前文档变化时,自动滚动菜单到选中项
|
|
176
|
+
watch(() => props.currentDoc, (newDoc) => {
|
|
177
|
+
if (!newDoc) return
|
|
178
|
+
nextTick(() => {
|
|
179
|
+
const activeEl = document.querySelector('.nav-menu .nav-item.active')
|
|
180
|
+
if (activeEl) {
|
|
181
|
+
activeEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// ===== 新建菜单 =====
|
|
187
|
+
const createMenuVisible = ref(false)
|
|
188
|
+
const createMenuStyle = ref({})
|
|
189
|
+
const createParentKey = ref('')
|
|
190
|
+
|
|
191
|
+
function showCreateMenu(event, parentKey) {
|
|
192
|
+
createParentKey.value = parentKey
|
|
193
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
194
|
+
// 先以不可见状态渲染,等测量后再修正位置
|
|
195
|
+
createMenuStyle.value = {
|
|
196
|
+
position: 'fixed',
|
|
197
|
+
left: `${rect.right + 4}px`,
|
|
198
|
+
top: `${rect.top}px`,
|
|
199
|
+
zIndex: 9999,
|
|
200
|
+
visibility: 'hidden'
|
|
201
|
+
}
|
|
202
|
+
createMenuVisible.value = true
|
|
203
|
+
nextTick(() => {
|
|
204
|
+
const menuEl = document.querySelector('.ctx-menu')
|
|
205
|
+
if (menuEl) {
|
|
206
|
+
const menuRect = menuEl.getBoundingClientRect()
|
|
207
|
+
let left = rect.right + 4
|
|
208
|
+
let top = rect.top
|
|
209
|
+
// 右侧溢出修正
|
|
210
|
+
if (left + menuRect.width > window.innerWidth - 10) {
|
|
211
|
+
left = rect.left - menuRect.width - 4
|
|
212
|
+
}
|
|
213
|
+
// 底部溢出修正:向上弹出,使菜单底部对齐触发按钮底部
|
|
214
|
+
if (top + menuRect.height > window.innerHeight - 10) {
|
|
215
|
+
top = rect.bottom - menuRect.height
|
|
216
|
+
}
|
|
217
|
+
// 如果向上修正后超出顶部,则贴顶
|
|
218
|
+
if (top < 10) {
|
|
219
|
+
top = 10
|
|
220
|
+
}
|
|
221
|
+
createMenuStyle.value = {
|
|
222
|
+
position: 'fixed',
|
|
223
|
+
left: `${left}px`,
|
|
224
|
+
top: `${top}px`,
|
|
225
|
+
zIndex: 9999,
|
|
226
|
+
visibility: 'visible'
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ===== 新建输入 =====
|
|
233
|
+
const createInputVisible = ref(false)
|
|
234
|
+
const createInputRef = ref(null)
|
|
235
|
+
const createType = ref('file')
|
|
236
|
+
const createName = ref('')
|
|
237
|
+
const createError = ref('')
|
|
238
|
+
|
|
239
|
+
function startCreate(type) {
|
|
240
|
+
createMenuVisible.value = false
|
|
241
|
+
createType.value = type
|
|
242
|
+
createName.value = ''
|
|
243
|
+
createError.value = ''
|
|
244
|
+
createInputVisible.value = true
|
|
245
|
+
nextTick(() => createInputRef.value?.focus())
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function confirmCreate() {
|
|
249
|
+
const name = createName.value.trim()
|
|
250
|
+
if (!name) { createError.value = '名称不能为空'; return }
|
|
251
|
+
if (/[\\/:*?"<>|]/.test(name)) { createError.value = '名称包含非法字符'; return }
|
|
252
|
+
createError.value = ''
|
|
253
|
+
emit('create-doc', { parentKey: createParentKey.value, name, type: createType.value })
|
|
254
|
+
createInputVisible.value = false
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ===== 拖拽排序 =====
|
|
258
|
+
function onDragEnd() {
|
|
259
|
+
// 拖拽结束后,通知父组件执行重编号
|
|
260
|
+
emit('reorder')
|
|
261
|
+
}
|
|
102
262
|
</script>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<node-view-wrapper class="code-block-wrapper editor-code-block" data-type="codeBlock">
|
|
3
|
+
<!-- 代码块 header -->
|
|
4
|
+
<div class="code-block-header" contenteditable="false">
|
|
5
|
+
<input
|
|
6
|
+
class="code-lang-input"
|
|
7
|
+
:value="node.attrs.language || ''"
|
|
8
|
+
@input="updateLanguage"
|
|
9
|
+
placeholder="语言"
|
|
10
|
+
spellcheck="false"
|
|
11
|
+
/>
|
|
12
|
+
<div class="code-block-actions">
|
|
13
|
+
<button class="code-action-btn" :class="{ copied }" @click="copyCode" title="复制代码">
|
|
14
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
15
|
+
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
16
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
|
17
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
|
18
|
+
</svg>
|
|
19
|
+
<span class="copy-text">{{ copied ? '已复制' : '复制' }}</span>
|
|
20
|
+
</button>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
<!-- 代码编辑区域:行号 gutter + 编辑区 -->
|
|
24
|
+
<div class="code-block-body">
|
|
25
|
+
<div class="code-edit-layout">
|
|
26
|
+
<!-- 行号列 -->
|
|
27
|
+
<div class="code-line-gutter" contenteditable="false" aria-hidden="true">
|
|
28
|
+
<span v-for="n in lineCount" :key="n" class="code-ln-num">{{ n }}</span>
|
|
29
|
+
</div>
|
|
30
|
+
<!-- 编辑区 -->
|
|
31
|
+
<pre class="code-edit-area"><node-view-content as="code" :class="codeClass" /></pre>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</node-view-wrapper>
|
|
35
|
+
</template>
|
|
36
|
+
|
|
37
|
+
<script setup>
|
|
38
|
+
import { ref, computed } from 'vue'
|
|
39
|
+
import { nodeViewProps, NodeViewWrapper, NodeViewContent } from '@tiptap/vue-3'
|
|
40
|
+
|
|
41
|
+
const props = defineProps(nodeViewProps)
|
|
42
|
+
const copied = ref(false)
|
|
43
|
+
|
|
44
|
+
const codeClass = computed(() => {
|
|
45
|
+
const lang = props.node.attrs.language
|
|
46
|
+
return lang ? `language-${lang}` : ''
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// 根据代码内容计算行数(响应式,编辑时自动更新)
|
|
50
|
+
const lineCount = computed(() => {
|
|
51
|
+
const text = props.node.textContent
|
|
52
|
+
if (!text) return 1
|
|
53
|
+
return text.split('\n').length
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// 更新语言属性
|
|
57
|
+
function updateLanguage(e) {
|
|
58
|
+
props.updateAttributes({ language: e.target.value || null })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 复制代码
|
|
62
|
+
async function copyCode() {
|
|
63
|
+
const text = props.node.textContent
|
|
64
|
+
try {
|
|
65
|
+
await navigator.clipboard.writeText(text)
|
|
66
|
+
copied.value = true
|
|
67
|
+
setTimeout(() => { copied.value = false }, 2000)
|
|
68
|
+
} catch {
|
|
69
|
+
// fallback
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
</script>
|