md2ui 1.0.10 → 1.0.12

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.10",
3
+ "version": "1.0.12",
4
4
  "type": "module",
5
5
  "description": "将本地 Markdown 文档转换为美观的 HTML 页面",
6
6
  "author": "",
@@ -22,7 +22,8 @@
22
22
  "scripts": {
23
23
  "dev": "vite",
24
24
  "build": "vite build",
25
- "preview": "vite preview"
25
+ "preview": "vite preview",
26
+ "publish": "bash publish.sh"
26
27
  },
27
28
  "files": [
28
29
  "bin",
package/src/App.vue CHANGED
@@ -78,7 +78,7 @@
78
78
  </button>
79
79
  </transition>
80
80
  <!-- 图片放大 -->
81
- <ImageZoom :visible="zoomVisible" :imageContent="zoomContent" @close="zoomVisible = false" />
81
+ <ImageZoom :visible="zoomVisible" :images="zoomImages" :currentIndex="zoomIndex" @update:currentIndex="zoomIndex = $event" @close="zoomVisible = false" />
82
82
 
83
83
  </div>
84
84
  </template>
@@ -105,6 +105,8 @@ const sidebarCollapsed = ref(false)
105
105
  const tocCollapsed = ref(false)
106
106
  const zoomVisible = ref(false)
107
107
  const zoomContent = ref('')
108
+ const zoomImages = ref([])
109
+ const zoomIndex = ref(0)
108
110
 
109
111
  // composables
110
112
  const {
@@ -126,8 +128,9 @@ const { isMobile, mobileDrawerOpen, mobileTocOpen } = useMobile()
126
128
  // 内容区点击:委托给 docManager,图片放大回调在这里处理
127
129
  function onContentClick(event) {
128
130
  handleContentClick(event, {
129
- onZoom(content) {
130
- zoomContent.value = content
131
+ onZoom({ images, index }) {
132
+ zoomImages.value = images
133
+ zoomIndex.value = index
131
134
  zoomVisible.value = true
132
135
  }
133
136
  })
@@ -23,11 +23,25 @@
23
23
  <RotateCcw :size="16" />
24
24
  </button>
25
25
  <span class="zoom-level">{{ Math.round(scale * 100) }}%</span>
26
+ <!-- 图片计数 -->
27
+ <span v-if="images.length > 1" class="image-counter">{{ currentIndex + 1 }} / {{ images.length }}</span>
26
28
  <button class="zoom-btn close-btn" @click="close" title="关闭">
27
29
  <X :size="16" />
28
30
  </button>
29
31
  </div>
30
32
 
33
+ <!-- 左切换按钮 -->
34
+ <button
35
+ v-if="images.length > 1"
36
+ class="nav-btn nav-btn-left"
37
+ :class="{ 'nav-btn-disabled': currentIndex <= 0 }"
38
+ :disabled="currentIndex <= 0"
39
+ @click="goPrev"
40
+ title="上一张"
41
+ >
42
+ <ChevronLeft :size="28" />
43
+ </button>
44
+
31
45
  <!-- 图片容器 -->
32
46
  <div
33
47
  class="image-wrapper"
@@ -35,16 +49,28 @@
35
49
  transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
36
50
  transformOrigin: 'center center'
37
51
  }"
38
- v-html="imageContent"
52
+ v-html="currentContent"
39
53
  ></div>
54
+
55
+ <!-- 右切换按钮 -->
56
+ <button
57
+ v-if="images.length > 1"
58
+ class="nav-btn nav-btn-right"
59
+ :class="{ 'nav-btn-disabled': currentIndex >= images.length - 1 }"
60
+ :disabled="currentIndex >= images.length - 1"
61
+ @click="goNext"
62
+ title="下一张"
63
+ >
64
+ <ChevronRight :size="28" />
65
+ </button>
40
66
  </div>
41
67
  </div>
42
68
  </teleport>
43
69
  </template>
44
70
 
45
71
  <script setup>
46
- import { ref } from 'vue'
47
- import { ZoomIn, ZoomOut, RotateCcw, X } from 'lucide-vue-next'
72
+ import { ref, computed } from 'vue'
73
+ import { ZoomIn, ZoomOut, RotateCcw, X, ChevronLeft, ChevronRight } from 'lucide-vue-next'
48
74
 
49
75
  // Props
50
76
  const props = defineProps({
@@ -52,14 +78,33 @@ const props = defineProps({
52
78
  type: Boolean,
53
79
  default: false
54
80
  },
81
+ // 兼容单图模式
55
82
  imageContent: {
56
83
  type: String,
57
84
  default: ''
85
+ },
86
+ // 图片列表(多图切换)
87
+ images: {
88
+ type: Array,
89
+ default: () => []
90
+ },
91
+ // 当前图片索引
92
+ currentIndex: {
93
+ type: Number,
94
+ default: 0
58
95
  }
59
96
  })
60
97
 
61
98
  // Emits
62
- const emit = defineEmits(['close'])
99
+ const emit = defineEmits(['close', 'update:currentIndex'])
100
+
101
+ // 当前展示内容:优先使用列表模式
102
+ const currentContent = computed(() => {
103
+ if (props.images.length > 0) {
104
+ return props.images[props.currentIndex] || ''
105
+ }
106
+ return props.imageContent
107
+ })
63
108
 
64
109
  // 缩放和拖拽状态
65
110
  const scale = ref(1)
@@ -95,6 +140,21 @@ function resetZoom() {
95
140
  translateY.value = 0
96
141
  }
97
142
 
143
+ // 切换图片时重置缩放
144
+ function goPrev() {
145
+ if (props.currentIndex > 0) {
146
+ resetZoom()
147
+ emit('update:currentIndex', props.currentIndex - 1)
148
+ }
149
+ }
150
+
151
+ function goNext() {
152
+ if (props.currentIndex < props.images.length - 1) {
153
+ resetZoom()
154
+ emit('update:currentIndex', props.currentIndex + 1)
155
+ }
156
+ }
157
+
98
158
  // 关闭
99
159
  function close() {
100
160
  resetZoom()
@@ -111,12 +171,9 @@ function handleOverlayClick(event) {
111
171
  // 处理滚轮缩放
112
172
  function handleWheel(event) {
113
173
  event.preventDefault()
114
-
115
- // 使用比例缩放,每次滚轮缩放 2%,体感更平滑
116
174
  const zoomFactor = 0.02
117
175
  const direction = event.deltaY > 0 ? -1 : 1
118
176
  const newScale = Math.max(minScale, Math.min(maxScale, scale.value * (1 + direction * zoomFactor)))
119
-
120
177
  if (newScale !== scale.value) {
121
178
  scale.value = newScale
122
179
  }
@@ -124,8 +181,7 @@ function handleWheel(event) {
124
181
 
125
182
  // 处理鼠标按下
126
183
  function handleMouseDown(event) {
127
- if (event.target.closest('.zoom-toolbar')) return
128
-
184
+ if (event.target.closest('.zoom-toolbar') || event.target.closest('.nav-btn')) return
129
185
  isDragging.value = true
130
186
  lastMouseX.value = event.clientX
131
187
  lastMouseY.value = event.clientY
@@ -135,13 +191,10 @@ function handleMouseDown(event) {
135
191
  // 处理鼠标移动
136
192
  function handleMouseMove(event) {
137
193
  if (!isDragging.value) return
138
-
139
194
  const deltaX = event.clientX - lastMouseX.value
140
195
  const deltaY = event.clientY - lastMouseY.value
141
-
142
196
  translateX.value += deltaX
143
197
  translateY.value += deltaY
144
-
145
198
  lastMouseX.value = event.clientX
146
199
  lastMouseY.value = event.clientY
147
200
  }
@@ -154,7 +207,6 @@ function handleMouseUp() {
154
207
  // 监听键盘事件
155
208
  function handleKeyDown(event) {
156
209
  if (!props.visible) return
157
-
158
210
  switch (event.key) {
159
211
  case 'Escape':
160
212
  close()
@@ -169,10 +221,15 @@ function handleKeyDown(event) {
169
221
  case '0':
170
222
  resetZoom()
171
223
  break
224
+ case 'ArrowLeft':
225
+ goPrev()
226
+ break
227
+ case 'ArrowRight':
228
+ goNext()
229
+ break
172
230
  }
173
231
  }
174
232
 
175
- // 添加键盘监听
176
233
  if (typeof window !== 'undefined') {
177
234
  window.addEventListener('keydown', handleKeyDown)
178
235
  }
@@ -261,6 +318,53 @@ if (typeof window !== 'undefined') {
261
318
  text-align: center;
262
319
  }
263
320
 
321
+ .image-counter {
322
+ font-size: 12px;
323
+ font-weight: 600;
324
+ color: #718096;
325
+ padding: 0 4px;
326
+ white-space: nowrap;
327
+ }
328
+
329
+ /* 左右切换按钮 */
330
+ .nav-btn {
331
+ position: absolute;
332
+ top: 50%;
333
+ transform: translateY(-50%);
334
+ z-index: 10000;
335
+ pointer-events: auto;
336
+ display: flex;
337
+ align-items: center;
338
+ justify-content: center;
339
+ width: 44px;
340
+ height: 44px;
341
+ border: none;
342
+ border-radius: 50%;
343
+ background: rgba(255, 255, 255, 0.9);
344
+ color: #2d3748;
345
+ cursor: pointer;
346
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
347
+ transition: all 0.2s;
348
+ }
349
+
350
+ .nav-btn:hover:not(:disabled) {
351
+ background: #fff;
352
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
353
+ }
354
+
355
+ .nav-btn-disabled {
356
+ opacity: 0.3;
357
+ cursor: not-allowed;
358
+ }
359
+
360
+ .nav-btn-left {
361
+ left: 20px;
362
+ }
363
+
364
+ .nav-btn-right {
365
+ right: 20px;
366
+ }
367
+
264
368
  .image-wrapper {
265
369
  transition: transform 0.2s ease-out;
266
370
  cursor: grab;
@@ -284,4 +388,4 @@ if (typeof window !== 'undefined') {
284
388
  width: auto !important;
285
389
  height: auto !important;
286
390
  }
287
- </style>
391
+ </style>
@@ -160,15 +160,24 @@ export function useDocManager() {
160
160
  }
161
161
  return
162
162
  }
163
- // 图片放大
164
- if (target.tagName === 'IMG' && target.classList.contains('zoomable-image')) {
165
- onZoom(`<img src="${target.src}" alt="${target.alt || ''}" style="max-width: 100%; height: auto;" />`)
166
- return
167
- }
168
- // Mermaid 图表放大
163
+ // 图片/Mermaid 放大(收集所有可放大元素,支持左右切换)
164
+ const isImg = target.tagName === 'IMG' && target.classList.contains('zoomable-image')
169
165
  const mermaidEl = target.closest('.mermaid')
170
- if (mermaidEl && mermaidEl.classList.contains('zoomable-image')) {
171
- onZoom(mermaidEl.innerHTML)
166
+ const isMermaid = mermaidEl && mermaidEl.classList.contains('zoomable-image')
167
+ if (isImg || isMermaid) {
168
+ const container = document.querySelector('.markdown-content')
169
+ if (!container) return
170
+ // 收集所有可放大元素
171
+ const allZoomable = [...container.querySelectorAll('.zoomable-image')]
172
+ const images = allZoomable.map(el => {
173
+ if (el.tagName === 'IMG') {
174
+ return `<img src="${el.src}" alt="${el.alt || ''}" style="max-width: 100%; height: auto;" />`
175
+ }
176
+ return el.innerHTML
177
+ })
178
+ const clickedEl = isImg ? target : mermaidEl
179
+ const index = allZoomable.indexOf(clickedEl)
180
+ onZoom({ images, index: Math.max(index, 0) })
172
181
  }
173
182
  }
174
183
 
@@ -3,6 +3,7 @@ import {
3
3
  Document, Packer, Paragraph, TextRun, HeadingLevel,
4
4
  Table, TableRow, TableCell, WidthType, BorderStyle,
5
5
  ImageRun, AlignmentType, ShadingType,
6
+ ExternalHyperlink,
6
7
  convertInchesToTwip
7
8
  } from 'docx'
8
9
  import { saveAs } from 'file-saver'
@@ -83,7 +84,7 @@ const HEADING_MAP = {
83
84
  H6: HeadingLevel.HEADING_6,
84
85
  }
85
86
 
86
- // 从 DOM 节点提取内联文本 runs
87
+ // 从 DOM 节点提取内联文本 runs(支持超链接、高亮、上下标、内联图片)
87
88
  function extractTextRuns(node, inherited = {}) {
88
89
  const runs = []
89
90
  if (!node) return runs
@@ -112,16 +113,45 @@ function extractTextRuns(node, inherited = {}) {
112
113
  ...inherited,
113
114
  }))
114
115
  } else if (tag === 'A') {
115
- runs.push(new TextRun({
116
- text: child.textContent,
117
- color: '4a6cf7',
118
- underline: {},
119
- ...inherited,
120
- }))
116
+ const href = child.getAttribute('href') || ''
117
+ // 生成真正的可点击超链接
118
+ if (href && (href.startsWith('http') || href.startsWith('mailto:'))) {
119
+ runs.push(new ExternalHyperlink({
120
+ children: [new TextRun({
121
+ text: child.textContent,
122
+ style: 'Hyperlink',
123
+ color: '4a6cf7',
124
+ underline: {},
125
+ ...inherited,
126
+ })],
127
+ link: href,
128
+ }))
129
+ } else {
130
+ runs.push(new TextRun({
131
+ text: child.textContent,
132
+ color: '4a6cf7',
133
+ underline: {},
134
+ ...inherited,
135
+ }))
136
+ }
121
137
  } else if (tag === 'DEL' || tag === 'S') {
122
138
  runs.push(...extractTextRuns(child, { ...inherited, strike: true }))
139
+ } else if (tag === 'MARK') {
140
+ // 高亮文本
141
+ runs.push(...extractTextRuns(child, {
142
+ ...inherited,
143
+ shading: { type: ShadingType.CLEAR, fill: 'ffff00' },
144
+ }))
145
+ } else if (tag === 'SUP') {
146
+ runs.push(...extractTextRuns(child, { ...inherited, superScript: true }))
147
+ } else if (tag === 'SUB') {
148
+ runs.push(...extractTextRuns(child, { ...inherited, subScript: true }))
123
149
  } else if (tag === 'BR') {
124
150
  runs.push(new TextRun({ break: 1 }))
151
+ } else if (tag === 'IMG') {
152
+ // 内联图片标记为占位符(实际图片在段落级处理)
153
+ const alt = child.getAttribute('alt') || '[图片]'
154
+ runs.push(new TextRun({ text: alt, color: '999999', italics: true }))
125
155
  } else {
126
156
  runs.push(...extractTextRuns(child, inherited))
127
157
  }
@@ -175,7 +205,7 @@ function parseList(listEl, level = 0) {
175
205
  const isOrdered = listEl.tagName === 'OL'
176
206
  const idx = Array.from(listEl.children).indexOf(li)
177
207
 
178
- // 提取文本(排除嵌套列表)
208
+ // 提取文本(排除嵌套列表,支持 li 内 p 包裹的多段落)
179
209
  const textRuns = []
180
210
  if (prefix) {
181
211
  textRuns.push(new TextRun({ text: prefix, font: 'Consolas', size: 20 }))
@@ -184,21 +214,32 @@ function parseList(listEl, level = 0) {
184
214
  if (child.nodeType === Node.TEXT_NODE) {
185
215
  const t = child.textContent.trim()
186
216
  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))
217
+ } else if (child.nodeType === Node.ELEMENT_NODE) {
218
+ const cTag = child.tagName
219
+ if (cTag === 'UL' || cTag === 'OL') continue // 嵌套列表单独处理
220
+ if (cTag === 'INPUT') continue // 跳过 checkbox 本身
221
+ // li 内的 p 标签:提取内容并在段落间加换行
222
+ if (cTag === 'P') {
223
+ if (textRuns.length > 0) {
224
+ textRuns.push(new TextRun({ break: 1 }))
225
+ }
226
+ textRuns.push(...extractTextRuns(child))
227
+ } else {
228
+ textRuns.push(...extractTextRuns(child))
229
+ }
190
230
  }
191
231
  }
192
232
 
193
- const bullet = isOrdered ? `${idx + 1}. ` : ' '
194
- const indent = level * 360
233
+ const bullet = isOrdered ? `${idx + 1}. ` : '\u2022 '
234
+ const baseIndent = 360
235
+ const indent = baseIndent + level * 360
195
236
  items.push(new Paragraph({
196
237
  children: [
197
- new TextRun({ text: ' '.repeat(level) + bullet }),
238
+ new TextRun({ text: bullet }),
198
239
  ...textRuns,
199
240
  ],
200
241
  spacing: { before: 40, after: 40 },
201
- indent: { left: indent },
242
+ indent: { left: indent, hanging: 240 },
202
243
  }))
203
244
 
204
245
  // 嵌套列表
@@ -228,14 +269,45 @@ async function parseDomToDocx(contentEl) {
228
269
  continue
229
270
  }
230
271
 
231
- // 段落
272
+ // 段落(处理内嵌图片)
232
273
  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
- }))
274
+ // 检查段落内是否有图片
275
+ const imgs = node.querySelectorAll('img')
276
+ if (imgs.length > 0) {
277
+ // 先输出文本部分
278
+ const runs = extractTextRuns(node)
279
+ const textOnly = runs.filter(r => !(r instanceof ImageRun))
280
+ if (textOnly.length > 0) {
281
+ elements.push(new Paragraph({
282
+ children: textOnly,
283
+ spacing: { before: 80, after: 80 },
284
+ }))
285
+ }
286
+ // 再逐个输出图片
287
+ for (const img of imgs) {
288
+ try {
289
+ const imgData = await fetchImageAsBytes(img.src)
290
+ if (imgData) {
291
+ elements.push(new Paragraph({
292
+ children: [new ImageRun({
293
+ data: imgData.bytes,
294
+ transformation: { width: imgData.width, height: imgData.height },
295
+ type: 'png',
296
+ })],
297
+ spacing: { before: 120, after: 120 },
298
+ alignment: AlignmentType.CENTER,
299
+ }))
300
+ }
301
+ } catch { /* 跳过无法加载的图片 */ }
302
+ }
303
+ } else {
304
+ const runs = extractTextRuns(node)
305
+ if (runs.length > 0) {
306
+ elements.push(new Paragraph({
307
+ children: runs,
308
+ spacing: { before: 80, after: 80 },
309
+ }))
310
+ }
239
311
  }
240
312
  continue
241
313
  }
@@ -354,18 +426,72 @@ async function parseDomToDocx(contentEl) {
354
426
  continue
355
427
  }
356
428
 
357
- // 引用块
429
+ // 引用块:递归处理内部块级元素,保留完整结构
358
430
  if (tag === 'BLOCKQUOTE') {
359
- const runs = extractTextRuns(node)
360
- elements.push(new Paragraph({
361
- children: runs,
362
- spacing: { before: 80, after: 80 },
431
+ const bqStyle = {
363
432
  indent: { left: 400 },
364
433
  border: {
365
434
  left: { style: BorderStyle.SINGLE, size: 6, color: 'cccccc' },
366
435
  },
367
436
  shading: { type: ShadingType.CLEAR, fill: 'fafafa' },
368
- }))
437
+ }
438
+ if (node.children.length > 0) {
439
+ for (const child of node.children) {
440
+ const childTag = child.tagName
441
+ // 嵌套引用块:增加缩进
442
+ if (childTag === 'BLOCKQUOTE') {
443
+ const innerChildren = child.children.length > 0 ? child.children : [child]
444
+ for (const inner of innerChildren) {
445
+ const runs = extractTextRuns(inner)
446
+ if (runs.length > 0) {
447
+ elements.push(new Paragraph({
448
+ children: runs,
449
+ spacing: { before: 80, after: 80 },
450
+ indent: { left: 800 },
451
+ border: {
452
+ left: { style: BorderStyle.SINGLE, size: 6, color: 'aaaaaa' },
453
+ },
454
+ shading: { type: ShadingType.CLEAR, fill: 'f5f5f5' },
455
+ }))
456
+ }
457
+ }
458
+ } else if (childTag === 'UL' || childTag === 'OL') {
459
+ // 引用块内的列表
460
+ const listItems = parseList(child)
461
+ for (const item of listItems) {
462
+ elements.push(item)
463
+ }
464
+ } else if (childTag === 'PRE' || child.classList?.contains('code-block-wrapper')) {
465
+ // 引用块内的代码块,走正常代码块逻辑但加引用样式
466
+ const runs = extractTextRuns(child)
467
+ if (runs.length > 0) {
468
+ elements.push(new Paragraph({
469
+ children: runs,
470
+ spacing: { before: 80, after: 80 },
471
+ ...bqStyle,
472
+ }))
473
+ }
474
+ } else {
475
+ const runs = extractTextRuns(child)
476
+ if (runs.length > 0) {
477
+ elements.push(new Paragraph({
478
+ children: runs,
479
+ spacing: { before: 80, after: 80 },
480
+ ...bqStyle,
481
+ }))
482
+ }
483
+ }
484
+ }
485
+ } else {
486
+ const runs = extractTextRuns(node)
487
+ if (runs.length > 0) {
488
+ elements.push(new Paragraph({
489
+ children: runs,
490
+ spacing: { before: 80, after: 80 },
491
+ ...bqStyle,
492
+ }))
493
+ }
494
+ }
369
495
  continue
370
496
  }
371
497