md2ui 1.0.8 → 1.0.10
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/bin/build.js +3 -4
- package/package.json +3 -1
- package/src/App.vue +4 -3
- package/src/components/DocContent.vue +17 -3
- package/src/composables/useDocManager.js +67 -18
- package/src/composables/useDocTree.js +16 -0
- package/src/composables/useExportWord.js +506 -0
- package/src/style.css +46 -4
package/bin/build.js
CHANGED
|
@@ -232,7 +232,7 @@ async function renderMarkdownToHtml(markdown, currentDocKey, docsList) {
|
|
|
232
232
|
|
|
233
233
|
// 纯锚点
|
|
234
234
|
if (decoded.startsWith('#')) {
|
|
235
|
-
const anchor =
|
|
235
|
+
const anchor = decoded.slice(1)
|
|
236
236
|
return `<a href="#${anchor}"${titleAttr}>${text}</a>`
|
|
237
237
|
}
|
|
238
238
|
|
|
@@ -243,9 +243,8 @@ async function renderMarkdownToHtml(markdown, currentDocKey, docsList) {
|
|
|
243
243
|
const doc = findDocInTree(docsList, targetKey)
|
|
244
244
|
if (doc) {
|
|
245
245
|
const hash = docHash(doc.key)
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
return `<a href="${url}" data-doc-key="${doc.key}"${anchorSlug ? ` data-anchor="${anchorSlug}"` : ''}${titleAttr}>${text}</a>`
|
|
246
|
+
const url = anchor ? `/${hash}.html#${anchor}` : `/${hash}.html`
|
|
247
|
+
return `<a href="${url}" data-doc-key="${doc.key}"${anchor ? ` data-anchor="${anchor}"` : ''}${titleAttr}>${text}</a>`
|
|
249
248
|
}
|
|
250
249
|
return `<a href="javascript:void(0)" class="broken-link" title="文档未找到: ${decoded}">${text}</a>`
|
|
251
250
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "md2ui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "将本地 Markdown 文档转换为美观的 HTML 页面",
|
|
6
6
|
"author": "",
|
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@vitejs/plugin-vue": "^5.0.0",
|
|
36
|
+
"docx": "^9.6.1",
|
|
37
|
+
"file-saver": "^2.0.5",
|
|
36
38
|
"flexsearch": "^0.8.212",
|
|
37
39
|
"github-slugger": "^2.0.0",
|
|
38
40
|
"highlight.js": "^11.11.1",
|
package/src/App.vue
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
:htmlContent="htmlContent"
|
|
47
47
|
:prevDoc="prevDoc"
|
|
48
48
|
:nextDoc="nextDoc"
|
|
49
|
+
:docTitle="currentDocTitle"
|
|
49
50
|
@scroll="handleScroll"
|
|
50
51
|
@content-click="onContentClick"
|
|
51
52
|
@start="loadFirstDoc"
|
|
@@ -53,7 +54,7 @@
|
|
|
53
54
|
/>
|
|
54
55
|
<!-- 桌面端 TOC -->
|
|
55
56
|
<div v-if="!isMobile && !tocCollapsed && tocItems.length > 0" class="resizer resizer-right" @mousedown="startResize('right', $event)"></div>
|
|
56
|
-
<TableOfContents v-if="!isMobile" :tocItems="tocItems" :activeHeading="activeHeading" :collapsed="tocCollapsed" :width="tocWidth" @toggle="tocCollapsed = !tocCollapsed" @scroll-to="scrollToHeading" />
|
|
57
|
+
<TableOfContents v-if="!isMobile" :tocItems="tocItems" :activeHeading="activeHeading" :collapsed="tocCollapsed" :width="tocWidth" @toggle="tocCollapsed = !tocCollapsed" @scroll-to="(id) => scrollToHeading(id, { push: true })" />
|
|
57
58
|
<button v-if="!isMobile && tocCollapsed && tocItems.length > 0" class="expand-btn expand-btn-right" @click="tocCollapsed = false" title="展开目录">
|
|
58
59
|
<ChevronLeft :size="14" />
|
|
59
60
|
</button>
|
|
@@ -67,7 +68,7 @@
|
|
|
67
68
|
:showWelcome="showWelcome"
|
|
68
69
|
@toggle="mobileTocOpen = !mobileTocOpen"
|
|
69
70
|
@close="mobileTocOpen = false"
|
|
70
|
-
@scroll-to="(id) => { scrollToHeading(id); mobileTocOpen = false }"
|
|
71
|
+
@scroll-to="(id) => { scrollToHeading(id, { push: true }); mobileTocOpen = false }"
|
|
71
72
|
/>
|
|
72
73
|
<!-- 返回顶部 -->
|
|
73
74
|
<transition name="fade">
|
|
@@ -107,7 +108,7 @@ const zoomContent = ref('')
|
|
|
107
108
|
|
|
108
109
|
// composables
|
|
109
110
|
const {
|
|
110
|
-
docsList, currentDoc, showWelcome, htmlContent, tocItems,
|
|
111
|
+
docsList, currentDoc, currentDocTitle, showWelcome, htmlContent, tocItems,
|
|
111
112
|
scrollProgress, showBackToTop, activeHeading,
|
|
112
113
|
handleScroll, scrollToHeading, scrollToTop,
|
|
113
114
|
loadDocsList, loadFromUrl, goHome, loadDoc, loadFirstDoc,
|
|
@@ -2,6 +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>
|
|
5
11
|
<article class="markdown-content" v-html="htmlContent"></article>
|
|
6
12
|
<nav v-if="prevDoc || nextDoc" class="doc-nav">
|
|
7
13
|
<a v-if="prevDoc" class="doc-nav-link prev" @click.prevent="$emit('load-doc', prevDoc.key)">
|
|
@@ -25,15 +31,23 @@
|
|
|
25
31
|
</template>
|
|
26
32
|
|
|
27
33
|
<script setup>
|
|
28
|
-
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
|
34
|
+
import { ChevronLeft, ChevronRight, FileDown } from 'lucide-vue-next'
|
|
29
35
|
import WelcomePage from './WelcomePage.vue'
|
|
36
|
+
import { useExportWord } from '../composables/useExportWord.js'
|
|
30
37
|
|
|
31
|
-
defineProps({
|
|
38
|
+
const props = defineProps({
|
|
32
39
|
showWelcome: { type: Boolean, default: true },
|
|
33
40
|
htmlContent: { type: String, default: '' },
|
|
34
41
|
prevDoc: { type: Object, default: null },
|
|
35
|
-
nextDoc: { type: Object, default: null }
|
|
42
|
+
nextDoc: { type: Object, default: null },
|
|
43
|
+
docTitle: { type: String, default: '文档' }
|
|
36
44
|
})
|
|
37
45
|
|
|
38
46
|
defineEmits(['scroll', 'content-click', 'start', 'load-doc'])
|
|
47
|
+
|
|
48
|
+
const { exporting, exportToWord } = useExportWord()
|
|
49
|
+
|
|
50
|
+
function handleExport() {
|
|
51
|
+
exportToWord(props.docTitle)
|
|
52
|
+
}
|
|
39
53
|
</script>
|
|
@@ -4,7 +4,7 @@ import { useMarkdown } from './useMarkdown.js'
|
|
|
4
4
|
import { useSearch } from './useSearch.js'
|
|
5
5
|
import { useScroll } from './useScroll.js'
|
|
6
6
|
import { useMobile } from './useMobile.js'
|
|
7
|
-
import { findDoc, findFirstDoc, findDocByHash, expandParents, flattenDocsList, expandAll, collapseAll } from './useDocTree.js'
|
|
7
|
+
import { findDoc, findFirstDoc, findReadmeDoc, findDocByHash, expandParents, flattenDocsList, expandAll, collapseAll } from './useDocTree.js'
|
|
8
8
|
|
|
9
9
|
// 等待内容区域图片加载完成
|
|
10
10
|
async function waitForContentImages(timeoutMs = 3000) {
|
|
@@ -36,23 +36,38 @@ export function useDocManager() {
|
|
|
36
36
|
} = useScroll()
|
|
37
37
|
const { isMobile, mobileDrawerOpen } = useMobile()
|
|
38
38
|
|
|
39
|
-
//
|
|
39
|
+
// 获取当前滚动位置
|
|
40
|
+
function getScrollTop() {
|
|
41
|
+
const el = document.querySelector('.content')
|
|
42
|
+
return el ? el.scrollTop : 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 构造 history state,保留滚动位置
|
|
46
|
+
function makeState(scrollTop) {
|
|
47
|
+
return { scrollTop: scrollTop ?? getScrollTop() }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 滚动处理,同步锚点到 URL(replaceState 保留滚动位置)
|
|
40
51
|
function handleScroll(e) {
|
|
41
52
|
_handleScroll(e)
|
|
42
53
|
if (activeHeading.value && currentDoc.value) {
|
|
43
|
-
history.replaceState(
|
|
54
|
+
history.replaceState(makeState(), '', `/${docHash(currentDoc.value)}#${activeHeading.value}`)
|
|
44
55
|
}
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
// push: true 表示用户主动点击锚点,产生可回退的历史条目
|
|
48
59
|
function scrollToHeading(id, { push = false } = {}) {
|
|
60
|
+
if (push && currentDoc.value) {
|
|
61
|
+
// 先把当前滚动位置写入即将被覆盖的历史条目
|
|
62
|
+
history.replaceState(makeState(), '', window.location.href)
|
|
63
|
+
}
|
|
49
64
|
_scrollToHeading(id)
|
|
50
65
|
if (currentDoc.value) {
|
|
51
66
|
const url = `/${docHash(currentDoc.value)}#${id}`
|
|
52
67
|
if (push) {
|
|
53
|
-
history.pushState(
|
|
68
|
+
history.pushState(makeState(), '', url)
|
|
54
69
|
} else {
|
|
55
|
-
history.replaceState(
|
|
70
|
+
history.replaceState(makeState(), '', url)
|
|
56
71
|
}
|
|
57
72
|
}
|
|
58
73
|
}
|
|
@@ -63,26 +78,33 @@ export function useDocManager() {
|
|
|
63
78
|
buildIndex(docsList.value)
|
|
64
79
|
}
|
|
65
80
|
|
|
66
|
-
//
|
|
67
|
-
function goHome() {
|
|
81
|
+
// 回到欢迎页(isPopstate: true 表示由浏览器回退触发,不操作 history)
|
|
82
|
+
function goHome({ isPopstate = false } = {}) {
|
|
68
83
|
currentDoc.value = ''
|
|
69
84
|
showWelcome.value = true
|
|
70
85
|
htmlContent.value = ''
|
|
71
86
|
tocItems.value = []
|
|
72
|
-
|
|
87
|
+
if (!isPopstate) {
|
|
88
|
+
// 用户主动点击,保存旧条目滚动位置后 push
|
|
89
|
+
history.replaceState(makeState(), '', window.location.href)
|
|
90
|
+
history.pushState(makeState(0), '', '/')
|
|
91
|
+
}
|
|
73
92
|
if (isMobile.value) mobileDrawerOpen.value = false
|
|
74
93
|
}
|
|
75
94
|
|
|
76
95
|
// 加载文档
|
|
77
|
-
async function loadDoc(key, { replace = false, anchor = '' } = {}) {
|
|
96
|
+
async function loadDoc(key, { replace = false, anchor = '', keepState = false } = {}) {
|
|
78
97
|
currentDoc.value = key
|
|
79
98
|
showWelcome.value = false
|
|
80
99
|
const hash = docHash(key)
|
|
81
100
|
const url = anchor ? `/${hash}#${anchor}` : `/${hash}`
|
|
82
101
|
if (replace) {
|
|
83
|
-
|
|
102
|
+
// keepState: popstate 回退时保留浏览器已有的 state(含 scrollTop)
|
|
103
|
+
if (!keepState) history.replaceState(makeState(0), '', url)
|
|
84
104
|
} else {
|
|
85
|
-
|
|
105
|
+
// push 前先保存当前滚动位置到旧条目
|
|
106
|
+
history.replaceState(makeState(), '', window.location.href)
|
|
107
|
+
history.pushState(makeState(0), '', url)
|
|
86
108
|
}
|
|
87
109
|
const doc = findDoc(docsList.value, key)
|
|
88
110
|
if (!doc) return
|
|
@@ -150,6 +172,13 @@ export function useDocManager() {
|
|
|
150
172
|
}
|
|
151
173
|
}
|
|
152
174
|
|
|
175
|
+
// 当前文档标题
|
|
176
|
+
const currentDocTitle = computed(() => {
|
|
177
|
+
if (!currentDoc.value) return '文档'
|
|
178
|
+
const doc = findDoc(docsList.value, currentDoc.value)
|
|
179
|
+
return doc?.label || '文档'
|
|
180
|
+
})
|
|
181
|
+
|
|
153
182
|
// 上一篇/下一篇
|
|
154
183
|
const prevDoc = computed(() => {
|
|
155
184
|
if (!currentDoc.value) return null
|
|
@@ -175,22 +204,35 @@ export function useDocManager() {
|
|
|
175
204
|
async function loadFromUrl() {
|
|
176
205
|
const pathname = window.location.pathname.replace(/^\//, '')
|
|
177
206
|
const anchor = window.location.hash.replace('#', '')
|
|
207
|
+
const savedScroll = history.state?.scrollTop
|
|
178
208
|
if (!pathname) {
|
|
179
209
|
if (currentDoc.value) {
|
|
180
|
-
//
|
|
181
|
-
goHome()
|
|
210
|
+
// 浏览器回退到首页,不操作 history
|
|
211
|
+
goHome({ isPopstate: true })
|
|
182
212
|
} else if (docsList.value.length === 0) {
|
|
183
213
|
showWelcome.value = false
|
|
184
214
|
renderMarkdown('# 当前目录没有 Markdown 文档\n\n请在当前目录下添加 `.md` 文件,然后刷新页面。')
|
|
215
|
+
} else {
|
|
216
|
+
// 优先定位到 README,没有则定位到第一篇文档
|
|
217
|
+
const readme = findReadmeDoc(docsList.value)
|
|
218
|
+
const target = readme || findFirstDoc(docsList.value)
|
|
219
|
+
if (target) {
|
|
220
|
+
expandParents(docsList.value, target.key)
|
|
221
|
+
await loadDoc(target.key, { replace: true })
|
|
222
|
+
}
|
|
185
223
|
}
|
|
186
224
|
return
|
|
187
225
|
}
|
|
188
226
|
const doc = findDocByHash(docsList.value, pathname, docHash)
|
|
189
227
|
if (!doc) return
|
|
190
|
-
//
|
|
228
|
+
// 同一文档内的锚点变化(含回退)
|
|
191
229
|
if (doc.key === currentDoc.value) {
|
|
192
|
-
|
|
193
|
-
|
|
230
|
+
await nextTick()
|
|
231
|
+
if (savedScroll != null) {
|
|
232
|
+
// 有保存的滚动位置,直接恢复(回退场景)
|
|
233
|
+
const contentEl = document.querySelector('.content')
|
|
234
|
+
if (contentEl) contentEl.scrollTo({ top: savedScroll, behavior: 'smooth' })
|
|
235
|
+
} else if (anchor) {
|
|
194
236
|
_scrollToHeading(decodeURIComponent(anchor))
|
|
195
237
|
} else {
|
|
196
238
|
const contentEl = document.querySelector('.content')
|
|
@@ -199,14 +241,21 @@ export function useDocManager() {
|
|
|
199
241
|
return
|
|
200
242
|
}
|
|
201
243
|
expandParents(docsList.value, doc.key)
|
|
202
|
-
await loadDoc(doc.key, { replace: true, anchor: anchor ? decodeURIComponent(anchor) : '' })
|
|
203
|
-
if (
|
|
244
|
+
await loadDoc(doc.key, { replace: true, keepState: savedScroll != null, anchor: anchor ? decodeURIComponent(anchor) : '' })
|
|
245
|
+
if (savedScroll != null) {
|
|
246
|
+
await nextTick()
|
|
247
|
+
const contentEl = document.querySelector('.content')
|
|
248
|
+
if (contentEl) contentEl.scrollTo({ top: savedScroll })
|
|
249
|
+
} else if (anchor) {
|
|
250
|
+
await nextTick(); await waitForContentImages(); _scrollToHeading(decodeURIComponent(anchor))
|
|
251
|
+
}
|
|
204
252
|
}
|
|
205
253
|
|
|
206
254
|
return {
|
|
207
255
|
// 状态
|
|
208
256
|
docsList,
|
|
209
257
|
currentDoc,
|
|
258
|
+
currentDocTitle,
|
|
210
259
|
showWelcome,
|
|
211
260
|
htmlContent,
|
|
212
261
|
tocItems,
|
|
@@ -24,6 +24,22 @@ export function findFirstDoc(items) {
|
|
|
24
24
|
return null
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// 查找 README 文档(不区分大小写,优先根目录)
|
|
28
|
+
export function findReadmeDoc(items) {
|
|
29
|
+
// 先在当前层级找
|
|
30
|
+
for (const item of items) {
|
|
31
|
+
if (item.type === 'file' && /^readme$/i.test(item.key?.split('/').pop() || '')) return item
|
|
32
|
+
}
|
|
33
|
+
// 再递归子目录找
|
|
34
|
+
for (const item of items) {
|
|
35
|
+
if (item.type === 'folder' && item.children) {
|
|
36
|
+
const found = findReadmeDoc(item.children)
|
|
37
|
+
if (found) return found
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
27
43
|
// 根据 hash 查找文档
|
|
28
44
|
export function findDocByHash(items, hash, docHash) {
|
|
29
45
|
for (const item of items) {
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import {
|
|
3
|
+
Document, Packer, Paragraph, TextRun, HeadingLevel,
|
|
4
|
+
Table, TableRow, TableCell, WidthType, BorderStyle,
|
|
5
|
+
ImageRun, AlignmentType, ShadingType,
|
|
6
|
+
convertInchesToTwip
|
|
7
|
+
} from 'docx'
|
|
8
|
+
import { saveAs } from 'file-saver'
|
|
9
|
+
|
|
10
|
+
// ---- SVG 转 PNG ----
|
|
11
|
+
|
|
12
|
+
async function svgToBase64Png(svgEl) {
|
|
13
|
+
const svgClone = svgEl.cloneNode(true)
|
|
14
|
+
const bbox = svgEl.getBoundingClientRect()
|
|
15
|
+
const width = Math.ceil(bbox.width) || 800
|
|
16
|
+
const height = Math.ceil(bbox.height) || 400
|
|
17
|
+
svgClone.setAttribute('width', width)
|
|
18
|
+
svgClone.setAttribute('height', height)
|
|
19
|
+
if (!svgClone.getAttribute('xmlns')) {
|
|
20
|
+
svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
|
21
|
+
}
|
|
22
|
+
// 内联所有计算样式到 SVG 元素,确保文字可见
|
|
23
|
+
inlineStyles(svgEl, svgClone)
|
|
24
|
+
|
|
25
|
+
const svgData = new XMLSerializer().serializeToString(svgClone)
|
|
26
|
+
// 将 SVG 字符串编码为 base64(支持 UTF-8 中文字符)
|
|
27
|
+
const encoder = new TextEncoder()
|
|
28
|
+
const bytes = encoder.encode(svgData)
|
|
29
|
+
let binary = ''
|
|
30
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
31
|
+
binary += String.fromCharCode(bytes[i])
|
|
32
|
+
}
|
|
33
|
+
const base64Svg = btoa(binary)
|
|
34
|
+
const dataUri = `data:image/svg+xml;base64,${base64Svg}`
|
|
35
|
+
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const img = new Image()
|
|
38
|
+
img.onload = () => {
|
|
39
|
+
const scale = 2
|
|
40
|
+
const canvas = document.createElement('canvas')
|
|
41
|
+
canvas.width = width * scale
|
|
42
|
+
canvas.height = height * scale
|
|
43
|
+
const ctx = canvas.getContext('2d')
|
|
44
|
+
ctx.fillStyle = '#ffffff'
|
|
45
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
|
46
|
+
ctx.scale(scale, scale)
|
|
47
|
+
ctx.drawImage(img, 0, 0, width, height)
|
|
48
|
+
resolve({ dataUrl: canvas.toDataURL('image/png'), width, height })
|
|
49
|
+
}
|
|
50
|
+
img.onerror = () => reject(new Error('SVG 转图片失败'))
|
|
51
|
+
img.src = dataUri
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 递归内联计算样式(确保 SVG 序列化后文字、颜色等不丢失)
|
|
56
|
+
function inlineStyles(source, target) {
|
|
57
|
+
const computed = window.getComputedStyle(source)
|
|
58
|
+
const dominated = ['font-family', 'font-size', 'font-weight', 'fill', 'stroke',
|
|
59
|
+
'stroke-width', 'opacity', 'visibility', 'display', 'text-anchor',
|
|
60
|
+
'dominant-baseline', 'color', 'transform']
|
|
61
|
+
let style = ''
|
|
62
|
+
for (const prop of dominated) {
|
|
63
|
+
const val = computed.getPropertyValue(prop)
|
|
64
|
+
if (val) style += `${prop}:${val};`
|
|
65
|
+
}
|
|
66
|
+
if (style) target.setAttribute('style', (target.getAttribute('style') || '') + ';' + style)
|
|
67
|
+
|
|
68
|
+
const sourceChildren = source.children
|
|
69
|
+
const targetChildren = target.children
|
|
70
|
+
for (let i = 0; i < sourceChildren.length && i < targetChildren.length; i++) {
|
|
71
|
+
inlineStyles(sourceChildren[i], targetChildren[i])
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- DOM 解析为 docx 元素 ----
|
|
76
|
+
|
|
77
|
+
const HEADING_MAP = {
|
|
78
|
+
H1: HeadingLevel.HEADING_1,
|
|
79
|
+
H2: HeadingLevel.HEADING_2,
|
|
80
|
+
H3: HeadingLevel.HEADING_3,
|
|
81
|
+
H4: HeadingLevel.HEADING_4,
|
|
82
|
+
H5: HeadingLevel.HEADING_5,
|
|
83
|
+
H6: HeadingLevel.HEADING_6,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 从 DOM 节点提取内联文本 runs
|
|
87
|
+
function extractTextRuns(node, inherited = {}) {
|
|
88
|
+
const runs = []
|
|
89
|
+
if (!node) return runs
|
|
90
|
+
|
|
91
|
+
for (const child of node.childNodes) {
|
|
92
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
93
|
+
const text = child.textContent
|
|
94
|
+
if (text) {
|
|
95
|
+
runs.push(new TextRun({ text, ...inherited }))
|
|
96
|
+
}
|
|
97
|
+
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
98
|
+
const tag = child.tagName
|
|
99
|
+
// 跳过锚点图标
|
|
100
|
+
if (child.classList?.contains('heading-anchor')) continue
|
|
101
|
+
|
|
102
|
+
if (tag === 'STRONG' || tag === 'B') {
|
|
103
|
+
runs.push(...extractTextRuns(child, { ...inherited, bold: true }))
|
|
104
|
+
} else if (tag === 'EM' || tag === 'I') {
|
|
105
|
+
runs.push(...extractTextRuns(child, { ...inherited, italics: true }))
|
|
106
|
+
} else if (tag === 'CODE') {
|
|
107
|
+
runs.push(new TextRun({
|
|
108
|
+
text: child.textContent,
|
|
109
|
+
font: 'Consolas',
|
|
110
|
+
size: 18, // 9pt
|
|
111
|
+
shading: { type: ShadingType.CLEAR, fill: 'f0f0f0' },
|
|
112
|
+
...inherited,
|
|
113
|
+
}))
|
|
114
|
+
} else if (tag === 'A') {
|
|
115
|
+
runs.push(new TextRun({
|
|
116
|
+
text: child.textContent,
|
|
117
|
+
color: '4a6cf7',
|
|
118
|
+
underline: {},
|
|
119
|
+
...inherited,
|
|
120
|
+
}))
|
|
121
|
+
} else if (tag === 'DEL' || tag === 'S') {
|
|
122
|
+
runs.push(...extractTextRuns(child, { ...inherited, strike: true }))
|
|
123
|
+
} else if (tag === 'BR') {
|
|
124
|
+
runs.push(new TextRun({ break: 1 }))
|
|
125
|
+
} else {
|
|
126
|
+
runs.push(...extractTextRuns(child, inherited))
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return runs
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 解析表格
|
|
134
|
+
function parseTable(tableEl) {
|
|
135
|
+
const rows = []
|
|
136
|
+
const allTr = tableEl.querySelectorAll('tr')
|
|
137
|
+
for (const tr of allTr) {
|
|
138
|
+
const cells = []
|
|
139
|
+
const tds = tr.querySelectorAll('th, td')
|
|
140
|
+
const isHeader = tr.querySelector('th') !== null
|
|
141
|
+
for (const td of tds) {
|
|
142
|
+
cells.push(new TableCell({
|
|
143
|
+
children: [new Paragraph({
|
|
144
|
+
children: extractTextRuns(td, isHeader ? { bold: true } : {}),
|
|
145
|
+
spacing: { before: 40, after: 40 },
|
|
146
|
+
})],
|
|
147
|
+
shading: isHeader
|
|
148
|
+
? { type: ShadingType.CLEAR, fill: 'f0f0f0' }
|
|
149
|
+
: undefined,
|
|
150
|
+
width: { size: 100 / tds.length, type: WidthType.PERCENTAGE },
|
|
151
|
+
}))
|
|
152
|
+
}
|
|
153
|
+
if (cells.length > 0) {
|
|
154
|
+
rows.push(new TableRow({ children: cells }))
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (rows.length === 0) return null
|
|
158
|
+
return new Table({
|
|
159
|
+
rows,
|
|
160
|
+
width: { size: 100, type: WidthType.PERCENTAGE },
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 解析列表
|
|
165
|
+
function parseList(listEl, level = 0) {
|
|
166
|
+
const items = []
|
|
167
|
+
for (const li of listEl.children) {
|
|
168
|
+
if (li.tagName !== 'LI') continue
|
|
169
|
+
// 检查 checkbox(任务列表)
|
|
170
|
+
const checkbox = li.querySelector('input[type="checkbox"]')
|
|
171
|
+
let prefix = ''
|
|
172
|
+
if (checkbox) {
|
|
173
|
+
prefix = checkbox.checked ? '[x] ' : '[ ] '
|
|
174
|
+
}
|
|
175
|
+
const isOrdered = listEl.tagName === 'OL'
|
|
176
|
+
const idx = Array.from(listEl.children).indexOf(li)
|
|
177
|
+
|
|
178
|
+
// 提取文本(排除嵌套列表)
|
|
179
|
+
const textRuns = []
|
|
180
|
+
if (prefix) {
|
|
181
|
+
textRuns.push(new TextRun({ text: prefix, font: 'Consolas', size: 20 }))
|
|
182
|
+
}
|
|
183
|
+
for (const child of li.childNodes) {
|
|
184
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
185
|
+
const t = child.textContent.trim()
|
|
186
|
+
if (t) textRuns.push(new TextRun({ text: t }))
|
|
187
|
+
} else if (child.nodeType === Node.ELEMENT_NODE && child.tagName !== 'UL' && child.tagName !== 'OL') {
|
|
188
|
+
if (child.tagName === 'INPUT') continue // 跳过 checkbox 本身
|
|
189
|
+
textRuns.push(...extractTextRuns(child))
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const bullet = isOrdered ? `${idx + 1}. ` : ' '
|
|
194
|
+
const indent = level * 360
|
|
195
|
+
items.push(new Paragraph({
|
|
196
|
+
children: [
|
|
197
|
+
new TextRun({ text: ' '.repeat(level) + bullet }),
|
|
198
|
+
...textRuns,
|
|
199
|
+
],
|
|
200
|
+
spacing: { before: 40, after: 40 },
|
|
201
|
+
indent: { left: indent },
|
|
202
|
+
}))
|
|
203
|
+
|
|
204
|
+
// 嵌套列表
|
|
205
|
+
const nested = li.querySelector('ul, ol')
|
|
206
|
+
if (nested) {
|
|
207
|
+
items.push(...parseList(nested, level + 1))
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return items
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 主解析函数:将 DOM 转为 docx 元素数组
|
|
214
|
+
async function parseDomToDocx(contentEl) {
|
|
215
|
+
const elements = []
|
|
216
|
+
|
|
217
|
+
for (const node of contentEl.children) {
|
|
218
|
+
const tag = node.tagName
|
|
219
|
+
|
|
220
|
+
// 标题
|
|
221
|
+
if (HEADING_MAP[tag]) {
|
|
222
|
+
const runs = extractTextRuns(node)
|
|
223
|
+
elements.push(new Paragraph({
|
|
224
|
+
children: runs,
|
|
225
|
+
heading: HEADING_MAP[tag],
|
|
226
|
+
spacing: { before: 240, after: 120 },
|
|
227
|
+
}))
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 段落
|
|
232
|
+
if (tag === 'P') {
|
|
233
|
+
const runs = extractTextRuns(node)
|
|
234
|
+
if (runs.length > 0) {
|
|
235
|
+
elements.push(new Paragraph({
|
|
236
|
+
children: runs,
|
|
237
|
+
spacing: { before: 80, after: 80 },
|
|
238
|
+
}))
|
|
239
|
+
}
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 文档元信息
|
|
244
|
+
if (node.classList?.contains('doc-meta')) {
|
|
245
|
+
elements.push(new Paragraph({
|
|
246
|
+
children: [new TextRun({
|
|
247
|
+
text: node.textContent.trim(),
|
|
248
|
+
color: '999999',
|
|
249
|
+
size: 18,
|
|
250
|
+
})],
|
|
251
|
+
spacing: { before: 40, after: 120 },
|
|
252
|
+
}))
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 代码块
|
|
257
|
+
if (node.classList?.contains('code-block-wrapper')) {
|
|
258
|
+
const rawCode = decodeURIComponent(node.dataset.rawCode || '')
|
|
259
|
+
const lang = (node.dataset.lang || '').toUpperCase()
|
|
260
|
+
|
|
261
|
+
const codeLines = rawCode.split('\n')
|
|
262
|
+
const codeRuns = []
|
|
263
|
+
|
|
264
|
+
// 语言标签
|
|
265
|
+
if (lang) {
|
|
266
|
+
codeRuns.push(new TextRun({
|
|
267
|
+
text: lang,
|
|
268
|
+
font: 'Consolas',
|
|
269
|
+
size: 16,
|
|
270
|
+
color: '999999',
|
|
271
|
+
}))
|
|
272
|
+
codeRuns.push(new TextRun({ break: 1 }))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 代码内容,逐行添加
|
|
276
|
+
codeLines.forEach((line, i) => {
|
|
277
|
+
codeRuns.push(new TextRun({
|
|
278
|
+
text: line || ' ', // 空行用空格占位
|
|
279
|
+
font: 'Consolas',
|
|
280
|
+
size: 18,
|
|
281
|
+
color: '333333',
|
|
282
|
+
}))
|
|
283
|
+
if (i < codeLines.length - 1) {
|
|
284
|
+
codeRuns.push(new TextRun({ break: 1 }))
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
elements.push(new Paragraph({
|
|
289
|
+
children: codeRuns,
|
|
290
|
+
alignment: AlignmentType.LEFT,
|
|
291
|
+
shading: { type: ShadingType.CLEAR, fill: 'f5f5f5' },
|
|
292
|
+
spacing: { before: 120, after: 120, line: 276 },
|
|
293
|
+
border: {
|
|
294
|
+
top: { style: BorderStyle.SINGLE, size: 1, color: 'dddddd' },
|
|
295
|
+
bottom: { style: BorderStyle.SINGLE, size: 1, color: 'dddddd' },
|
|
296
|
+
left: { style: BorderStyle.SINGLE, size: 1, color: 'dddddd' },
|
|
297
|
+
right: { style: BorderStyle.SINGLE, size: 1, color: 'dddddd' },
|
|
298
|
+
},
|
|
299
|
+
indent: { left: 200, right: 200 },
|
|
300
|
+
}))
|
|
301
|
+
continue
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Mermaid 图表
|
|
305
|
+
if (node.classList?.contains('mermaid')) {
|
|
306
|
+
const svg = node.querySelector('svg')
|
|
307
|
+
if (svg) {
|
|
308
|
+
try {
|
|
309
|
+
const { dataUrl, width, height } = await svgToBase64Png(svg)
|
|
310
|
+
// base64 数据提取
|
|
311
|
+
const base64Data = dataUrl.split(',')[1]
|
|
312
|
+
const binaryString = atob(base64Data)
|
|
313
|
+
const bytes = new Uint8Array(binaryString.length)
|
|
314
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
315
|
+
bytes[i] = binaryString.charCodeAt(i)
|
|
316
|
+
}
|
|
317
|
+
// 限制最大宽度为 6 英寸(Word 页面宽度约 6.5 英寸)
|
|
318
|
+
const maxWidth = 6 * 96 // 576px
|
|
319
|
+
const scale = width > maxWidth ? maxWidth / width : 1
|
|
320
|
+
const imgWidth = Math.round(width * scale)
|
|
321
|
+
const imgHeight = Math.round(height * scale)
|
|
322
|
+
|
|
323
|
+
elements.push(new Paragraph({
|
|
324
|
+
children: [new ImageRun({
|
|
325
|
+
data: bytes,
|
|
326
|
+
transformation: { width: imgWidth, height: imgHeight },
|
|
327
|
+
type: 'png',
|
|
328
|
+
})],
|
|
329
|
+
spacing: { before: 120, after: 120 },
|
|
330
|
+
alignment: AlignmentType.CENTER,
|
|
331
|
+
}))
|
|
332
|
+
} catch {
|
|
333
|
+
elements.push(new Paragraph({
|
|
334
|
+
children: [new TextRun({ text: '[图表无法导出]', color: '999999', italics: true })],
|
|
335
|
+
}))
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
continue
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// 表格(可能被 table-wrapper 包裹)
|
|
342
|
+
if (tag === 'TABLE' || node.classList?.contains('table-wrapper')) {
|
|
343
|
+
const tableEl = tag === 'TABLE' ? node : node.querySelector('table')
|
|
344
|
+
if (tableEl) {
|
|
345
|
+
const table = parseTable(tableEl)
|
|
346
|
+
if (table) elements.push(table)
|
|
347
|
+
}
|
|
348
|
+
continue
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// 列表
|
|
352
|
+
if (tag === 'UL' || tag === 'OL') {
|
|
353
|
+
elements.push(...parseList(node))
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 引用块
|
|
358
|
+
if (tag === 'BLOCKQUOTE') {
|
|
359
|
+
const runs = extractTextRuns(node)
|
|
360
|
+
elements.push(new Paragraph({
|
|
361
|
+
children: runs,
|
|
362
|
+
spacing: { before: 80, after: 80 },
|
|
363
|
+
indent: { left: 400 },
|
|
364
|
+
border: {
|
|
365
|
+
left: { style: BorderStyle.SINGLE, size: 6, color: 'cccccc' },
|
|
366
|
+
},
|
|
367
|
+
shading: { type: ShadingType.CLEAR, fill: 'fafafa' },
|
|
368
|
+
}))
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 图片
|
|
373
|
+
if (tag === 'IMG') {
|
|
374
|
+
try {
|
|
375
|
+
const imgData = await fetchImageAsBytes(node.src)
|
|
376
|
+
if (imgData) {
|
|
377
|
+
elements.push(new Paragraph({
|
|
378
|
+
children: [new ImageRun({
|
|
379
|
+
data: imgData.bytes,
|
|
380
|
+
transformation: { width: imgData.width, height: imgData.height },
|
|
381
|
+
type: 'png',
|
|
382
|
+
})],
|
|
383
|
+
spacing: { before: 120, after: 120 },
|
|
384
|
+
alignment: AlignmentType.CENTER,
|
|
385
|
+
}))
|
|
386
|
+
}
|
|
387
|
+
} catch { /* 跳过无法加载的图片 */ }
|
|
388
|
+
continue
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// 水平线
|
|
392
|
+
if (tag === 'HR') {
|
|
393
|
+
elements.push(new Paragraph({
|
|
394
|
+
children: [],
|
|
395
|
+
border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: 'cccccc' } },
|
|
396
|
+
spacing: { before: 120, after: 120 },
|
|
397
|
+
}))
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 其他 div 容器,递归处理子元素
|
|
402
|
+
if (tag === 'DIV' || tag === 'SECTION' || tag === 'ARTICLE') {
|
|
403
|
+
const sub = await parseDomToDocx(node)
|
|
404
|
+
elements.push(...sub)
|
|
405
|
+
continue
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 兜底:提取文本
|
|
409
|
+
const text = node.textContent?.trim()
|
|
410
|
+
if (text) {
|
|
411
|
+
elements.push(new Paragraph({
|
|
412
|
+
children: [new TextRun({ text })],
|
|
413
|
+
spacing: { before: 80, after: 80 },
|
|
414
|
+
}))
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return elements
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// 加载图片为字节数组
|
|
422
|
+
async function fetchImageAsBytes(src) {
|
|
423
|
+
try {
|
|
424
|
+
const img = new Image()
|
|
425
|
+
img.crossOrigin = 'anonymous'
|
|
426
|
+
await new Promise((resolve, reject) => {
|
|
427
|
+
img.onload = resolve
|
|
428
|
+
img.onerror = reject
|
|
429
|
+
img.src = src
|
|
430
|
+
})
|
|
431
|
+
const maxWidth = 576
|
|
432
|
+
const scale = img.naturalWidth > maxWidth ? maxWidth / img.naturalWidth : 1
|
|
433
|
+
const w = Math.round(img.naturalWidth * scale)
|
|
434
|
+
const h = Math.round(img.naturalHeight * scale)
|
|
435
|
+
const canvas = document.createElement('canvas')
|
|
436
|
+
canvas.width = w
|
|
437
|
+
canvas.height = h
|
|
438
|
+
const ctx = canvas.getContext('2d')
|
|
439
|
+
ctx.drawImage(img, 0, 0, w, h)
|
|
440
|
+
const dataUrl = canvas.toDataURL('image/png')
|
|
441
|
+
const base64 = dataUrl.split(',')[1]
|
|
442
|
+
const binary = atob(base64)
|
|
443
|
+
const bytes = new Uint8Array(binary.length)
|
|
444
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
|
445
|
+
return { bytes, width: w, height: h }
|
|
446
|
+
} catch {
|
|
447
|
+
return null
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ---- 主 composable ----
|
|
452
|
+
|
|
453
|
+
export function useExportWord() {
|
|
454
|
+
const exporting = ref(false)
|
|
455
|
+
|
|
456
|
+
async function exportToWord(title = '文档') {
|
|
457
|
+
exporting.value = true
|
|
458
|
+
try {
|
|
459
|
+
const contentEl = document.querySelector('.markdown-content')
|
|
460
|
+
if (!contentEl) return
|
|
461
|
+
|
|
462
|
+
const children = await parseDomToDocx(contentEl)
|
|
463
|
+
|
|
464
|
+
const doc = new Document({
|
|
465
|
+
styles: {
|
|
466
|
+
default: {
|
|
467
|
+
document: {
|
|
468
|
+
run: {
|
|
469
|
+
font: 'Microsoft YaHei',
|
|
470
|
+
size: 22, // 11pt
|
|
471
|
+
color: '333333',
|
|
472
|
+
},
|
|
473
|
+
paragraph: {
|
|
474
|
+
spacing: { line: 360 }, // 1.5 倍行距
|
|
475
|
+
alignment: AlignmentType.LEFT,
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
sections: [{
|
|
481
|
+
properties: {
|
|
482
|
+
page: {
|
|
483
|
+
margin: {
|
|
484
|
+
top: convertInchesToTwip(1),
|
|
485
|
+
bottom: convertInchesToTwip(1),
|
|
486
|
+
left: convertInchesToTwip(1.2),
|
|
487
|
+
right: convertInchesToTwip(1.2),
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
children,
|
|
492
|
+
}],
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
const blob = await Packer.toBlob(doc)
|
|
496
|
+
const fileName = title.replace(/[\\/:*?"<>|]/g, '_') + '.docx'
|
|
497
|
+
saveAs(blob, fileName)
|
|
498
|
+
} catch (err) {
|
|
499
|
+
console.error('导出 Word 失败:', err)
|
|
500
|
+
} finally {
|
|
501
|
+
exporting.value = false
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return { exporting, exportToWord }
|
|
506
|
+
}
|
package/src/style.css
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
--color-text: #1a1e1b;
|
|
8
8
|
--color-text-secondary: #505856;
|
|
9
9
|
--color-text-tertiary: #848e89;
|
|
10
|
-
--color-border:
|
|
11
|
-
--color-border-light:
|
|
10
|
+
--color-border: rgba(66, 184, 131, 0.25);
|
|
11
|
+
--color-border-light: rgba(66, 184, 131, 0.15);
|
|
12
12
|
--color-accent: #42b883;
|
|
13
13
|
--color-accent-hover: #359d6e;
|
|
14
14
|
--color-accent-bg: rgba(66, 184, 131, 0.1);
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
--color-code-border: transparent;
|
|
19
19
|
--color-pre-bg: #ffffff;
|
|
20
20
|
--color-pre-header: #f7f9f8;
|
|
21
|
-
--color-pre-border:
|
|
21
|
+
--color-pre-border: rgba(66, 184, 131, 0.25);
|
|
22
22
|
--color-pre-text: #1a1e1b;
|
|
23
23
|
--color-blockquote-border: #42b883;
|
|
24
24
|
--color-blockquote-bg: rgba(66, 184, 131, 0.03);
|
|
@@ -1074,7 +1074,7 @@ body {
|
|
|
1074
1074
|
align-items: center;
|
|
1075
1075
|
padding: 8px 16px;
|
|
1076
1076
|
background: var(--color-bg);
|
|
1077
|
-
border-bottom:
|
|
1077
|
+
border-bottom: 1px solid var(--color-border);
|
|
1078
1078
|
flex-shrink: 0;
|
|
1079
1079
|
gap: 12px;
|
|
1080
1080
|
z-index: 100;
|
|
@@ -1260,6 +1260,48 @@ body {
|
|
|
1260
1260
|
color: var(--color-border);
|
|
1261
1261
|
}
|
|
1262
1262
|
|
|
1263
|
+
/* ===== 文档工具栏 ===== */
|
|
1264
|
+
.doc-toolbar {
|
|
1265
|
+
display: flex;
|
|
1266
|
+
justify-content: flex-end;
|
|
1267
|
+
width: 100%;
|
|
1268
|
+
max-width: 960px;
|
|
1269
|
+
margin: 0 auto;
|
|
1270
|
+
padding: 8px 32px 0;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
.export-word-btn {
|
|
1274
|
+
display: inline-flex;
|
|
1275
|
+
align-items: center;
|
|
1276
|
+
gap: 5px;
|
|
1277
|
+
padding: 5px 12px;
|
|
1278
|
+
font-size: 12px;
|
|
1279
|
+
color: var(--color-text-secondary);
|
|
1280
|
+
background: transparent;
|
|
1281
|
+
border: 1px solid var(--color-border);
|
|
1282
|
+
border-radius: 5px;
|
|
1283
|
+
cursor: pointer;
|
|
1284
|
+
transition: all 0.15s;
|
|
1285
|
+
white-space: nowrap;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
.export-word-btn:hover {
|
|
1289
|
+
color: var(--color-accent);
|
|
1290
|
+
border-color: var(--color-accent);
|
|
1291
|
+
background: var(--color-accent-bg);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
.export-word-btn:disabled {
|
|
1295
|
+
opacity: 0.5;
|
|
1296
|
+
cursor: not-allowed;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
@media (max-width: 768px) {
|
|
1300
|
+
.doc-toolbar {
|
|
1301
|
+
padding: 8px 16px 0;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1263
1305
|
/* ===== 上一篇/下一篇导航 ===== */
|
|
1264
1306
|
.doc-nav {
|
|
1265
1307
|
display: flex;
|