md2ui 1.0.9 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "md2ui",
3
- "version": "1.0.9",
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"
@@ -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>
@@ -172,6 +172,13 @@ export function useDocManager() {
172
172
  }
173
173
  }
174
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
+
175
182
  // 上一篇/下一篇
176
183
  const prevDoc = computed(() => {
177
184
  if (!currentDoc.value) return null
@@ -248,6 +255,7 @@ export function useDocManager() {
248
255
  // 状态
249
256
  docsList,
250
257
  currentDoc,
258
+ currentDocTitle,
251
259
  showWelcome,
252
260
  htmlContent,
253
261
  tocItems,
@@ -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
@@ -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;