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
@@ -0,0 +1,670 @@
1
+ /**
2
+ * 表格控件扩展 - Excel 风格行号列头
3
+ * 左侧显示行号(1, 2, 3...),顶部显示列头(A, B, C...),左上角全选按钮
4
+ * 行号/列头常驻显示,点击可选中整行/整列
5
+ * hover 删除按钮时高亮整行/整列(使用 overlay div,因为 ProseMirror 会阻止 td 的 background-color)
6
+ */
7
+ import { Extension } from '@tiptap/vue-3'
8
+ import { CellSelection } from '@tiptap/pm/tables'
9
+
10
+ const ICON_PLUS = `<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="6" y1="2" x2="6" y2="10"/><line x1="2" y1="6" x2="10" y2="6"/></svg>`
11
+ const ICON_TRASH = `<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 3h8M4.5 3V2h3v1M3 3l.5 7h5l.5-7"/></svg>`
12
+
13
+ const EDGE_THRESHOLD = 14
14
+ const GUTTER = 28
15
+
16
+ function findClosestTable(dom) {
17
+ let el = dom
18
+ while (el && el !== document.body) {
19
+ if (el.tagName === 'TABLE') return el
20
+ if (el.classList && el.classList.contains('tableWrapper')) return el.querySelector('table')
21
+ el = el.parentElement
22
+ }
23
+ return null
24
+ }
25
+
26
+ function focusCellAt(view, editor, table, rowIdx, colIdx) {
27
+ const rows = table.querySelectorAll('tr')
28
+ if (!rows[rowIdx]) return
29
+ const cells = rows[rowIdx].children
30
+ if (!cells[colIdx]) return
31
+ const pos = view.posAtDOM(cells[colIdx], 0)
32
+ if (pos != null) editor.commands.setTextSelection(pos)
33
+ }
34
+
35
+ function createBtn(cls, icon, title) {
36
+ const b = document.createElement('button')
37
+ b.className = cls
38
+ b.innerHTML = icon
39
+ b.title = title
40
+ return b
41
+ }
42
+
43
+ /**
44
+ * 通过 CellSelection 全选一行
45
+ */
46
+ function selectRow(view, editor, table, rowIdx) {
47
+ const rows = table.querySelectorAll('tr')
48
+ if (!rows[rowIdx]) return
49
+ const cells = rows[rowIdx].children
50
+ if (!cells.length) return
51
+ // posAtDOM 返回单元格内部的位置,resolve 后用 before() 找到单元格节点的起始位置
52
+ const firstPos = view.posAtDOM(cells[0], 0)
53
+ const lastPos = view.posAtDOM(cells[cells.length - 1], 0)
54
+ if (firstPos == null || lastPos == null) return
55
+ try {
56
+ const $first = view.state.doc.resolve(firstPos)
57
+ const $last = view.state.doc.resolve(lastPos)
58
+ // 向上找到 depth 对应 tableCell/tableHeader 的层级
59
+ const firstCellPos = findCellPos($first)
60
+ const lastCellPos = findCellPos($last)
61
+ if (firstCellPos == null || lastCellPos == null) return
62
+ const sel = CellSelection.create(view.state.doc, firstCellPos, lastCellPos)
63
+ view.dispatch(view.state.tr.setSelection(sel))
64
+ } catch (e) {
65
+ console.warn('selectRow failed:', e)
66
+ }
67
+ }
68
+
69
+ /**
70
+ * 通过 CellSelection 全选一列
71
+ */
72
+ function selectCol(view, editor, table, colIdx) {
73
+ const rows = table.querySelectorAll('tr')
74
+ if (!rows.length) return
75
+ const firstCell = rows[0].children[colIdx]
76
+ const lastCell = rows[rows.length - 1].children[colIdx]
77
+ if (!firstCell || !lastCell) return
78
+ const firstPos = view.posAtDOM(firstCell, 0)
79
+ const lastPos = view.posAtDOM(lastCell, 0)
80
+ if (firstPos == null || lastPos == null) return
81
+ try {
82
+ const $first = view.state.doc.resolve(firstPos)
83
+ const $last = view.state.doc.resolve(lastPos)
84
+ const firstCellPos = findCellPos($first)
85
+ const lastCellPos = findCellPos($last)
86
+ if (firstCellPos == null || lastCellPos == null) return
87
+ const sel = CellSelection.create(view.state.doc, firstCellPos, lastCellPos)
88
+ view.dispatch(view.state.tr.setSelection(sel))
89
+ } catch (e) {
90
+ console.warn('selectCol failed:', e)
91
+ }
92
+ }
93
+
94
+ /**
95
+ * 从 resolved position 向上查找 tableCell 或 tableHeader 节点的起始位置
96
+ */
97
+ function findCellPos($pos) {
98
+ for (let d = $pos.depth; d > 0; d--) {
99
+ const node = $pos.node(d)
100
+ if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
101
+ return $pos.before(d)
102
+ }
103
+ }
104
+ return null
105
+ }
106
+
107
+ // ===== 高亮 overlay =====
108
+ let _highlightOverlay = null
109
+
110
+ function clearHighlights() {
111
+ if (_highlightOverlay && _highlightOverlay.parentElement) {
112
+ _highlightOverlay.parentElement.removeChild(_highlightOverlay)
113
+ }
114
+ _highlightOverlay = null
115
+ }
116
+
117
+ /**
118
+ * 创建高亮 overlay,覆盖指定的行/列/整个表格
119
+ * @param {'row'|'col'|'table'} type
120
+ * @param {HTMLTableElement} table
121
+ * @param {number} index - 行号或列号(type='table' 时忽略)
122
+ */
123
+ function showHighlightOverlay(type, table, index) {
124
+ clearHighlights()
125
+ const wrapper = table.closest('.tableWrapper') || table.parentElement
126
+ if (!wrapper) return
127
+ const wrapperRect = wrapper.getBoundingClientRect()
128
+ const tableRect = table.getBoundingClientRect()
129
+ const rows = Array.from(table.querySelectorAll('tr'))
130
+ if (!rows.length) return
131
+
132
+ const overlay = document.createElement('div')
133
+ overlay.className = 'tc-highlight-overlay'
134
+
135
+ if (type === 'table') {
136
+ overlay.style.cssText = `
137
+ position:absolute; pointer-events:none; z-index:10;
138
+ left:${tableRect.left - wrapperRect.left}px;
139
+ top:${tableRect.top - wrapperRect.top}px;
140
+ width:${tableRect.width}px;
141
+ height:${tableRect.height}px;
142
+ `
143
+ } else if (type === 'row' && rows[index]) {
144
+ const rr = rows[index].getBoundingClientRect()
145
+ overlay.style.cssText = `
146
+ position:absolute; pointer-events:none; z-index:10;
147
+ left:${tableRect.left - wrapperRect.left}px;
148
+ top:${rr.top - wrapperRect.top}px;
149
+ width:${tableRect.width}px;
150
+ height:${rr.height}px;
151
+ `
152
+ } else if (type === 'col') {
153
+ const firstRowCells = Array.from(rows[0].children)
154
+ if (!firstRowCells[index]) return
155
+ const cr = firstRowCells[index].getBoundingClientRect()
156
+ overlay.style.cssText = `
157
+ position:absolute; pointer-events:none; z-index:10;
158
+ left:${cr.left - wrapperRect.left}px;
159
+ top:${tableRect.top - wrapperRect.top}px;
160
+ width:${cr.width}px;
161
+ height:${tableRect.height}px;
162
+ `
163
+ } else {
164
+ return
165
+ }
166
+
167
+ wrapper.appendChild(overlay)
168
+ _highlightOverlay = overlay
169
+ }
170
+
171
+ // 标记:通过行号/列头/全选角标触发的选中,不弹浮动菜单
172
+ let _headerSelectFlag = false
173
+
174
+ /**
175
+ * 列索引转 Excel 风格字母(0->A, 1->B, ..., 25->Z, 26->AA)
176
+ */
177
+ function colToLetter(idx) {
178
+ let s = ''
179
+ let n = idx
180
+ while (n >= 0) {
181
+ s = String.fromCharCode(65 + (n % 26)) + s
182
+ n = Math.floor(n / 26) - 1
183
+ }
184
+ return s
185
+ }
186
+
187
+ /**
188
+ * 渲染 Excel 风格的行号列头(常驻)+ hover 时的删除/插入按钮
189
+ */
190
+ function renderHoverControls(view, table, container, editor, mouseX, mouseY) {
191
+ clearHighlights()
192
+ container.innerHTML = ''
193
+
194
+ const rows = Array.from(table.querySelectorAll('tr'))
195
+ if (!rows.length) return
196
+ const firstRowCells = Array.from(rows[0].children)
197
+ const tableRect = table.getBoundingClientRect()
198
+ const wrapper = container.parentElement
199
+ const wrapperRect = wrapper.getBoundingClientRect()
200
+
201
+ container.style.cssText = `
202
+ position:absolute; left:0; top:0; right:0; bottom:0;
203
+ pointer-events:none; z-index:15;
204
+ `
205
+
206
+ const tLeft = tableRect.left - wrapperRect.left
207
+ const tTop = tableRect.top - wrapperRect.top
208
+
209
+ function isMouseOverBtn(btn) {
210
+ const r = btn.getBoundingClientRect()
211
+ return mouseX >= r.left - 2 && mouseX <= r.right + 2 && mouseY >= r.top - 2 && mouseY <= r.bottom + 2
212
+ }
213
+
214
+ // --- 1. 左上角全选角标 ---
215
+ const cornerBtn = document.createElement('div')
216
+ cornerBtn.className = 'tc-corner-btn'
217
+ cornerBtn.title = '选择整个表格'
218
+ cornerBtn.style.cssText = `
219
+ left:${tLeft - GUTTER}px;
220
+ top:${tTop - GUTTER}px;
221
+ width:${GUTTER - 1}px;
222
+ height:${GUTTER - 1}px;
223
+ `
224
+ // 左上角小三角
225
+ const triangle = document.createElement('div')
226
+ triangle.className = 'tc-corner-triangle'
227
+ cornerBtn.appendChild(triangle)
228
+ cornerBtn.addEventListener('click', (e) => {
229
+ e.preventDefault(); e.stopPropagation()
230
+ _headerSelectFlag = true
231
+ // 全选表格:选中从第一个到最后一个单元格
232
+ const firstCell = rows[0].children[0]
233
+ const lastRow = rows[rows.length - 1]
234
+ const lastCell = lastRow.children[lastRow.children.length - 1]
235
+ if (firstCell && lastCell) {
236
+ const firstPos = view.posAtDOM(firstCell, 0)
237
+ const lastPos = view.posAtDOM(lastCell, 0)
238
+ if (firstPos != null && lastPos != null) {
239
+ try {
240
+ const $first = view.state.doc.resolve(firstPos)
241
+ const $last = view.state.doc.resolve(lastPos)
242
+ const firstCellPos = findCellPos($first)
243
+ const lastCellPos = findCellPos($last)
244
+ if (firstCellPos != null && lastCellPos != null) {
245
+ const sel = CellSelection.create(view.state.doc, firstCellPos, lastCellPos)
246
+ view.dispatch(view.state.tr.setSelection(sel))
247
+ }
248
+ } catch (err) { console.warn('selectAll failed:', err) }
249
+ }
250
+ }
251
+ })
252
+ cornerBtn.addEventListener('mouseenter', () => showHighlightOverlay('table', table))
253
+ cornerBtn.addEventListener('mouseleave', clearHighlights)
254
+ container.appendChild(cornerBtn)
255
+
256
+ // --- 2. 列头(A, B, C...)常驻显示 ---
257
+ for (let c = 0; c < firstRowCells.length; c++) {
258
+ const cr = firstRowCells[c].getBoundingClientRect()
259
+ const colHeader = document.createElement('div')
260
+ colHeader.className = 'tc-col-header'
261
+ colHeader.textContent = colToLetter(c)
262
+ colHeader.title = '选择整列'
263
+ colHeader.style.cssText = `
264
+ left:${cr.left - wrapperRect.left}px;
265
+ top:${tTop - GUTTER}px;
266
+ width:${cr.width}px;
267
+ height:${GUTTER - 1}px;
268
+ `
269
+ const ci = c
270
+ colHeader.addEventListener('click', (e) => {
271
+ e.preventDefault(); e.stopPropagation()
272
+ _headerSelectFlag = true
273
+ selectCol(view, editor, table, ci)
274
+ })
275
+ colHeader.addEventListener('mouseenter', () => showHighlightOverlay('col', table, ci))
276
+ colHeader.addEventListener('mouseleave', clearHighlights)
277
+ container.appendChild(colHeader)
278
+ }
279
+
280
+ // --- 3. 行号(1, 2, 3...)常驻显示 ---
281
+ for (let r = 0; r < rows.length; r++) {
282
+ const rr = rows[r].getBoundingClientRect()
283
+ const rowHeader = document.createElement('div')
284
+ rowHeader.className = 'tc-row-header'
285
+ rowHeader.textContent = String(r + 1)
286
+ rowHeader.title = '选择整行'
287
+ rowHeader.style.cssText = `
288
+ left:${tLeft - GUTTER}px;
289
+ top:${rr.top - wrapperRect.top}px;
290
+ width:${GUTTER - 1}px;
291
+ height:${rr.height}px;
292
+ `
293
+ const ri = r
294
+ rowHeader.addEventListener('click', (e) => {
295
+ e.preventDefault(); e.stopPropagation()
296
+ _headerSelectFlag = true
297
+ selectRow(view, editor, table, ri)
298
+ })
299
+ rowHeader.addEventListener('mouseenter', () => showHighlightOverlay('row', table, ri))
300
+ rowHeader.addEventListener('mouseleave', clearHighlights)
301
+ container.appendChild(rowHeader)
302
+ }
303
+
304
+ // --- 找到鼠标所在的行和列 ---
305
+ let hoverRowIdx = -1, hoverColIdx = -1
306
+ for (let r = 0; r < rows.length; r++) {
307
+ const rr = rows[r].getBoundingClientRect()
308
+ if (mouseY >= rr.top && mouseY <= rr.bottom) { hoverRowIdx = r; break }
309
+ }
310
+ for (let c = 0; c < firstRowCells.length; c++) {
311
+ const cr = firstRowCells[c].getBoundingClientRect()
312
+ if (mouseX >= cr.left && mouseX <= cr.right) { hoverColIdx = c; break }
313
+ }
314
+
315
+ // --- 4. hover 行时高亮对应行号 ---
316
+ if (hoverRowIdx >= 0) {
317
+ const headers = container.querySelectorAll('.tc-row-header')
318
+ if (headers[hoverRowIdx]) headers[hoverRowIdx].classList.add('tc-header-hover')
319
+ }
320
+ if (hoverColIdx >= 0) {
321
+ const headers = container.querySelectorAll('.tc-col-header')
322
+ if (headers[hoverColIdx]) headers[hoverColIdx].classList.add('tc-header-hover')
323
+ }
324
+
325
+ // --- 5. 当前行的删除按钮(左侧,行号左边) ---
326
+ if (hoverRowIdx >= 0) {
327
+ const rr = rows[hoverRowIdx].getBoundingClientRect()
328
+ const btn = createBtn('tc-btn tc-del-row tc-visible', ICON_TRASH, '删除此行')
329
+ btn.style.left = `${tLeft - GUTTER - 22}px`
330
+ btn.style.top = `${rr.top - wrapperRect.top + rr.height / 2 - 10}px`
331
+ const ri = hoverRowIdx
332
+ btn.addEventListener('mouseenter', () => showHighlightOverlay('row', table, ri))
333
+ btn.addEventListener('mouseleave', clearHighlights)
334
+ btn.addEventListener('click', (e) => {
335
+ e.preventDefault(); e.stopPropagation()
336
+ focusCellAt(view, editor, table, ri, 0)
337
+ setTimeout(() => editor.chain().focus().deleteRow().run(), 10)
338
+ })
339
+ container.appendChild(btn)
340
+ }
341
+
342
+ // --- 6. 当前列的删除按钮(顶部,列头上面) ---
343
+ if (hoverColIdx >= 0) {
344
+ const cr = firstRowCells[hoverColIdx].getBoundingClientRect()
345
+ const btn = createBtn('tc-btn tc-del-col tc-visible', ICON_TRASH, '删除此列')
346
+ btn.style.left = `${cr.left - wrapperRect.left + cr.width / 2 - 10}px`
347
+ btn.style.top = `${tTop - GUTTER - 22}px`
348
+ const ci = hoverColIdx
349
+ btn.addEventListener('mouseenter', () => showHighlightOverlay('col', table, ci))
350
+ btn.addEventListener('mouseleave', clearHighlights)
351
+ btn.addEventListener('click', (e) => {
352
+ e.preventDefault(); e.stopPropagation()
353
+ focusCellAt(view, editor, table, 0, ci)
354
+ setTimeout(() => editor.chain().focus().deleteColumn().run(), 10)
355
+ })
356
+ container.appendChild(btn)
357
+ }
358
+
359
+ // --- 按钮创建完毕后,检查鼠标是否已在某个删除按钮上,立即触发高亮 ---
360
+ const allBtns = container.querySelectorAll('.tc-del-row, .tc-del-col')
361
+ for (const btn of allBtns) {
362
+ if (isMouseOverBtn(btn)) {
363
+ if (btn.classList.contains('tc-del-row') && hoverRowIdx >= 0) {
364
+ showHighlightOverlay('row', table, hoverRowIdx)
365
+ } else if (btn.classList.contains('tc-del-col') && hoverColIdx >= 0) {
366
+ showHighlightOverlay('col', table, hoverColIdx)
367
+ }
368
+ break
369
+ }
370
+ }
371
+
372
+ // --- 7. 靠近行间线时显示"+"插入行 ---
373
+ for (let i = 0; i <= rows.length; i++) {
374
+ let lineY
375
+ if (i === 0) lineY = rows[0].getBoundingClientRect().top
376
+ else if (i === rows.length) lineY = rows[i - 1].getBoundingClientRect().bottom
377
+ else {
378
+ const p = rows[i - 1].getBoundingClientRect()
379
+ const n = rows[i].getBoundingClientRect()
380
+ lineY = (p.bottom + n.top) / 2
381
+ }
382
+ if (Math.abs(mouseY - lineY) <= EDGE_THRESHOLD) {
383
+ const btn = createBtn('tc-btn tc-add-row tc-visible', ICON_PLUS, '插入行')
384
+ btn.style.left = `${tLeft - GUTTER - 22}px`
385
+ btn.style.top = `${lineY - wrapperRect.top - 10}px`
386
+ const ri = i
387
+ btn.addEventListener('click', (e) => {
388
+ e.preventDefault(); e.stopPropagation()
389
+ if (ri === rows.length) {
390
+ focusCellAt(view, editor, table, ri - 1, 0)
391
+ setTimeout(() => editor.chain().focus().addRowAfter().run(), 10)
392
+ } else {
393
+ focusCellAt(view, editor, table, ri, 0)
394
+ setTimeout(() => editor.chain().focus().addRowBefore().run(), 10)
395
+ }
396
+ })
397
+ container.appendChild(btn)
398
+ break
399
+ }
400
+ }
401
+
402
+ // --- 8. 靠近列间线时显示"+"插入列 ---
403
+ for (let i = 0; i <= firstRowCells.length; i++) {
404
+ let lineX
405
+ if (i === 0) lineX = firstRowCells[0].getBoundingClientRect().left
406
+ else if (i === firstRowCells.length) lineX = firstRowCells[i - 1].getBoundingClientRect().right
407
+ else {
408
+ const p = firstRowCells[i - 1].getBoundingClientRect()
409
+ const n = firstRowCells[i].getBoundingClientRect()
410
+ lineX = (p.right + n.left) / 2
411
+ }
412
+ if (Math.abs(mouseX - lineX) <= EDGE_THRESHOLD) {
413
+ const btn = createBtn('tc-btn tc-add-col tc-visible', ICON_PLUS, '插入列')
414
+ btn.style.left = `${lineX - wrapperRect.left - 10}px`
415
+ btn.style.top = `${tTop - GUTTER - 22}px`
416
+ const ci = i
417
+ btn.addEventListener('click', (e) => {
418
+ e.preventDefault(); e.stopPropagation()
419
+ if (ci === firstRowCells.length) {
420
+ focusCellAt(view, editor, table, 0, ci - 1)
421
+ setTimeout(() => editor.chain().focus().addColumnAfter().run(), 10)
422
+ } else {
423
+ focusCellAt(view, editor, table, 0, ci)
424
+ setTimeout(() => editor.chain().focus().addColumnBefore().run(), 10)
425
+ }
426
+ })
427
+ container.appendChild(btn)
428
+ break
429
+ }
430
+ }
431
+ }
432
+
433
+ /** 选中单元格后的浮动操作菜单 */
434
+ function renderSelectionMenu(editor) {
435
+ removeSelectionMenu()
436
+ const cells = document.querySelectorAll('.editor-area .tiptap table .selectedCell')
437
+ if (cells.length < 2) return
438
+
439
+ let minL = Infinity, maxR = -Infinity, maxB = -Infinity, minT = Infinity
440
+ cells.forEach(c => {
441
+ const r = c.getBoundingClientRect()
442
+ if (r.left < minL) minL = r.left
443
+ if (r.right > maxR) maxR = r.right
444
+ if (r.bottom > maxB) maxB = r.bottom
445
+ if (r.top < minT) minT = r.top
446
+ })
447
+
448
+ const menu = document.createElement('div')
449
+ menu.className = 'tc-selection-menu'
450
+ const actions = [
451
+ { label: '合并单元格', cmd: () => editor.chain().focus().mergeCells().run() },
452
+ { label: '拆分单元格', cmd: () => editor.chain().focus().splitCell().run() },
453
+ { sep: true },
454
+ { label: '在上方插入行', cmd: () => editor.chain().focus().addRowBefore().run() },
455
+ { label: '在下方插入行', cmd: () => editor.chain().focus().addRowAfter().run() },
456
+ { label: '在左侧插入列', cmd: () => editor.chain().focus().addColumnBefore().run() },
457
+ { label: '在右侧插入列', cmd: () => editor.chain().focus().addColumnAfter().run() },
458
+ { sep: true },
459
+ { label: '删除行', cmd: () => editor.chain().focus().deleteRow().run(), danger: true },
460
+ { label: '删除列', cmd: () => editor.chain().focus().deleteColumn().run(), danger: true },
461
+ { label: '删除表格', cmd: () => editor.chain().focus().deleteTable().run(), danger: true },
462
+ ]
463
+ actions.forEach(item => {
464
+ if (item.sep) { const s = document.createElement('div'); s.className = 'tc-menu-sep'; menu.appendChild(s); return }
465
+ const btn = document.createElement('button')
466
+ btn.className = 'tc-menu-btn' + (item.danger ? ' danger' : '')
467
+ btn.textContent = item.label
468
+ btn.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); item.cmd(); removeSelectionMenu() })
469
+ menu.appendChild(btn)
470
+ })
471
+ menu.style.cssText = `position:fixed;left:${(minL+maxR)/2}px;top:${maxB+6}px;transform:translateX(-50%);z-index:200;`
472
+ document.body.appendChild(menu)
473
+ requestAnimationFrame(() => {
474
+ if (!menu.parentElement) return
475
+ const mr = menu.getBoundingClientRect()
476
+ if (mr.left < 8) menu.style.left = `${8 + mr.width/2}px`
477
+ if (mr.right > window.innerWidth - 8) menu.style.left = `${window.innerWidth - 8 - mr.width/2}px`
478
+ if (mr.bottom > window.innerHeight - 8) menu.style.top = `${minT - mr.height - 6}px`
479
+ })
480
+ }
481
+
482
+ function removeSelectionMenu() {
483
+ const old = document.querySelector('.tc-selection-menu')
484
+ if (old) old.remove()
485
+ }
486
+
487
+ /** 右键上下文菜单 */
488
+ function showContextMenu(editor, view, e) {
489
+ removeContextMenu()
490
+ const table = findClosestTable(e.target)
491
+ if (!table) return
492
+
493
+ const pos = view.posAtCoords({ left: e.clientX, top: e.clientY })
494
+ if (pos) editor.commands.setTextSelection(pos.pos)
495
+
496
+ const menu = document.createElement('div')
497
+ menu.className = 'tc-context-menu'
498
+
499
+ const selectedCells = table.querySelectorAll('.selectedCell')
500
+ const canMerge = selectedCells.length >= 2
501
+ const cell = e.target.closest('td, th')
502
+ const canSplit = cell && (
503
+ (cell.getAttribute('colspan') && parseInt(cell.getAttribute('colspan')) > 1) ||
504
+ (cell.getAttribute('rowspan') && parseInt(cell.getAttribute('rowspan')) > 1)
505
+ )
506
+
507
+ const actions = [
508
+ { label: '合并单元格', cmd: () => editor.chain().focus().mergeCells().run(), disabled: !canMerge },
509
+ { label: '拆分单元格', cmd: () => editor.chain().focus().splitCell().run(), disabled: !canSplit },
510
+ { sep: true },
511
+ { label: '在上方插入行', cmd: () => editor.chain().focus().addRowBefore().run() },
512
+ { label: '在下方插入行', cmd: () => editor.chain().focus().addRowAfter().run() },
513
+ { label: '在左侧插入列', cmd: () => editor.chain().focus().addColumnBefore().run() },
514
+ { label: '在右侧插入列', cmd: () => editor.chain().focus().addColumnAfter().run() },
515
+ { sep: true },
516
+ { label: '删除行', cmd: () => editor.chain().focus().deleteRow().run(), danger: true },
517
+ { label: '删除列', cmd: () => editor.chain().focus().deleteColumn().run(), danger: true },
518
+ { label: '删除表格', cmd: () => editor.chain().focus().deleteTable().run(), danger: true },
519
+ ]
520
+
521
+ actions.forEach(item => {
522
+ if (item.sep) {
523
+ const s = document.createElement('div')
524
+ s.className = 'tc-menu-sep'
525
+ menu.appendChild(s)
526
+ return
527
+ }
528
+ const btn = document.createElement('button')
529
+ btn.className = 'tc-menu-btn' + (item.danger ? ' danger' : '') + (item.disabled ? ' disabled' : '')
530
+ btn.textContent = item.label
531
+ if (item.disabled) {
532
+ btn.setAttribute('disabled', 'true')
533
+ } else {
534
+ btn.addEventListener('mousedown', (ev) => {
535
+ ev.preventDefault()
536
+ ev.stopPropagation()
537
+ item.cmd()
538
+ removeContextMenu()
539
+ })
540
+ }
541
+ menu.appendChild(btn)
542
+ })
543
+
544
+ menu.style.cssText = `position:fixed;left:${e.clientX}px;top:${e.clientY}px;z-index:300;`
545
+ document.body.appendChild(menu)
546
+
547
+ requestAnimationFrame(() => {
548
+ if (!menu.parentElement) return
549
+ const mr = menu.getBoundingClientRect()
550
+ if (mr.right > window.innerWidth - 8) menu.style.left = `${e.clientX - mr.width}px`
551
+ if (mr.bottom > window.innerHeight - 8) menu.style.top = `${e.clientY - mr.height}px`
552
+ })
553
+ }
554
+
555
+ function removeContextMenu() {
556
+ const old = document.querySelector('.tc-context-menu')
557
+ if (old) old.remove()
558
+ }
559
+
560
+ /** Tiptap Extension */
561
+ export const TableControls = Extension.create({
562
+ name: 'tableControls',
563
+ onCreate() {
564
+ const editor = this.editor
565
+ const editorDom = editor.view.dom
566
+ const scrollEl = editorDom.closest('.editor-content')
567
+ let currentTable = null, container = null
568
+
569
+ function cleanup() {
570
+ clearHighlights()
571
+ if (container && container.parentElement) container.parentElement.removeChild(container)
572
+ container = null; currentTable = null
573
+ }
574
+
575
+ const onMouseMove = (e) => {
576
+ const target = e.target
577
+ // 鼠标在控件按钮/行号列头上时,不重建控件
578
+ if (container && container.contains(target)) {
579
+ const btn = target.closest('.tc-btn')
580
+ const isDel = btn && (btn.classList.contains('tc-del-row') || btn.classList.contains('tc-del-col'))
581
+ if (isDel && currentTable) {
582
+ const rows = Array.from(currentTable.querySelectorAll('tr'))
583
+ const firstRowCells = rows.length ? Array.from(rows[0].children) : []
584
+ if (btn.classList.contains('tc-del-row')) {
585
+ const btnY = btn.getBoundingClientRect().top + btn.getBoundingClientRect().height / 2
586
+ for (let r = 0; r < rows.length; r++) {
587
+ const rr = rows[r].getBoundingClientRect()
588
+ if (btnY >= rr.top && btnY <= rr.bottom) {
589
+ showHighlightOverlay('row', currentTable, r)
590
+ break
591
+ }
592
+ }
593
+ } else if (btn.classList.contains('tc-del-col')) {
594
+ const btnX = btn.getBoundingClientRect().left + btn.getBoundingClientRect().width / 2
595
+ for (let c = 0; c < firstRowCells.length; c++) {
596
+ const cr = firstRowCells[c].getBoundingClientRect()
597
+ if (btnX >= cr.left && btnX <= cr.right) {
598
+ showHighlightOverlay('col', currentTable, c)
599
+ break
600
+ }
601
+ }
602
+ }
603
+ } else if (!target.closest('.tc-row-header, .tc-col-header, .tc-corner-btn')) {
604
+ clearHighlights()
605
+ }
606
+ return
607
+ }
608
+
609
+ const table = findClosestTable(target)
610
+ if (!table) { if (currentTable) cleanup(); return }
611
+
612
+ if (table !== currentTable) {
613
+ cleanup()
614
+ currentTable = table
615
+ container = document.createElement('div')
616
+ container.className = 'table-controls-layer'
617
+ const wrapper = table.closest('.tableWrapper') || table.parentElement
618
+ if (wrapper) {
619
+ wrapper.style.position = 'relative'
620
+ wrapper.appendChild(container)
621
+ }
622
+ }
623
+ if (container) renderHoverControls(editor.view, table, container, editor, e.clientX, e.clientY)
624
+ }
625
+
626
+ const onMouseLeave = () => {
627
+ setTimeout(() => {
628
+ const h = document.querySelector('.table-controls-layer:hover, .table-controls-layer *:hover')
629
+ if (!h) { const t = document.querySelector('.editor-area table:hover'); if (!t) cleanup() }
630
+ }, 150)
631
+ }
632
+
633
+ const onScroll = () => { if (currentTable) cleanup() }
634
+
635
+ const onContextMenu = (e) => {
636
+ const table = findClosestTable(e.target)
637
+ if (!table) return
638
+ e.preventDefault()
639
+ showContextMenu(editor, editor.view, e)
640
+ }
641
+
642
+ const onDocClick = () => removeContextMenu()
643
+
644
+ editorDom.addEventListener('mousemove', onMouseMove)
645
+ editorDom.addEventListener('mouseleave', onMouseLeave)
646
+ editorDom.addEventListener('contextmenu', onContextMenu)
647
+ document.addEventListener('mousedown', onDocClick)
648
+ if (scrollEl) scrollEl.addEventListener('scroll', onScroll, { passive: true })
649
+
650
+ const onTransaction = () => {
651
+ const sel = document.querySelectorAll('.editor-area .tiptap table .selectedCell')
652
+ if (sel.length >= 2 && !_headerSelectFlag) requestAnimationFrame(() => renderSelectionMenu(editor))
653
+ else removeSelectionMenu()
654
+ _headerSelectFlag = false
655
+ if (currentTable && !currentTable.isConnected) cleanup()
656
+ }
657
+ editor.on('transaction', onTransaction)
658
+
659
+ this.storage.destroy = () => {
660
+ cleanup(); removeSelectionMenu(); removeContextMenu()
661
+ editorDom.removeEventListener('mousemove', onMouseMove)
662
+ editorDom.removeEventListener('mouseleave', onMouseLeave)
663
+ editorDom.removeEventListener('contextmenu', onContextMenu)
664
+ document.removeEventListener('mousedown', onDocClick)
665
+ if (scrollEl) scrollEl.removeEventListener('scroll', onScroll)
666
+ editor.off('transaction', onTransaction)
667
+ }
668
+ },
669
+ onDestroy() { if (this.storage.destroy) this.storage.destroy() },
670
+ })