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
package/src/App.vue
CHANGED
|
@@ -5,13 +5,18 @@
|
|
|
5
5
|
v-if="isMobile"
|
|
6
6
|
@open-drawer="mobileDrawerOpen = true"
|
|
7
7
|
@go-home="goHome"
|
|
8
|
-
@open-search="
|
|
8
|
+
@open-search="mobileSearchOpen = true"
|
|
9
9
|
/>
|
|
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,24 +39,51 @@
|
|
|
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
|
+
<!-- 移动端搜索页(替换内容区) -->
|
|
53
|
+
<MobileSearch
|
|
54
|
+
v-if="isMobile && mobileSearchOpen"
|
|
55
|
+
@close="mobileSearchOpen = false"
|
|
56
|
+
@select="(key) => { mobileSearchOpen = false; handleSearchSelect(key) }"
|
|
57
|
+
/>
|
|
58
|
+
<!-- 内容区:查看模式 / 编辑模式 -->
|
|
44
59
|
<DocContent
|
|
60
|
+
v-else-if="!editMode"
|
|
45
61
|
:showWelcome="showWelcome"
|
|
46
62
|
:htmlContent="htmlContent"
|
|
47
63
|
:prevDoc="prevDoc"
|
|
48
64
|
:nextDoc="nextDoc"
|
|
49
65
|
:docTitle="currentDocTitle"
|
|
66
|
+
:lastModified="lastModified"
|
|
50
67
|
@scroll="handleScroll"
|
|
51
68
|
@content-click="onContentClick"
|
|
52
69
|
@start="loadFirstDoc"
|
|
53
70
|
@load-doc="loadDoc"
|
|
54
71
|
/>
|
|
72
|
+
<EditorContentVue
|
|
73
|
+
v-else
|
|
74
|
+
:showWelcome="showWelcome"
|
|
75
|
+
:markdownContent="rawMarkdown"
|
|
76
|
+
:prevDoc="prevDoc"
|
|
77
|
+
:nextDoc="nextDoc"
|
|
78
|
+
:docTitle="currentDocTitle"
|
|
79
|
+
:currentDocPath="currentDocFilePath"
|
|
80
|
+
@scroll="handleScroll"
|
|
81
|
+
@content-click="onContentClick"
|
|
82
|
+
@start="loadFirstDoc"
|
|
83
|
+
@load-doc="loadDoc"
|
|
84
|
+
@save="onSaveDoc"
|
|
85
|
+
@update:markdownContent="onEditorUpdate"
|
|
86
|
+
/>
|
|
55
87
|
<!-- 桌面端 TOC -->
|
|
56
88
|
<div v-if="!isMobile && !tocCollapsed && tocItems.length > 0" class="resizer resizer-right" @mousedown="startResize('right', $event)"></div>
|
|
57
89
|
<TableOfContents v-if="!isMobile" :tocItems="tocItems" :activeHeading="activeHeading" :collapsed="tocCollapsed" :width="tocWidth" @toggle="tocCollapsed = !tocCollapsed" @scroll-to="(id) => scrollToHeading(id, { push: true })" />
|
|
@@ -84,46 +116,104 @@
|
|
|
84
116
|
</template>
|
|
85
117
|
|
|
86
118
|
<script setup>
|
|
87
|
-
import { ref, onMounted } from 'vue'
|
|
119
|
+
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
|
88
120
|
import { ArrowUp, ChevronRight, ChevronLeft } from 'lucide-vue-next'
|
|
89
121
|
import MobileHeader from './components/MobileHeader.vue'
|
|
90
122
|
import TopBar from './components/TopBar.vue'
|
|
91
123
|
import AppSidebar from './components/AppSidebar.vue'
|
|
92
124
|
import DocContent from './components/DocContent.vue'
|
|
125
|
+
import EditorContentVue from './components/EditorContent.vue'
|
|
93
126
|
import TableOfContents from './components/TableOfContents.vue'
|
|
94
127
|
import MobileToc from './components/MobileToc.vue'
|
|
95
128
|
import ImageZoom from './components/ImageZoom.vue'
|
|
129
|
+
import MobileSearch from './components/MobileSearch.vue'
|
|
96
130
|
import { useDocManager } from './composables/useDocManager.js'
|
|
97
131
|
import { useResize } from './composables/useResize.js'
|
|
132
|
+
import { useFileWatcher } from './composables/useFileWatcher.js'
|
|
133
|
+
import { resetContentEtag } from './services/DocService.js'
|
|
98
134
|
|
|
99
|
-
import { useSearch } from './composables/useSearch.js'
|
|
100
135
|
import { useMobile } from './composables/useMobile.js'
|
|
101
136
|
|
|
102
137
|
|
|
103
|
-
// UI
|
|
104
|
-
const sidebarCollapsed = ref(
|
|
105
|
-
const tocCollapsed = ref(
|
|
138
|
+
// UI 状态(从 sessionStorage 恢复)
|
|
139
|
+
const sidebarCollapsed = ref(sessionStorage.getItem('sidebarCollapsed') === 'true')
|
|
140
|
+
const tocCollapsed = ref(sessionStorage.getItem('tocCollapsed') === 'true')
|
|
106
141
|
const zoomVisible = ref(false)
|
|
107
|
-
const zoomContent = ref('')
|
|
108
142
|
const zoomImages = ref([])
|
|
109
143
|
const zoomIndex = ref(0)
|
|
144
|
+
const mobileSearchOpen = ref(false)
|
|
110
145
|
|
|
111
146
|
// composables
|
|
112
147
|
const {
|
|
113
148
|
docsList, currentDoc, currentDocTitle, showWelcome, htmlContent, tocItems,
|
|
149
|
+
editMode, rawMarkdown, currentDocFilePath, lastModified,
|
|
114
150
|
scrollProgress, showBackToTop, activeHeading,
|
|
115
151
|
handleScroll, scrollToHeading, scrollToTop,
|
|
116
152
|
loadDocsList, loadFromUrl, goHome, loadDoc, loadFirstDoc,
|
|
117
153
|
handleDocSelect, handleContentClick, handleSearchSelect,
|
|
154
|
+
toggleEditMode, reloadDocsList, reloadCurrentDoc, saveDoc, getCurrentDocPath,
|
|
118
155
|
toggleFolder, onExpandAll, onCollapseAll,
|
|
119
|
-
prevDoc, nextDoc
|
|
156
|
+
prevDoc, nextDoc,
|
|
157
|
+
createDoc, deleteDoc, renameDoc, reorderDocs
|
|
120
158
|
} = useDocManager()
|
|
121
159
|
|
|
122
160
|
const { sidebarWidth, tocWidth, startResize } = useResize()
|
|
123
161
|
|
|
124
|
-
const { openSearch } = useSearch()
|
|
125
162
|
const { isMobile, mobileDrawerOpen, mobileTocOpen } = useMobile()
|
|
126
163
|
|
|
164
|
+
// 文件监听(DocService 统一处理模式检测和 ETag)
|
|
165
|
+
useFileWatcher({
|
|
166
|
+
getCurrentDocPath,
|
|
167
|
+
onDocsListChange: (newTree) => reloadDocsList(newTree),
|
|
168
|
+
onDocContentChange: (content) => reloadCurrentDoc(content)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// 切换编辑模式(记住当前锚点,切换后复用 scrollToHeading 恢复位置)
|
|
172
|
+
function onToggleEdit(isEdit) {
|
|
173
|
+
// 记住当前激活的锚点
|
|
174
|
+
const savedAnchor = activeHeading.value
|
|
175
|
+
// 记录滚动比例作为兜底
|
|
176
|
+
const contentEl = document.querySelector('.content')
|
|
177
|
+
let scrollRatio = 0
|
|
178
|
+
if (contentEl) {
|
|
179
|
+
const scrollHeight = contentEl.scrollHeight - contentEl.clientHeight
|
|
180
|
+
scrollRatio = scrollHeight > 0 ? contentEl.scrollTop / scrollHeight : 0
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
editMode.value = isEdit
|
|
184
|
+
sessionStorage.setItem('editMode', isEdit)
|
|
185
|
+
|
|
186
|
+
// 等待 DOM 渲染完成后恢复位置
|
|
187
|
+
nextTick(() => {
|
|
188
|
+
// 再等一帧,确保 tiptap 编辑器完成渲染
|
|
189
|
+
requestAnimationFrame(() => {
|
|
190
|
+
const newContentEl = document.querySelector('.content')
|
|
191
|
+
if (!newContentEl) return
|
|
192
|
+
|
|
193
|
+
if (savedAnchor) {
|
|
194
|
+
scrollToHeading(savedAnchor)
|
|
195
|
+
} else if (scrollRatio > 0) {
|
|
196
|
+
const scrollHeight = newContentEl.scrollHeight - newContentEl.clientHeight
|
|
197
|
+
if (scrollHeight > 0) {
|
|
198
|
+
newContentEl.scrollTop = scrollRatio * scrollHeight
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 编辑器内容更新
|
|
206
|
+
function onEditorUpdate(md) {
|
|
207
|
+
rawMarkdown.value = md
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 保存文档
|
|
211
|
+
async function onSaveDoc({ path, content }) {
|
|
212
|
+
const ok = await saveDoc({ path, content })
|
|
213
|
+
if (ok) {
|
|
214
|
+
resetContentEtag()
|
|
215
|
+
}
|
|
216
|
+
}
|
|
127
217
|
|
|
128
218
|
// 内容区点击:委托给 docManager,图片放大回调在这里处理
|
|
129
219
|
function onContentClick(event) {
|
|
@@ -136,11 +226,20 @@ function onContentClick(event) {
|
|
|
136
226
|
})
|
|
137
227
|
}
|
|
138
228
|
|
|
139
|
-
//
|
|
140
|
-
|
|
229
|
+
// popstate 事件监听(生命周期管理)
|
|
230
|
+
function onPopstate() { loadFromUrl() }
|
|
231
|
+
|
|
232
|
+
// 持久化 UI 状态
|
|
233
|
+
watch(sidebarCollapsed, (v) => sessionStorage.setItem('sidebarCollapsed', v))
|
|
234
|
+
watch(tocCollapsed, (v) => sessionStorage.setItem('tocCollapsed', v))
|
|
141
235
|
|
|
142
236
|
onMounted(async () => {
|
|
237
|
+
window.addEventListener('popstate', onPopstate)
|
|
143
238
|
await loadDocsList()
|
|
144
239
|
await loadFromUrl()
|
|
145
240
|
})
|
|
241
|
+
|
|
242
|
+
onBeforeUnmount(() => {
|
|
243
|
+
window.removeEventListener('popstate', onPopstate)
|
|
244
|
+
})
|
|
146
245
|
</script>
|
|
@@ -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>
|
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
<main class="content" @scroll="$emit('scroll', $event)" @click="$emit('content-click', $event)">
|
|
3
3
|
<WelcomePage v-if="showWelcome" @start="$emit('start')" />
|
|
4
4
|
<template v-else>
|
|
5
|
-
<div class="doc-toolbar">
|
|
6
|
-
<button class="export-word-btn" :disabled="exporting" @click="handleExport" title="导出为 Word 文档">
|
|
7
|
-
<FileDown :size="15" />
|
|
8
|
-
<span>{{ exporting ? '导出中...' : '导出 Word' }}</span>
|
|
9
|
-
</button>
|
|
10
|
-
</div>
|
|
11
5
|
<article class="markdown-content" v-html="htmlContent"></article>
|
|
6
|
+
<!-- 最后更新时间 -->
|
|
7
|
+
<div v-if="lastModifiedText" class="doc-last-modified">
|
|
8
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
|
9
|
+
<span>最后更新于 {{ lastModifiedText }}</span>
|
|
10
|
+
</div>
|
|
12
11
|
<nav v-if="prevDoc || nextDoc" class="doc-nav">
|
|
13
12
|
<a v-if="prevDoc" class="doc-nav-link prev" @click.prevent="$emit('load-doc', prevDoc.key)">
|
|
14
13
|
<ChevronLeft :size="16" />
|
|
@@ -31,23 +30,35 @@
|
|
|
31
30
|
</template>
|
|
32
31
|
|
|
33
32
|
<script setup>
|
|
34
|
-
import {
|
|
33
|
+
import { computed } from 'vue'
|
|
34
|
+
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
|
35
35
|
import WelcomePage from './WelcomePage.vue'
|
|
36
|
-
import { useExportWord } from '../composables/useExportWord.js'
|
|
37
36
|
|
|
38
37
|
const props = defineProps({
|
|
39
38
|
showWelcome: { type: Boolean, default: true },
|
|
40
39
|
htmlContent: { type: String, default: '' },
|
|
41
40
|
prevDoc: { type: Object, default: null },
|
|
42
41
|
nextDoc: { type: Object, default: null },
|
|
43
|
-
docTitle: { type: String, default: '文档' }
|
|
42
|
+
docTitle: { type: String, default: '文档' },
|
|
43
|
+
lastModified: { type: String, default: '' },
|
|
44
44
|
})
|
|
45
45
|
|
|
46
46
|
defineEmits(['scroll', 'content-click', 'start', 'load-doc'])
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
// 格式化最后修改时间
|
|
49
|
+
const lastModifiedText = computed(() => {
|
|
50
|
+
if (!props.lastModified) return ''
|
|
51
|
+
try {
|
|
52
|
+
const date = new Date(props.lastModified)
|
|
53
|
+
if (isNaN(date.getTime())) return ''
|
|
54
|
+
const y = date.getFullYear()
|
|
55
|
+
const m = String(date.getMonth() + 1).padStart(2, '0')
|
|
56
|
+
const d = String(date.getDate()).padStart(2, '0')
|
|
57
|
+
const h = String(date.getHours()).padStart(2, '0')
|
|
58
|
+
const min = String(date.getMinutes()).padStart(2, '0')
|
|
59
|
+
return `${y}-${m}-${d} ${h}:${min}`
|
|
60
|
+
} catch {
|
|
61
|
+
return ''
|
|
62
|
+
}
|
|
63
|
+
})
|
|
53
64
|
</script>
|