md2ui 1.0.19 → 1.0.21

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 (29) hide show
  1. package/README.md +65 -20
  2. package/bin/build.js +13 -2
  3. package/bin/md2ui.js +25 -12
  4. package/package.json +4 -4
  5. 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 +2 -0
  6. 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
  7. package/public/docs/02-Markdown/346/270/262/346/237/223/assets/img-1777383093712.png +0 -0
  8. 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
  9. 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 +4 -4
  10. package/src/App.vue +36 -61
  11. package/src/components/ImageZoom.vue +9 -123
  12. package/src/components/MermaidNodeView.vue +10 -2
  13. package/src/components/MobileSearch.vue +97 -0
  14. package/src/components/TableOfContents.vue +42 -6
  15. package/src/composables/useDocManager.js +134 -44
  16. package/src/composables/useDocTree.js +26 -50
  17. package/src/composables/useMarkdown.js +51 -140
  18. package/src/composables/useMermaidCache.js +15 -0
  19. package/src/composables/useScroll.js +317 -32
  20. package/src/composables/useSearch.js +12 -11
  21. package/src/config.js +1 -4
  22. package/src/services/DocService.js +0 -16
  23. package/src/style.css +235 -10
  24. package/src/utils/imageConverter.js +129 -0
  25. package/vite-plugin-doc-api.js +158 -157
  26. package/vite.config.js +5 -1
  27. package/src/components/SearchPanel.vue +0 -90
  28. package/src/components/TableBubbleMenu.vue +0 -177
  29. package/src/composables/useExportPdf.js +0 -102
@@ -24,6 +24,29 @@ export default function docApiPlugin(docsDir = '.') {
24
24
  return '"' + crypto.createHash('md5').update(content).digest('hex') + '"'
25
25
  }
26
26
 
27
+ // 安全路径验证:使用 path.relative 防止路径遍历
28
+ function safePath(filePath) {
29
+ const fullPath = path.resolve(resolvedDocsDir, filePath)
30
+ const relative = path.relative(resolvedDocsDir, fullPath)
31
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
32
+ return null // 路径越界
33
+ }
34
+ return fullPath
35
+ }
36
+
37
+ // 解析 POST 请求的 JSON body
38
+ function parseJsonBody(req) {
39
+ return new Promise((resolve, reject) => {
40
+ let body = ''
41
+ req.on('data', chunk => { body += chunk })
42
+ req.on('end', () => {
43
+ try { resolve(JSON.parse(body)) }
44
+ catch (e) { reject(e) }
45
+ })
46
+ req.on('error', reject)
47
+ })
48
+ }
49
+
27
50
  // 递归扫描 .md 文件,返回树结构(与 CLI 模式 scanDocs 一致)
28
51
  function scanDocs(dir, basePath = '', level = 0) {
29
52
  const items = []
@@ -101,8 +124,8 @@ export default function docApiPlugin(docsDir = '.') {
101
124
 
102
125
  // GET /@user-docs/xxx — 返回文件内容(md / 图片等)
103
126
  if (req.url?.startsWith('/@user-docs/') && req.method === 'GET') {
104
- const filePath = path.resolve(resolvedDocsDir, decodeURIComponent(req.url.replace('/@user-docs/', '')))
105
- if (!filePath.startsWith(resolvedDocsDir)) {
127
+ const filePath = safePath(decodeURIComponent(req.url.replace('/@user-docs/', '')))
128
+ if (!filePath) {
106
129
  res.statusCode = 403; res.end('禁止访问'); return
107
130
  }
108
131
  if (!fs.existsSync(filePath)) {
@@ -144,7 +167,8 @@ export default function docApiPlugin(docsDir = '.') {
144
167
  }
145
168
 
146
169
  const docDir = path.dirname(path.resolve(resolvedDocsDir, docPath))
147
- if (!docDir.startsWith(resolvedDocsDir)) {
170
+ const relative = path.relative(resolvedDocsDir, docDir)
171
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
148
172
  res.statusCode = 403; res.end('禁止访问'); return
149
173
  }
150
174
 
@@ -180,188 +204,165 @@ export default function docApiPlugin(docsDir = '.') {
180
204
  })
181
205
 
182
206
  // POST /api/create — 创建文件或文件夹
183
- server.middlewares.use('/api/create', (req, res) => {
207
+ server.middlewares.use('/api/create', async (req, res) => {
184
208
  if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
185
- let body = ''
186
- req.on('data', chunk => { body += chunk })
187
- req.on('end', () => {
188
- try {
189
- const { path: filePath, type } = JSON.parse(body)
190
- if (!filePath) {
191
- res.statusCode = 400; res.end('缺少 path'); return
192
- }
193
- const fullPath = path.resolve(resolvedDocsDir, filePath)
194
- if (!fullPath.startsWith(resolvedDocsDir)) {
195
- res.statusCode = 403; res.end('禁止访问'); return
196
- }
197
- if (fs.existsSync(fullPath)) {
198
- res.statusCode = 409; res.end('已存在同名文件或文件夹'); return
199
- }
200
- if (type === 'folder') {
201
- fs.mkdirSync(fullPath, { recursive: true })
202
- } else {
203
- fs.mkdirSync(path.dirname(fullPath), { recursive: true })
204
- fs.writeFileSync(fullPath, '', 'utf-8')
205
- }
206
- res.setHeader('Content-Type', 'application/json')
207
- res.end(JSON.stringify({ ok: true }))
208
- } catch (e) {
209
- res.statusCode = 500; res.end(e.message)
209
+ try {
210
+ const { path: filePath, type } = await parseJsonBody(req)
211
+ if (!filePath) {
212
+ res.statusCode = 400; res.end('缺少 path'); return
210
213
  }
211
- })
214
+ const fullPath = safePath(filePath)
215
+ if (!fullPath) {
216
+ res.statusCode = 403; res.end('禁止访问'); return
217
+ }
218
+ if (fs.existsSync(fullPath)) {
219
+ res.statusCode = 409; res.end('已存在同名文件或文件夹'); return
220
+ }
221
+ if (type === 'folder') {
222
+ fs.mkdirSync(fullPath, { recursive: true })
223
+ } else {
224
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true })
225
+ fs.writeFileSync(fullPath, '', 'utf-8')
226
+ }
227
+ res.setHeader('Content-Type', 'application/json')
228
+ res.end(JSON.stringify({ ok: true }))
229
+ } catch (e) {
230
+ res.statusCode = 500; res.end(e.message)
231
+ }
212
232
  })
213
233
 
214
234
  // POST /api/delete — 删除文件或文件夹
215
- server.middlewares.use('/api/delete', (req, res) => {
235
+ server.middlewares.use('/api/delete', async (req, res) => {
216
236
  if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
217
- let body = ''
218
- req.on('data', chunk => { body += chunk })
219
- req.on('end', () => {
220
- try {
221
- const { path: filePath } = JSON.parse(body)
222
- if (!filePath) {
223
- res.statusCode = 400; res.end('缺少 path'); return
224
- }
225
- const fullPath = path.resolve(resolvedDocsDir, filePath)
226
- if (!fullPath.startsWith(resolvedDocsDir)) {
227
- res.statusCode = 403; res.end('禁止访问'); return
228
- }
229
- if (!fs.existsSync(fullPath)) {
230
- res.statusCode = 404; res.end('文件不存在'); return
231
- }
232
- fs.rmSync(fullPath, { recursive: true, force: true })
233
- res.setHeader('Content-Type', 'application/json')
234
- res.end(JSON.stringify({ ok: true }))
235
- } catch (e) {
236
- res.statusCode = 500; res.end(e.message)
237
+ try {
238
+ const { path: filePath } = await parseJsonBody(req)
239
+ if (!filePath) {
240
+ res.statusCode = 400; res.end('缺少 path'); return
237
241
  }
238
- })
242
+ const fullPath = safePath(filePath)
243
+ if (!fullPath) {
244
+ res.statusCode = 403; res.end('禁止访问'); return
245
+ }
246
+ if (!fs.existsSync(fullPath)) {
247
+ res.statusCode = 404; res.end('文件不存在'); return
248
+ }
249
+ fs.rmSync(fullPath, { recursive: true, force: true })
250
+ res.setHeader('Content-Type', 'application/json')
251
+ res.end(JSON.stringify({ ok: true }))
252
+ } catch (e) {
253
+ res.statusCode = 500; res.end(e.message)
254
+ }
239
255
  })
240
256
 
241
257
  // POST /api/rename — 重命名文件或文件夹
242
- server.middlewares.use('/api/rename', (req, res) => {
258
+ server.middlewares.use('/api/rename', async (req, res) => {
243
259
  if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
244
- let body = ''
245
- req.on('data', chunk => { body += chunk })
246
- req.on('end', () => {
247
- try {
248
- const { oldPath, newPath } = JSON.parse(body)
249
- if (!oldPath || !newPath) {
250
- res.statusCode = 400; res.end('缺少 oldPath 或 newPath'); return
251
- }
252
- const fullOld = path.resolve(resolvedDocsDir, oldPath)
253
- const fullNew = path.resolve(resolvedDocsDir, newPath)
254
- if (!fullOld.startsWith(resolvedDocsDir) || !fullNew.startsWith(resolvedDocsDir)) {
255
- res.statusCode = 403; res.end('禁止访问'); return
256
- }
257
- if (!fs.existsSync(fullOld)) {
258
- res.statusCode = 404; res.end('源文件不存在'); return
259
- }
260
- if (fs.existsSync(fullNew)) {
261
- res.statusCode = 409; res.end('目标名称已存在'); return
262
- }
263
- fs.mkdirSync(path.dirname(fullNew), { recursive: true })
264
- fs.renameSync(fullOld, fullNew)
265
- res.setHeader('Content-Type', 'application/json')
266
- res.end(JSON.stringify({ ok: true }))
267
- } catch (e) {
268
- res.statusCode = 500; res.end(e.message)
260
+ try {
261
+ const { oldPath, newPath } = await parseJsonBody(req)
262
+ if (!oldPath || !newPath) {
263
+ res.statusCode = 400; res.end('缺少 oldPath 或 newPath'); return
269
264
  }
270
- })
265
+ const fullOld = safePath(oldPath)
266
+ const fullNew = safePath(newPath)
267
+ if (!fullOld || !fullNew) {
268
+ res.statusCode = 403; res.end('禁止访问'); return
269
+ }
270
+ if (!fs.existsSync(fullOld)) {
271
+ res.statusCode = 404; res.end('源文件不存在'); return
272
+ }
273
+ if (fs.existsSync(fullNew)) {
274
+ res.statusCode = 409; res.end('目标名称已存在'); return
275
+ }
276
+ fs.mkdirSync(path.dirname(fullNew), { recursive: true })
277
+ fs.renameSync(fullOld, fullNew)
278
+ res.setHeader('Content-Type', 'application/json')
279
+ res.end(JSON.stringify({ ok: true }))
280
+ } catch (e) {
281
+ res.statusCode = 500; res.end(e.message)
282
+ }
271
283
  })
272
284
 
273
285
  // POST /api/reorder — 批量重编号
274
- server.middlewares.use('/api/reorder', (req, res) => {
286
+ server.middlewares.use('/api/reorder', async (req, res) => {
275
287
  if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
276
- let body = ''
277
- req.on('data', chunk => { body += chunk })
278
- req.on('end', () => {
279
- try {
280
- const { items } = JSON.parse(body)
281
- if (!Array.isArray(items) || items.length === 0) {
282
- res.statusCode = 400; res.end('缺少 items'); return
283
- }
284
- for (const { oldPath, newPath } of items) {
285
- const fullOld = path.resolve(resolvedDocsDir, oldPath)
286
- const fullNew = path.resolve(resolvedDocsDir, newPath)
287
- if (!fullOld.startsWith(resolvedDocsDir) || !fullNew.startsWith(resolvedDocsDir)) {
288
- res.statusCode = 403; res.end('禁止访问'); return
289
- }
290
- }
291
- const tempMap = []
292
- for (let i = 0; i < items.length; i++) {
293
- const { oldPath } = items[i]
294
- const fullOld = path.resolve(resolvedDocsDir, oldPath)
295
- if (!fs.existsSync(fullOld)) continue
296
- const tempName = fullOld + `.__reorder_tmp_${i}__`
297
- fs.renameSync(fullOld, tempName)
298
- tempMap.push({ temp: tempName, newPath: items[i].newPath })
299
- }
300
- for (const { temp, newPath: np } of tempMap) {
301
- const fullNew = path.resolve(resolvedDocsDir, np)
302
- fs.mkdirSync(path.dirname(fullNew), { recursive: true })
303
- fs.renameSync(temp, fullNew)
288
+ try {
289
+ const { items } = await parseJsonBody(req)
290
+ if (!Array.isArray(items) || items.length === 0) {
291
+ res.statusCode = 400; res.end('缺少 items'); return
292
+ }
293
+ // 验证所有路径
294
+ for (const { oldPath, newPath } of items) {
295
+ if (!safePath(oldPath) || !safePath(newPath)) {
296
+ res.statusCode = 403; res.end('禁止访问'); return
304
297
  }
305
- res.setHeader('Content-Type', 'application/json')
306
- res.end(JSON.stringify({ ok: true }))
307
- } catch (e) {
308
- res.statusCode = 500; res.end(e.message)
309
298
  }
310
- })
299
+ // 先全部重命名为临时文件,再重命名为目标文件(避免冲突)
300
+ const tempMap = []
301
+ for (let i = 0; i < items.length; i++) {
302
+ const { oldPath } = items[i]
303
+ const fullOld = safePath(oldPath)
304
+ if (!fullOld || !fs.existsSync(fullOld)) continue
305
+ const tempName = fullOld + `.__reorder_tmp_${i}__`
306
+ fs.renameSync(fullOld, tempName)
307
+ tempMap.push({ temp: tempName, newPath: items[i].newPath })
308
+ }
309
+ for (const { temp, newPath: np } of tempMap) {
310
+ const fullNew = safePath(np)
311
+ if (!fullNew) continue
312
+ fs.mkdirSync(path.dirname(fullNew), { recursive: true })
313
+ fs.renameSync(temp, fullNew)
314
+ }
315
+ res.setHeader('Content-Type', 'application/json')
316
+ res.end(JSON.stringify({ ok: true }))
317
+ } catch (e) {
318
+ res.statusCode = 500; res.end(e.message)
319
+ }
311
320
  })
312
321
 
313
322
  // POST /api/move — 移动文件/文件夹
314
- server.middlewares.use('/api/move', (req, res) => {
323
+ server.middlewares.use('/api/move', async (req, res) => {
315
324
  if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
316
- let body = ''
317
- req.on('data', chunk => { body += chunk })
318
- req.on('end', () => {
319
- try {
320
- const { oldPath, newPath } = JSON.parse(body)
321
- if (!oldPath || !newPath) {
322
- res.statusCode = 400; res.end('缺少 oldPath 或 newPath'); return
323
- }
324
- const fullOld = path.resolve(resolvedDocsDir, oldPath)
325
- const fullNew = path.resolve(resolvedDocsDir, newPath)
326
- if (!fullOld.startsWith(resolvedDocsDir) || !fullNew.startsWith(resolvedDocsDir)) {
327
- res.statusCode = 403; res.end('禁止访问'); return
328
- }
329
- if (!fs.existsSync(fullOld)) {
330
- res.statusCode = 404; res.end('源文件不存在'); return
331
- }
332
- fs.mkdirSync(path.dirname(fullNew), { recursive: true })
333
- fs.renameSync(fullOld, fullNew)
334
- res.setHeader('Content-Type', 'application/json')
335
- res.end(JSON.stringify({ ok: true }))
336
- } catch (e) {
337
- res.statusCode = 500; res.end(e.message)
325
+ try {
326
+ const { oldPath, newPath } = await parseJsonBody(req)
327
+ if (!oldPath || !newPath) {
328
+ res.statusCode = 400; res.end('缺少 oldPath 或 newPath'); return
338
329
  }
339
- })
330
+ const fullOld = safePath(oldPath)
331
+ const fullNew = safePath(newPath)
332
+ if (!fullOld || !fullNew) {
333
+ res.statusCode = 403; res.end('禁止访问'); return
334
+ }
335
+ if (!fs.existsSync(fullOld)) {
336
+ res.statusCode = 404; res.end('源文件不存在'); return
337
+ }
338
+ fs.mkdirSync(path.dirname(fullNew), { recursive: true })
339
+ fs.renameSync(fullOld, fullNew)
340
+ res.setHeader('Content-Type', 'application/json')
341
+ res.end(JSON.stringify({ ok: true }))
342
+ } catch (e) {
343
+ res.statusCode = 500; res.end(e.message)
344
+ }
340
345
  })
341
346
 
342
347
  // POST /api/save — 保存文件内容
343
- server.middlewares.use('/api/save', (req, res) => {
348
+ server.middlewares.use('/api/save', async (req, res) => {
344
349
  if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
345
- let body = ''
346
- req.on('data', chunk => { body += chunk })
347
- req.on('end', () => {
348
- try {
349
- const { path: filePath, content } = JSON.parse(body)
350
- if (!filePath || content == null) {
351
- res.statusCode = 400; res.end('缺少 path 或 content'); return
352
- }
353
- const fullPath = path.resolve(resolvedDocsDir, filePath)
354
- if (!fullPath.startsWith(resolvedDocsDir)) {
355
- res.statusCode = 403; res.end('禁止访问'); return
356
- }
357
- fs.mkdirSync(path.dirname(fullPath), { recursive: true })
358
- fs.writeFileSync(fullPath, content, 'utf-8')
359
- res.setHeader('Content-Type', 'application/json')
360
- res.end(JSON.stringify({ ok: true }))
361
- } catch (e) {
362
- res.statusCode = 500; res.end(e.message)
350
+ try {
351
+ const { path: filePath, content } = await parseJsonBody(req)
352
+ if (!filePath || content == null) {
353
+ res.statusCode = 400; res.end('缺少 path 或 content'); return
363
354
  }
364
- })
355
+ const fullPath = safePath(filePath)
356
+ if (!fullPath) {
357
+ res.statusCode = 403; res.end('禁止访问'); return
358
+ }
359
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true })
360
+ fs.writeFileSync(fullPath, content, 'utf-8')
361
+ res.setHeader('Content-Type', 'application/json')
362
+ res.end(JSON.stringify({ ok: true }))
363
+ } catch (e) {
364
+ res.statusCode = 500; res.end(e.message)
365
+ }
365
366
  })
366
367
  }
367
368
  }
package/vite.config.js CHANGED
@@ -9,6 +9,10 @@ export default defineConfig({
9
9
  port: config.defaultPort
10
10
  },
11
11
  optimizeDeps: {
12
- include: ['vue', 'marked', 'mermaid']
12
+ include: [
13
+ 'vue',
14
+ 'marked',
15
+ 'mermaid',
16
+ ],
13
17
  }
14
18
  })
@@ -1,90 +0,0 @@
1
- <template>
2
- <teleport to="body">
3
- <div v-if="visible" class="search-overlay" @click.self="$emit('close')">
4
- <div class="search-panel">
5
- <div class="search-input-wrapper">
6
- <Search :size="18" class="search-icon" />
7
- <input
8
- ref="inputRef"
9
- type="text"
10
- class="search-input"
11
- placeholder="搜索文档..."
12
- :value="query"
13
- @input="$emit('search', $event.target.value)"
14
- @keydown.escape="$emit('close')"
15
- @keydown.enter="handleEnter"
16
- @keydown.up.prevent="moveSelection(-1)"
17
- @keydown.down.prevent="moveSelection(1)"
18
- />
19
- <kbd class="search-kbd">ESC</kbd>
20
- </div>
21
- <div class="search-results" v-if="query">
22
- <div v-if="results.length === 0" class="search-empty">
23
- 没有找到相关文档
24
- </div>
25
- <div
26
- v-for="(item, index) in results"
27
- :key="item.key"
28
- :class="['search-result-item', { active: index === selectedIndex }]"
29
- @click="selectResult(item)"
30
- @mouseenter="selectedIndex = index"
31
- >
32
- <FileText :size="16" class="result-icon" />
33
- <span class="result-title">{{ item.title }}</span>
34
- </div>
35
- </div>
36
- <div class="search-footer" v-if="!query">
37
- <span class="search-tip">输入关键词搜索文档内容</span>
38
- </div>
39
- </div>
40
- </div>
41
- </teleport>
42
- </template>
43
-
44
- <script setup>
45
- import { ref, watch, nextTick } from 'vue'
46
- import { Search, FileText } from 'lucide-vue-next'
47
-
48
- const props = defineProps({
49
- visible: { type: Boolean, default: false },
50
- query: { type: String, default: '' },
51
- results: { type: Array, default: () => [] }
52
- })
53
-
54
- const emit = defineEmits(['close', 'search', 'select'])
55
- const inputRef = ref(null)
56
- const selectedIndex = ref(0)
57
-
58
- // 面板打开时自动聚焦输入框
59
- watch(() => props.visible, (val) => {
60
- if (val) {
61
- selectedIndex.value = 0
62
- nextTick(() => inputRef.value?.focus())
63
- }
64
- })
65
-
66
- // 搜索结果变化时重置选中
67
- watch(() => props.results, () => {
68
- selectedIndex.value = 0
69
- })
70
-
71
- // 键盘上下移动选中项
72
- function moveSelection(delta) {
73
- const len = props.results.length
74
- if (len === 0) return
75
- selectedIndex.value = (selectedIndex.value + delta + len) % len
76
- }
77
-
78
- // 回车选中
79
- function handleEnter() {
80
- if (props.results.length > 0) {
81
- selectResult(props.results[selectedIndex.value])
82
- }
83
- }
84
-
85
- // 选中结果
86
- function selectResult(item) {
87
- emit('select', item.key)
88
- emit('close')
89
- }
90
- </script>
@@ -1,177 +0,0 @@
1
- <template>
2
- <teleport to="body">
3
- <div
4
- v-if="show"
5
- ref="menuRef"
6
- class="table-bubble-menu"
7
- :style="menuStyle"
8
- >
9
- <div class="table-bubble-group">
10
- <button class="table-bubble-btn" @click="addColumnBefore" title="在左侧插入列">
11
- <BetweenVerticalStart :size="14" />
12
- </button>
13
- <button class="table-bubble-btn" @click="addColumnAfter" title="在右侧插入列">
14
- <BetweenVerticalEnd :size="14" />
15
- </button>
16
- <button class="table-bubble-btn danger" @click="deleteColumn" title="删除当前列">
17
- <Columns3 :size="14" />
18
- <X :size="10" class="table-bubble-badge" />
19
- </button>
20
- </div>
21
- <div class="table-bubble-divider"></div>
22
- <div class="table-bubble-group">
23
- <button class="table-bubble-btn" @click="addRowBefore" title="在上方插入行">
24
- <BetweenHorizontalStart :size="14" />
25
- </button>
26
- <button class="table-bubble-btn" @click="addRowAfter" title="在下方插入行">
27
- <BetweenHorizontalEnd :size="14" />
28
- </button>
29
- <button class="table-bubble-btn danger" @click="deleteRow" title="删除当前行">
30
- <RowsIcon :size="14" />
31
- <X :size="10" class="table-bubble-badge" />
32
- </button>
33
- </div>
34
- <div class="table-bubble-divider"></div>
35
- <div class="table-bubble-group">
36
- <button class="table-bubble-btn" @click="mergeCells" title="合并单元格">
37
- <TableCellsMerge :size="14" />
38
- </button>
39
- <button class="table-bubble-btn" @click="splitCell" title="拆分单元格">
40
- <TableCellsSplit :size="14" />
41
- </button>
42
- </div>
43
- <div class="table-bubble-divider"></div>
44
- <button class="table-bubble-btn danger" @click="deleteTable" title="删除表格">
45
- <Trash2 :size="14" />
46
- </button>
47
- </div>
48
- </teleport>
49
- </template>
50
-
51
- <script setup>
52
- import { computed, ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
53
- import {
54
- BetweenVerticalStart, BetweenVerticalEnd, Columns3,
55
- BetweenHorizontalStart, BetweenHorizontalEnd, Rows3 as RowsIcon,
56
- TableCellsMerge, TableCellsSplit,
57
- Trash2, X,
58
- } from 'lucide-vue-next'
59
-
60
- const props = defineProps({
61
- editor: { type: Object, required: true }
62
- })
63
-
64
- const menuRef = ref(null)
65
- const menuStyle = ref({})
66
-
67
- const show = computed(() => {
68
- if (!props.editor) return false
69
- if (props.editor.isActive('codeBlock') || props.editor.isActive('mermaidBlock')) return false
70
- return props.editor.isActive('table')
71
- })
72
-
73
- // 计算菜单位置:定位到表格上方
74
- function updatePosition() {
75
- if (!show.value || !props.editor) return
76
-
77
- const { view } = props.editor
78
- const { state } = view
79
-
80
- // 找到当前所在的 table 节点
81
- let tablePos = null
82
- const { $from } = state.selection
83
- for (let d = $from.depth; d > 0; d--) {
84
- if ($from.node(d).type.name === 'table') {
85
- tablePos = $from.start(d) - 1
86
- break
87
- }
88
- }
89
- if (tablePos === null) return
90
-
91
- // 获取表格 DOM 元素的位置
92
- const dom = view.nodeDOM(tablePos)
93
- if (!dom) return
94
-
95
- const tableRect = dom.getBoundingClientRect()
96
- const menuEl = menuRef.value
97
- if (!menuEl) return
98
-
99
- const menuWidth = menuEl.offsetWidth
100
- // 水平居中于表格上方
101
- let left = tableRect.left + (tableRect.width - menuWidth) / 2
102
- let top = tableRect.top - menuEl.offsetHeight - 8
103
-
104
- // 边界修正
105
- if (left < 8) left = 8
106
- if (left + menuWidth > window.innerWidth - 8) left = window.innerWidth - menuWidth - 8
107
- if (top < 8) top = tableRect.bottom + 8 // 表格上方放不下就放下方
108
-
109
- menuStyle.value = {
110
- position: 'fixed',
111
- left: `${left}px`,
112
- top: `${top}px`,
113
- zIndex: 100,
114
- }
115
- }
116
-
117
- // 监听 show 变化和编辑器事务来更新位置
118
- watch(show, async (val) => {
119
- if (val) {
120
- await nextTick()
121
- updatePosition()
122
- }
123
- })
124
-
125
- let updateTimer = null
126
- function onTransaction() {
127
- if (!show.value) return
128
- if (updateTimer) cancelAnimationFrame(updateTimer)
129
- updateTimer = requestAnimationFrame(updatePosition)
130
- }
131
-
132
- onMounted(() => {
133
- if (props.editor) {
134
- props.editor.on('transaction', onTransaction)
135
- // 滚动时也更新位置
136
- const scrollEl = document.querySelector('.editor-content')
137
- if (scrollEl) scrollEl.addEventListener('scroll', updatePosition, { passive: true })
138
- }
139
- })
140
-
141
- onUnmounted(() => {
142
- if (props.editor) {
143
- props.editor.off('transaction', onTransaction)
144
- }
145
- const scrollEl = document.querySelector('.editor-content')
146
- if (scrollEl) scrollEl.removeEventListener('scroll', updatePosition)
147
- if (updateTimer) cancelAnimationFrame(updateTimer)
148
- })
149
-
150
- function addColumnBefore() {
151
- props.editor.chain().focus().addColumnBefore().run()
152
- }
153
- function addColumnAfter() {
154
- props.editor.chain().focus().addColumnAfter().run()
155
- }
156
- function deleteColumn() {
157
- props.editor.chain().focus().deleteColumn().run()
158
- }
159
- function addRowBefore() {
160
- props.editor.chain().focus().addRowBefore().run()
161
- }
162
- function addRowAfter() {
163
- props.editor.chain().focus().addRowAfter().run()
164
- }
165
- function deleteRow() {
166
- props.editor.chain().focus().deleteRow().run()
167
- }
168
- function mergeCells() {
169
- props.editor.chain().focus().mergeCells().run()
170
- }
171
- function splitCell() {
172
- props.editor.chain().focus().splitCell().run()
173
- }
174
- function deleteTable() {
175
- props.editor.chain().focus().deleteTable().run()
176
- }
177
- </script>