@tnotesjs/core 0.1.22 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +87 -87
  2. package/package.json +1 -1
  3. package/types/config.ts +60 -60
  4. package/types/index.ts +11 -11
  5. package/types/note.ts +33 -33
  6. package/vitepress/assets/icons/icon__check.svg +3 -3
  7. package/vitepress/assets/icons/icon__clipboard.svg +7 -7
  8. package/vitepress/assets/icons/icon__close.svg +1 -1
  9. package/vitepress/assets/icons/icon__collapse.svg +1 -1
  10. package/vitepress/assets/icons/icon__confirm.svg +1 -1
  11. package/vitepress/assets/icons/icon__copy.svg +4 -4
  12. package/vitepress/assets/icons/icon__focus.svg +1 -1
  13. package/vitepress/assets/icons/icon__fold.svg +3 -3
  14. package/vitepress/assets/icons/icon__folder.svg +1 -1
  15. package/vitepress/assets/icons/icon__fullscreen.svg +1 -1
  16. package/vitepress/assets/icons/icon__fullscreen_exit.svg +1 -1
  17. package/vitepress/assets/icons/icon__github.svg +3 -3
  18. package/vitepress/assets/icons/icon__mindmap.svg +1 -1
  19. package/vitepress/assets/icons/icon__next.svg +1 -1
  20. package/vitepress/assets/icons/icon__number_gray.svg +1 -1
  21. package/vitepress/assets/icons/icon__number_purple.svg +1 -1
  22. package/vitepress/assets/icons/icon__prev.svg +1 -1
  23. package/vitepress/assets/icons/icon__restore.svg +1 -1
  24. package/vitepress/assets/icons/icon__rotate.svg +4 -4
  25. package/vitepress/assets/icons/icon__search.svg +1 -1
  26. package/vitepress/assets/icons/icon__sidebar_collapsed.svg +1 -1
  27. package/vitepress/assets/icons/icon__sidebar_opened.svg +1 -1
  28. package/vitepress/assets/icons/icon__totop.svg +5 -5
  29. package/vitepress/assets/icons/icon__vscode.svg +5 -5
  30. package/vitepress/assets/icons/icon__zoom_fit.svg +1 -1
  31. package/vitepress/assets/icons/icon__zoom_in.svg +1 -1
  32. package/vitepress/assets/icons/icon__zoom_out.svg +1 -1
  33. package/vitepress/assets/icons/icon__zoom_reset.svg +1 -1
  34. package/vitepress/assets/icons/index.ts +39 -39
  35. package/vitepress/components/BilibiliOutsidePlayer/BilibiliOutsidePlayer.vue +20 -20
  36. package/vitepress/components/CodeBlockFullscreen/CodeBlockFullscreen.vue +369 -369
  37. package/vitepress/components/CodeBlockFullscreen/index.ts +209 -209
  38. package/vitepress/components/CodeBlockFullscreen/styles.css +126 -126
  39. package/vitepress/components/Discussions/Discussions.vue +242 -242
  40. package/vitepress/components/EnWordList/EnWordList.vue +664 -664
  41. package/vitepress/components/EnWordList/RightClickMenu.vue +87 -87
  42. package/vitepress/components/Footprints/Footprints.vue +472 -472
  43. package/vitepress/components/Layout/AboutModal.vue +329 -329
  44. package/vitepress/components/Layout/AboutPanel.vue +485 -485
  45. package/vitepress/components/Layout/ContentCollapse.vue +591 -591
  46. package/vitepress/components/Layout/CustomSidebar.vue +817 -817
  47. package/vitepress/components/Layout/DocBeforeControls.vue +145 -145
  48. package/vitepress/components/Layout/DocFooter.vue +233 -233
  49. package/vitepress/components/Layout/ImagePreview.vue +485 -485
  50. package/vitepress/components/Layout/Layout.vue +744 -744
  51. package/vitepress/components/Layout/NoteStatus.vue +140 -140
  52. package/vitepress/components/Layout/SidebarItems.vue +1083 -1083
  53. package/vitepress/components/Layout/SidebarNavBefore.vue +170 -170
  54. package/vitepress/components/Layout/SidebarResizeHandle.vue +311 -311
  55. package/vitepress/components/Layout/Swiper.vue +167 -167
  56. package/vitepress/components/Layout/ToggleFullContent.vue +45 -45
  57. package/vitepress/components/Layout/ToggleSidebar.vue +35 -35
  58. package/vitepress/components/Layout/composables/useCollapseControl.ts +88 -88
  59. package/vitepress/components/Layout/composables/useNoteConfig.ts +119 -119
  60. package/vitepress/components/Layout/composables/useNoteSave.ts +178 -178
  61. package/vitepress/components/Layout/composables/useNoteValidation.ts +85 -85
  62. package/vitepress/components/Layout/composables/useRedirect.ts +113 -113
  63. package/vitepress/components/Layout/composables/useSidebarLayout.ts +122 -122
  64. package/vitepress/components/Layout/composables/useVSCodeIntegration.ts +85 -85
  65. package/vitepress/components/Layout/homeReadme.data.ts +124 -124
  66. package/vitepress/components/MarkMap/MarkMap.vue +562 -562
  67. package/vitepress/components/Mermaid/Mermaid.vue +644 -644
  68. package/vitepress/components/NotesTable/NotesTable.vue +175 -175
  69. package/vitepress/components/NotesTable/README.md +67 -67
  70. package/vitepress/components/Settings/Settings.vue +711 -711
  71. package/vitepress/components/SidebarCard/MindMapView.vue +484 -484
  72. package/vitepress/components/SidebarCard/NotesTrendChart.vue +108 -108
  73. package/vitepress/components/SidebarCard/SidebarCard.vue +945 -945
  74. package/vitepress/components/Tooltip/Tooltip.vue +70 -70
  75. package/vitepress/components/constants.ts +109 -109
  76. package/vitepress/components/notesConfig.data.ts +73 -73
  77. package/vitepress/components/sidebar.data.ts +59 -59
  78. package/vitepress/components/tnotes-config.data.ts +21 -21
  79. package/vitepress/components/utils.ts +26 -26
  80. package/vitepress/config/index.ts +169 -169
  81. package/vitepress/configs/constants.ts +26 -26
  82. package/vitepress/configs/head.config.ts +25 -25
  83. package/vitepress/configs/index.ts +9 -9
  84. package/vitepress/configs/markdown-it.d.ts +23 -23
  85. package/vitepress/configs/markdown.config.ts +368 -368
  86. package/vitepress/configs/theme.config.ts +108 -108
  87. package/vitepress/plugins/buildProgressPlugin.ts +434 -434
  88. package/vitepress/plugins/getNoteByConfigIdPlugin.ts +107 -107
  89. package/vitepress/plugins/renameNotePlugin.ts +68 -68
  90. package/vitepress/plugins/sidebarStructurePlugin.ts +260 -260
  91. package/vitepress/plugins/updateConfigPlugin.ts +63 -63
  92. package/vitepress/theme/index.ts +137 -137
  93. package/vitepress/theme/styles/base.scss +66 -64
  94. package/vitepress/theme/styles/components/404.scss +31 -31
  95. package/vitepress/theme/styles/components/collapse.scss +172 -172
  96. package/vitepress/theme/styles/components/markmap.scss +101 -101
  97. package/vitepress/theme/styles/components/swiper.scss +255 -255
  98. package/vitepress/theme/styles/index.scss +25 -25
  99. package/vitepress/theme/styles/layout.scss +78 -78
  100. package/vitepress/theme/styles/utilities.scss +39 -39
  101. package/vitepress/theme/styles/vitepress-override.scss +25 -25
@@ -1,485 +1,485 @@
1
- <template>
2
- <!-- #region img preview -->
3
- <div
4
- v-show="preview.visible"
5
- class="tnPreview"
6
- @click.self="closePreview"
7
- @wheel.prevent="onWheel"
8
- >
9
- <!-- 左侧切换按钮 -->
10
- <button
11
- v-if="preview.images.length > 1"
12
- class="nav navLeft"
13
- @click.stop="prevImage"
14
- >
15
- <img :src="icon__prev" alt="prev" />
16
- </button>
17
- <!-- 右侧切换按钮 -->
18
- <button
19
- v-if="preview.images.length > 1"
20
- class="nav navRight"
21
- @click.stop="nextImage"
22
- >
23
- <img :src="icon__next" alt="next" />
24
- </button>
25
-
26
- <div class="toolbar">
27
- <button @click.stop="zoomOut" title="缩小">
28
- <img :src="icon__zoom_out" alt="zoom out" />
29
- </button>
30
- <button @click.stop="resetTransform" title="还原">
31
- <img :src="icon__restore" alt="restore" />
32
- </button>
33
- <button @click.stop="zoomIn" title="放大">
34
- <img :src="icon__zoom_in" alt="zoom in" />
35
- </button>
36
- <button class="close" @click.stop="closePreview" title="关闭">
37
- <img :src="icon__close" alt="close" />
38
- </button>
39
- </div>
40
-
41
- <img
42
- ref="previewImg"
43
- class="img"
44
- :class="{ dragging: isDragging }"
45
- :src="preview.src"
46
- :style="previewStyle"
47
- @pointerdown="onPointerDown"
48
- draggable="false"
49
- />
50
-
51
- <!-- 底部页码指示器 -->
52
- <div v-if="preview.images.length > 1" class="counter">
53
- {{ preview.index + 1 }} / {{ preview.images.length }}
54
- </div>
55
- </div>
56
- <!-- #endregion -->
57
- </template>
58
-
59
- <script setup>
60
- import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
61
-
62
- import {
63
- icon__close,
64
- icon__next,
65
- icon__prev,
66
- icon__restore,
67
- icon__zoom_in,
68
- icon__zoom_out,
69
- } from '../../assets/icons'
70
-
71
-
72
- const preview = ref({
73
- visible: false,
74
- src: '',
75
- scale: 1,
76
- tx: 0,
77
- ty: 0,
78
- images: [], // 当前文档里的所有图片 src
79
- index: -1, // 当前打开的索引
80
- })
81
-
82
- const previewImg = ref(null)
83
-
84
- const isDragging = ref(false)
85
-
86
- const previewStyle = computed(() => ({
87
- transform: `translate(${preview.value.tx}px, ${preview.value.ty}px) scale(${preview.value.scale})`,
88
- cursor: 'grab',
89
- }))
90
-
91
- function openPreview(src) {
92
- // 收集当前文档区所有 img,排除:
93
- // 1. data-preview="false" 的图片
94
- // 2. .tn-preview-ignore 容器内的图片
95
- // 3. 组件工具栏按钮内的图标 (button > img)
96
- // 4. 导航按钮内的图标
97
- const imgs = Array.from(document.querySelectorAll('.vp-doc img'))
98
- .filter((img) => {
99
- // 排除明确标记不预览的
100
- if (
101
- img.dataset?.preview === 'false' ||
102
- img.closest('.tn-preview-ignore')
103
- ) {
104
- return false
105
- }
106
-
107
- // 排除按钮内的图标 (工具栏图标)
108
- if (img.closest('button')) {
109
- return false
110
- }
111
-
112
- // 排除小尺寸图标 (通常是图标而非内容图片)
113
- if (img.naturalWidth < 50 && img.naturalHeight < 50) {
114
- return false
115
- }
116
-
117
- return true
118
- })
119
- .map((img) => img.currentSrc || img.src)
120
-
121
- preview.value.images = imgs
122
- preview.value.index = imgs.indexOf(src)
123
-
124
- preview.value.visible = true
125
- preview.value.src = src
126
- preview.value.scale = 1
127
- preview.value.tx = 0
128
- preview.value.ty = 0
129
- // 锁滚动(可选)
130
- document.documentElement.style.overflow = 'hidden'
131
- }
132
-
133
- function prevImage() {
134
- if (preview.value.images.length <= 1) return
135
- preview.value.index =
136
- (preview.value.index - 1 + preview.value.images.length) %
137
- preview.value.images.length
138
- preview.value.src = preview.value.images[preview.value.index]
139
- resetTransform()
140
- }
141
-
142
- function nextImage() {
143
- if (preview.value.images.length <= 1) return
144
- preview.value.index = (preview.value.index + 1) % preview.value.images.length
145
- preview.value.src = preview.value.images[preview.value.index]
146
- resetTransform()
147
- }
148
-
149
- function closePreview() {
150
- preview.value.visible = false
151
- document.documentElement.style.overflow = ''
152
- }
153
-
154
- function zoomIn() {
155
- preview.value.scale = Math.min(3, preview.value.scale + 0.1)
156
- }
157
-
158
- function zoomOut() {
159
- preview.value.scale = Math.max(0.2, preview.value.scale - 0.1)
160
- }
161
-
162
- function resetTransform() {
163
- preview.value.scale = 1
164
- preview.value.tx = 0
165
- preview.value.ty = 0
166
- }
167
-
168
- // 滚轮缩放(以视口中心为参考,简单好用)
169
- function onWheel(e) {
170
- const delta = -e.deltaY
171
- const factor = delta > 0 ? 1.1 : 1 / 1.1
172
- const next = Math.min(6, Math.max(0.2, preview.value.scale * factor))
173
- preview.value.scale = next
174
- }
175
-
176
- // 拖拽(Pointer Events,兼容鼠标/触屏)
177
- let dragging = false
178
- let startX = 0
179
- let startY = 0
180
- let baseX = 0
181
- let baseY = 0
182
-
183
- function onPointerDown(e) {
184
- dragging = true
185
- isDragging.value = true
186
- e.target.setPointerCapture?.(e.pointerId)
187
- startX = e.clientX
188
- startY = e.clientY
189
- baseX = preview.value.tx
190
- baseY = preview.value.ty
191
- window.addEventListener('pointermove', onPointerMove)
192
- window.addEventListener('pointerup', onPointerUp, { once: true })
193
- }
194
-
195
- function onPointerMove(e) {
196
- if (!dragging) return
197
- preview.value.tx = baseX + (e.clientX - startX)
198
- preview.value.ty = baseY + (e.clientY - startY)
199
- }
200
-
201
- function onPointerUp() {
202
- dragging = false
203
- isDragging.value = false
204
- window.removeEventListener('pointermove', onPointerMove)
205
- }
206
-
207
- let downX = 0
208
- function onPointerDownSwiper(e) {
209
- downX = e.clientX
210
- }
211
-
212
- // 事件委托:拦截正文区域的 <img> 点击
213
- function onDocClick(e) {
214
- if (preview.value.visible) return // 预览层打开时忽略底下点击
215
- const target = e.target
216
- if (!target) return
217
-
218
- // 只处理正文:VitePress 主文档区常见容器:.vp-doc / .VPDoc .main
219
- const img = target.closest?.('img')
220
- if (!img) return
221
- const inDoc = img.closest('.main')
222
- if (!inDoc) return
223
-
224
- // 判断是否在 swiper 中切换图片
225
- const inSwiper = img.closest('.swiper-container')
226
- if (inSwiper) {
227
- const diffX = Math.abs(e.clientX - downX)
228
- // 只要水平位移超过阈值,就认为是滑动,而不是点击
229
- if (diffX > 5) {
230
- return
231
- }
232
- }
233
-
234
- // 明确排除:带 data-preview="false" 或 .tn-preview-ignore 的图片
235
- if (img.dataset?.preview === 'false' || img.closest('.tn-preview-ignore'))
236
- return
237
-
238
- // 排除按钮内的图标 (组件工具栏)
239
- if (img.closest('button')) return
240
-
241
- // 排除小尺寸图标
242
- if (img.naturalWidth < 50 && img.naturalHeight < 50) return
243
-
244
- // !禁止图片包含超链接
245
- // TODO
246
- // 做法 1:包含超链接的图片不支持 preview;
247
- // 做法 2:如果图片包了链接,阻止跳转,支持 preview;【当前的方案】
248
- // 做法 3:维护一个白名单,对特定类型的图片进行特殊处理;
249
- const a = img.closest('a')
250
- if (a) {
251
- e.preventDefault()
252
- e.stopPropagation()
253
- }
254
-
255
- const src = img.currentSrc || img.src
256
- if (src) openPreview(src)
257
- }
258
-
259
- // 键盘 ESC 关闭
260
- function onKeydown(e) {
261
- if (!preview.value.visible) return
262
- if (e.key === 'Escape') closePreview()
263
- else if (e.key === 'ArrowLeft') prevImage()
264
- else if (e.key === 'ArrowRight') nextImage()
265
- else if (e.key === 'Enter') toggleZoom()
266
- else if (e.key === 'ArrowUp') zoomIn()
267
- else if (e.key === 'ArrowDown') zoomOut()
268
- }
269
-
270
- onMounted(() => {
271
- document.addEventListener('click', onDocClick, true)
272
- document.addEventListener('keydown', onKeydown)
273
- document.addEventListener('pointerdown', onPointerDownSwiper, true)
274
- })
275
-
276
- onBeforeUnmount(() => {
277
- document.removeEventListener('click', onDocClick, true)
278
- document.removeEventListener('keydown', onKeydown)
279
- document.removeEventListener('pointerdown', onPointerDownSwiper, true)
280
- })
281
- </script>
282
-
283
- <style scoped lang="scss">
284
- /* 预览遮罩层 */
285
- .tnPreview {
286
- position: fixed;
287
- inset: 0;
288
- z-index: 3000;
289
- background: rgba(0, 0, 0, 0.85);
290
- display: flex;
291
- align-items: center;
292
- justify-content: center;
293
- backdrop-filter: blur(4px);
294
- animation: fadeIn 0.2s ease-out;
295
- }
296
-
297
- @keyframes fadeIn {
298
- from {
299
- opacity: 0;
300
- }
301
- to {
302
- opacity: 1;
303
- }
304
- }
305
-
306
- /* 工具栏 */
307
- .toolbar {
308
- position: absolute;
309
- top: 16px;
310
- right: 16px;
311
- display: flex;
312
- gap: 10px;
313
- z-index: 2;
314
-
315
- button {
316
- width: 36px;
317
- height: 36px;
318
- display: flex;
319
- align-items: center;
320
- justify-content: center;
321
- background: rgba(42, 42, 42, 0.85);
322
- border: 1px solid rgba(255, 255, 255, 0.15);
323
- border-radius: 8px;
324
- cursor: pointer;
325
- transition: all 0.2s ease;
326
-
327
- img {
328
- width: 18px;
329
- height: 18px;
330
- transition: transform 0.2s ease;
331
- }
332
-
333
- &:hover {
334
- background: rgba(255, 255, 255, 0.15);
335
- transform: translateY(-2px);
336
-
337
- img {
338
- transform: scale(1.1);
339
- }
340
- }
341
-
342
- &:active {
343
- transform: translateY(0);
344
- }
345
- }
346
- }
347
-
348
- .close {
349
- &:hover img {
350
- transform: rotate(90deg) scale(1.1) !important;
351
- }
352
- }
353
-
354
- /* 左右切换按钮 */
355
- .nav {
356
- position: absolute;
357
- top: 50%;
358
- transform: translateY(-50%);
359
- width: 56px;
360
- height: 56px;
361
- display: flex;
362
- align-items: center;
363
- justify-content: center;
364
- background: rgba(0, 0, 0, 0.3);
365
- border: none;
366
- border-radius: 50%;
367
- cursor: pointer;
368
- z-index: 2;
369
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
370
-
371
- img {
372
- width: 2rem;
373
- height: 2rem;
374
- transition: transform 0.3s ease;
375
- }
376
-
377
- &:hover {
378
- background: rgba(0, 0, 0, 0.7);
379
- transform: translateY(-50%) scale(1.15);
380
-
381
- img {
382
- filter: brightness(1.2);
383
- }
384
- }
385
-
386
- &:active {
387
- transform: translateY(-50%) scale(1.05);
388
- }
389
- }
390
-
391
- .navLeft {
392
- left: 24px;
393
-
394
- &:hover img {
395
- transform: translateX(-2px);
396
- }
397
- }
398
-
399
- .navRight {
400
- right: 24px;
401
-
402
- &:hover img {
403
- transform: translateX(2px);
404
- }
405
- }
406
-
407
- /* 预览图片 */
408
- .img {
409
- max-width: 90vw;
410
- max-height: 90vh;
411
- user-select: none;
412
- transition: transform 0.1s ease-out;
413
-
414
- &.dragging {
415
- cursor: grabbing !important;
416
- }
417
- }
418
-
419
- /* 底部页码指示器 */
420
- .counter {
421
- position: absolute;
422
- bottom: 20px;
423
- left: 50%;
424
- transform: translateX(-50%);
425
- background: rgba(0, 0, 0, 0.65);
426
- color: #fff;
427
- padding: 8px 16px;
428
- border-radius: 16px;
429
- font-size: 0.9rem;
430
- font-weight: 500;
431
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
432
- z-index: 2;
433
- user-select: none;
434
- backdrop-filter: blur(8px);
435
- border: 1px solid rgba(255, 255, 255, 0.1);
436
- }
437
-
438
- /* 响应式设计 */
439
- @media (max-width: 768px) {
440
- .nav {
441
- width: 48px;
442
- height: 48px;
443
-
444
- img {
445
- width: 1.5rem;
446
- height: 1.5rem;
447
- }
448
- }
449
-
450
- .navLeft {
451
- left: 12px;
452
- }
453
-
454
- .navRight {
455
- right: 12px;
456
- }
457
-
458
- .toolbar {
459
- top: 12px;
460
- right: 12px;
461
- gap: 8px;
462
-
463
- button {
464
- width: 32px;
465
- height: 32px;
466
-
467
- img {
468
- width: 16px;
469
- height: 16px;
470
- }
471
- }
472
- }
473
-
474
- .counter {
475
- bottom: 16px;
476
- font-size: 0.85rem;
477
- padding: 6px 14px;
478
- }
479
-
480
- .img {
481
- max-width: 95vw;
482
- max-height: 85vh;
483
- }
484
- }
485
- </style>
1
+ <template>
2
+ <!-- #region img preview -->
3
+ <div
4
+ v-show="preview.visible"
5
+ class="tnPreview"
6
+ @click.self="closePreview"
7
+ @wheel.prevent="onWheel"
8
+ >
9
+ <!-- 左侧切换按钮 -->
10
+ <button
11
+ v-if="preview.images.length > 1"
12
+ class="nav navLeft"
13
+ @click.stop="prevImage"
14
+ >
15
+ <img :src="icon__prev" alt="prev" />
16
+ </button>
17
+ <!-- 右侧切换按钮 -->
18
+ <button
19
+ v-if="preview.images.length > 1"
20
+ class="nav navRight"
21
+ @click.stop="nextImage"
22
+ >
23
+ <img :src="icon__next" alt="next" />
24
+ </button>
25
+
26
+ <div class="toolbar">
27
+ <button @click.stop="zoomOut" title="缩小">
28
+ <img :src="icon__zoom_out" alt="zoom out" />
29
+ </button>
30
+ <button @click.stop="resetTransform" title="还原">
31
+ <img :src="icon__restore" alt="restore" />
32
+ </button>
33
+ <button @click.stop="zoomIn" title="放大">
34
+ <img :src="icon__zoom_in" alt="zoom in" />
35
+ </button>
36
+ <button class="close" @click.stop="closePreview" title="关闭">
37
+ <img :src="icon__close" alt="close" />
38
+ </button>
39
+ </div>
40
+
41
+ <img
42
+ ref="previewImg"
43
+ class="img"
44
+ :class="{ dragging: isDragging }"
45
+ :src="preview.src"
46
+ :style="previewStyle"
47
+ @pointerdown="onPointerDown"
48
+ draggable="false"
49
+ />
50
+
51
+ <!-- 底部页码指示器 -->
52
+ <div v-if="preview.images.length > 1" class="counter">
53
+ {{ preview.index + 1 }} / {{ preview.images.length }}
54
+ </div>
55
+ </div>
56
+ <!-- #endregion -->
57
+ </template>
58
+
59
+ <script setup>
60
+ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
61
+
62
+ import {
63
+ icon__close,
64
+ icon__next,
65
+ icon__prev,
66
+ icon__restore,
67
+ icon__zoom_in,
68
+ icon__zoom_out,
69
+ } from '../../assets/icons'
70
+
71
+
72
+ const preview = ref({
73
+ visible: false,
74
+ src: '',
75
+ scale: 1,
76
+ tx: 0,
77
+ ty: 0,
78
+ images: [], // 当前文档里的所有图片 src
79
+ index: -1, // 当前打开的索引
80
+ })
81
+
82
+ const previewImg = ref(null)
83
+
84
+ const isDragging = ref(false)
85
+
86
+ const previewStyle = computed(() => ({
87
+ transform: `translate(${preview.value.tx}px, ${preview.value.ty}px) scale(${preview.value.scale})`,
88
+ cursor: 'grab',
89
+ }))
90
+
91
+ function openPreview(src) {
92
+ // 收集当前文档区所有 img,排除:
93
+ // 1. data-preview="false" 的图片
94
+ // 2. .tn-preview-ignore 容器内的图片
95
+ // 3. 组件工具栏按钮内的图标 (button > img)
96
+ // 4. 导航按钮内的图标
97
+ const imgs = Array.from(document.querySelectorAll('.vp-doc img'))
98
+ .filter((img) => {
99
+ // 排除明确标记不预览的
100
+ if (
101
+ img.dataset?.preview === 'false' ||
102
+ img.closest('.tn-preview-ignore')
103
+ ) {
104
+ return false
105
+ }
106
+
107
+ // 排除按钮内的图标 (工具栏图标)
108
+ if (img.closest('button')) {
109
+ return false
110
+ }
111
+
112
+ // 排除小尺寸图标 (通常是图标而非内容图片)
113
+ if (img.naturalWidth < 50 && img.naturalHeight < 50) {
114
+ return false
115
+ }
116
+
117
+ return true
118
+ })
119
+ .map((img) => img.currentSrc || img.src)
120
+
121
+ preview.value.images = imgs
122
+ preview.value.index = imgs.indexOf(src)
123
+
124
+ preview.value.visible = true
125
+ preview.value.src = src
126
+ preview.value.scale = 1
127
+ preview.value.tx = 0
128
+ preview.value.ty = 0
129
+ // 锁滚动(可选)
130
+ document.documentElement.style.overflow = 'hidden'
131
+ }
132
+
133
+ function prevImage() {
134
+ if (preview.value.images.length <= 1) return
135
+ preview.value.index =
136
+ (preview.value.index - 1 + preview.value.images.length) %
137
+ preview.value.images.length
138
+ preview.value.src = preview.value.images[preview.value.index]
139
+ resetTransform()
140
+ }
141
+
142
+ function nextImage() {
143
+ if (preview.value.images.length <= 1) return
144
+ preview.value.index = (preview.value.index + 1) % preview.value.images.length
145
+ preview.value.src = preview.value.images[preview.value.index]
146
+ resetTransform()
147
+ }
148
+
149
+ function closePreview() {
150
+ preview.value.visible = false
151
+ document.documentElement.style.overflow = ''
152
+ }
153
+
154
+ function zoomIn() {
155
+ preview.value.scale = Math.min(3, preview.value.scale + 0.1)
156
+ }
157
+
158
+ function zoomOut() {
159
+ preview.value.scale = Math.max(0.2, preview.value.scale - 0.1)
160
+ }
161
+
162
+ function resetTransform() {
163
+ preview.value.scale = 1
164
+ preview.value.tx = 0
165
+ preview.value.ty = 0
166
+ }
167
+
168
+ // 滚轮缩放(以视口中心为参考,简单好用)
169
+ function onWheel(e) {
170
+ const delta = -e.deltaY
171
+ const factor = delta > 0 ? 1.1 : 1 / 1.1
172
+ const next = Math.min(6, Math.max(0.2, preview.value.scale * factor))
173
+ preview.value.scale = next
174
+ }
175
+
176
+ // 拖拽(Pointer Events,兼容鼠标/触屏)
177
+ let dragging = false
178
+ let startX = 0
179
+ let startY = 0
180
+ let baseX = 0
181
+ let baseY = 0
182
+
183
+ function onPointerDown(e) {
184
+ dragging = true
185
+ isDragging.value = true
186
+ e.target.setPointerCapture?.(e.pointerId)
187
+ startX = e.clientX
188
+ startY = e.clientY
189
+ baseX = preview.value.tx
190
+ baseY = preview.value.ty
191
+ window.addEventListener('pointermove', onPointerMove)
192
+ window.addEventListener('pointerup', onPointerUp, { once: true })
193
+ }
194
+
195
+ function onPointerMove(e) {
196
+ if (!dragging) return
197
+ preview.value.tx = baseX + (e.clientX - startX)
198
+ preview.value.ty = baseY + (e.clientY - startY)
199
+ }
200
+
201
+ function onPointerUp() {
202
+ dragging = false
203
+ isDragging.value = false
204
+ window.removeEventListener('pointermove', onPointerMove)
205
+ }
206
+
207
+ let downX = 0
208
+ function onPointerDownSwiper(e) {
209
+ downX = e.clientX
210
+ }
211
+
212
+ // 事件委托:拦截正文区域的 <img> 点击
213
+ function onDocClick(e) {
214
+ if (preview.value.visible) return // 预览层打开时忽略底下点击
215
+ const target = e.target
216
+ if (!target) return
217
+
218
+ // 只处理正文:VitePress 主文档区常见容器:.vp-doc / .VPDoc .main
219
+ const img = target.closest?.('img')
220
+ if (!img) return
221
+ const inDoc = img.closest('.main')
222
+ if (!inDoc) return
223
+
224
+ // 判断是否在 swiper 中切换图片
225
+ const inSwiper = img.closest('.swiper-container')
226
+ if (inSwiper) {
227
+ const diffX = Math.abs(e.clientX - downX)
228
+ // 只要水平位移超过阈值,就认为是滑动,而不是点击
229
+ if (diffX > 5) {
230
+ return
231
+ }
232
+ }
233
+
234
+ // 明确排除:带 data-preview="false" 或 .tn-preview-ignore 的图片
235
+ if (img.dataset?.preview === 'false' || img.closest('.tn-preview-ignore'))
236
+ return
237
+
238
+ // 排除按钮内的图标 (组件工具栏)
239
+ if (img.closest('button')) return
240
+
241
+ // 排除小尺寸图标
242
+ if (img.naturalWidth < 50 && img.naturalHeight < 50) return
243
+
244
+ // !禁止图片包含超链接
245
+ // TODO
246
+ // 做法 1:包含超链接的图片不支持 preview;
247
+ // 做法 2:如果图片包了链接,阻止跳转,支持 preview;【当前的方案】
248
+ // 做法 3:维护一个白名单,对特定类型的图片进行特殊处理;
249
+ const a = img.closest('a')
250
+ if (a) {
251
+ e.preventDefault()
252
+ e.stopPropagation()
253
+ }
254
+
255
+ const src = img.currentSrc || img.src
256
+ if (src) openPreview(src)
257
+ }
258
+
259
+ // 键盘 ESC 关闭
260
+ function onKeydown(e) {
261
+ if (!preview.value.visible) return
262
+ if (e.key === 'Escape') closePreview()
263
+ else if (e.key === 'ArrowLeft') prevImage()
264
+ else if (e.key === 'ArrowRight') nextImage()
265
+ else if (e.key === 'Enter') toggleZoom()
266
+ else if (e.key === 'ArrowUp') zoomIn()
267
+ else if (e.key === 'ArrowDown') zoomOut()
268
+ }
269
+
270
+ onMounted(() => {
271
+ document.addEventListener('click', onDocClick, true)
272
+ document.addEventListener('keydown', onKeydown)
273
+ document.addEventListener('pointerdown', onPointerDownSwiper, true)
274
+ })
275
+
276
+ onBeforeUnmount(() => {
277
+ document.removeEventListener('click', onDocClick, true)
278
+ document.removeEventListener('keydown', onKeydown)
279
+ document.removeEventListener('pointerdown', onPointerDownSwiper, true)
280
+ })
281
+ </script>
282
+
283
+ <style scoped lang="scss">
284
+ /* 预览遮罩层 */
285
+ .tnPreview {
286
+ position: fixed;
287
+ inset: 0;
288
+ z-index: 3000;
289
+ background: rgba(0, 0, 0, 0.85);
290
+ display: flex;
291
+ align-items: center;
292
+ justify-content: center;
293
+ backdrop-filter: blur(4px);
294
+ animation: fadeIn 0.2s ease-out;
295
+ }
296
+
297
+ @keyframes fadeIn {
298
+ from {
299
+ opacity: 0;
300
+ }
301
+ to {
302
+ opacity: 1;
303
+ }
304
+ }
305
+
306
+ /* 工具栏 */
307
+ .toolbar {
308
+ position: absolute;
309
+ top: 16px;
310
+ right: 16px;
311
+ display: flex;
312
+ gap: 10px;
313
+ z-index: 2;
314
+
315
+ button {
316
+ width: 36px;
317
+ height: 36px;
318
+ display: flex;
319
+ align-items: center;
320
+ justify-content: center;
321
+ background: rgba(42, 42, 42, 0.85);
322
+ border: 1px solid rgba(255, 255, 255, 0.15);
323
+ border-radius: 8px;
324
+ cursor: pointer;
325
+ transition: all 0.2s ease;
326
+
327
+ img {
328
+ width: 18px;
329
+ height: 18px;
330
+ transition: transform 0.2s ease;
331
+ }
332
+
333
+ &:hover {
334
+ background: rgba(255, 255, 255, 0.15);
335
+ transform: translateY(-2px);
336
+
337
+ img {
338
+ transform: scale(1.1);
339
+ }
340
+ }
341
+
342
+ &:active {
343
+ transform: translateY(0);
344
+ }
345
+ }
346
+ }
347
+
348
+ .close {
349
+ &:hover img {
350
+ transform: rotate(90deg) scale(1.1) !important;
351
+ }
352
+ }
353
+
354
+ /* 左右切换按钮 */
355
+ .nav {
356
+ position: absolute;
357
+ top: 50%;
358
+ transform: translateY(-50%);
359
+ width: 56px;
360
+ height: 56px;
361
+ display: flex;
362
+ align-items: center;
363
+ justify-content: center;
364
+ background: rgba(0, 0, 0, 0.3);
365
+ border: none;
366
+ border-radius: 50%;
367
+ cursor: pointer;
368
+ z-index: 2;
369
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
370
+
371
+ img {
372
+ width: 2rem;
373
+ height: 2rem;
374
+ transition: transform 0.3s ease;
375
+ }
376
+
377
+ &:hover {
378
+ background: rgba(0, 0, 0, 0.7);
379
+ transform: translateY(-50%) scale(1.15);
380
+
381
+ img {
382
+ filter: brightness(1.2);
383
+ }
384
+ }
385
+
386
+ &:active {
387
+ transform: translateY(-50%) scale(1.05);
388
+ }
389
+ }
390
+
391
+ .navLeft {
392
+ left: 24px;
393
+
394
+ &:hover img {
395
+ transform: translateX(-2px);
396
+ }
397
+ }
398
+
399
+ .navRight {
400
+ right: 24px;
401
+
402
+ &:hover img {
403
+ transform: translateX(2px);
404
+ }
405
+ }
406
+
407
+ /* 预览图片 */
408
+ .img {
409
+ max-width: 90vw;
410
+ max-height: 90vh;
411
+ user-select: none;
412
+ transition: transform 0.1s ease-out;
413
+
414
+ &.dragging {
415
+ cursor: grabbing !important;
416
+ }
417
+ }
418
+
419
+ /* 底部页码指示器 */
420
+ .counter {
421
+ position: absolute;
422
+ bottom: 20px;
423
+ left: 50%;
424
+ transform: translateX(-50%);
425
+ background: rgba(0, 0, 0, 0.65);
426
+ color: #fff;
427
+ padding: 8px 16px;
428
+ border-radius: 16px;
429
+ font-size: 0.9rem;
430
+ font-weight: 500;
431
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
432
+ z-index: 2;
433
+ user-select: none;
434
+ backdrop-filter: blur(8px);
435
+ border: 1px solid rgba(255, 255, 255, 0.1);
436
+ }
437
+
438
+ /* 响应式设计 */
439
+ @media (max-width: 768px) {
440
+ .nav {
441
+ width: 48px;
442
+ height: 48px;
443
+
444
+ img {
445
+ width: 1.5rem;
446
+ height: 1.5rem;
447
+ }
448
+ }
449
+
450
+ .navLeft {
451
+ left: 12px;
452
+ }
453
+
454
+ .navRight {
455
+ right: 12px;
456
+ }
457
+
458
+ .toolbar {
459
+ top: 12px;
460
+ right: 12px;
461
+ gap: 8px;
462
+
463
+ button {
464
+ width: 32px;
465
+ height: 32px;
466
+
467
+ img {
468
+ width: 16px;
469
+ height: 16px;
470
+ }
471
+ }
472
+ }
473
+
474
+ .counter {
475
+ bottom: 16px;
476
+ font-size: 0.85rem;
477
+ padding: 6px 14px;
478
+ }
479
+
480
+ .img {
481
+ max-width: 95vw;
482
+ max-height: 85vh;
483
+ }
484
+ }
485
+ </style>