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.
- package/README.md +65 -20
- package/bin/build.js +13 -2
- package/bin/md2ui.js +25 -12
- package/package.json +4 -4
- 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
- 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/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/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
- package/src/App.vue +36 -61
- package/src/components/ImageZoom.vue +9 -123
- package/src/components/MermaidNodeView.vue +10 -2
- package/src/components/MobileSearch.vue +97 -0
- package/src/components/TableOfContents.vue +42 -6
- package/src/composables/useDocManager.js +134 -44
- package/src/composables/useDocTree.js +26 -50
- package/src/composables/useMarkdown.js +51 -140
- package/src/composables/useMermaidCache.js +15 -0
- package/src/composables/useScroll.js +317 -32
- package/src/composables/useSearch.js +12 -11
- package/src/config.js +1 -4
- package/src/services/DocService.js +0 -16
- package/src/style.css +235 -10
- package/src/utils/imageConverter.js +129 -0
- package/vite-plugin-doc-api.js +158 -157
- package/vite.config.js +5 -1
- package/src/components/SearchPanel.vue +0 -90
- package/src/components/TableBubbleMenu.vue +0 -177
- package/src/composables/useExportPdf.js +0 -102
package/vite-plugin-doc-api.js
CHANGED
|
@@ -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 =
|
|
105
|
-
if (!filePath
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
@@ -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>
|