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
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="openSearch"
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(false)
105
- const tocCollapsed = ref(false)
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
- window.addEventListener('popstate', () => loadFromUrl())
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
- <TreeNode
45
- v-for="item in filteredDocs"
46
- :key="item.key"
47
- :item="item"
48
- :currentDoc="currentDoc"
49
- @toggle="$emit('toggle-folder', $event)"
50
- @select="$emit('select-doc', $event)"
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 { ChevronLeft, ChevronRight, FileDown } from 'lucide-vue-next'
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
- const { exporting, exportToWord } = useExportWord()
49
-
50
- function handleExport() {
51
- exportToWord(props.docTitle)
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>