@tnotesjs/core 0.1.26 → 0.1.28

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": "@tnotesjs/core",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "TNotes 知识库核心框架 —— 基于 VitePress 的笔记管理系统",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.17.1",
@@ -8,69 +8,47 @@ Mermaid 图表组件,用于在页面中渲染 Mermaid 图表
8
8
  <div
9
9
  ref="mermaidRef"
10
10
  class="mermaidWrapper"
11
- @mouseenter="showToolbar = true"
12
- @mouseleave="showToolbar = false"
13
11
  >
14
- <!-- 工具栏 -->
12
+ <!-- 右上角按钮组(hover 时显示,与 code block 风格一致) -->
15
13
  <div
16
14
  v-if="!loading && !error"
17
- class="toolbar"
18
- :class="{ visible: showToolbar }"
15
+ class="mermaidActions"
16
+ :data-copy-state="copyState"
19
17
  >
20
- <button
21
- @click="zoomIn"
22
- class="toolbarBtn iconBtn"
23
- title="放大 (Ctrl + +)"
24
- >
25
- <img :src="icon__zoom_in" alt="放大" class="btnIcon" />
26
- </button>
27
- <button
28
- @click="zoomOut"
29
- class="toolbarBtn iconBtn"
30
- title="缩小 (Ctrl + -)"
31
- >
32
- <img :src="icon__zoom_out" alt="缩小" class="btnIcon" />
33
- </button>
34
- <button
35
- @click="resetZoom"
36
- class="toolbarBtn iconBtn"
37
- title="重置缩放 (Ctrl + 0)"
38
- >
39
- <img :src="icon__zoom_reset" alt="重置" class="btnIcon" />
40
- </button>
41
- <button
42
- @click="fitToScreen"
43
- class="toolbarBtn iconBtn"
44
- title="适应屏幕"
45
- >
46
- <img :src="icon__zoom_fit" alt="适应屏幕" class="btnIcon" />
47
- </button>
48
18
  <button
49
19
  @click="toggleFullscreen"
50
- class="toolbarBtn iconBtn"
51
- title="全屏 (F11)"
20
+ class="mermaidActionFullscreen"
21
+ :title="isFullscreen ? '退出全屏' : '全屏查看图表'"
52
22
  >
53
23
  <img
54
24
  v-if="isFullscreen"
55
25
  :src="icon__fullscreen_exit"
56
26
  alt="退出全屏"
57
- class="btnIcon"
58
27
  />
59
28
  <img
60
29
  v-else
61
30
  :src="icon__fullscreen"
62
31
  alt="全屏"
63
- class="btnIcon"
64
32
  />
65
33
  </button>
66
- <span class="zoomLevel">{{ Math.round(scale * 100) }}%</span>
34
+ <button
35
+ @click="copySource"
36
+ class="mermaidActionCopy"
37
+ :data-copy-state="copyState"
38
+ :title="copyTitle"
39
+ >
40
+ <img :src="copyIcon" alt="复制" />
41
+ </button>
67
42
  </div>
68
43
 
69
44
  <!-- 图表容器 -->
70
45
  <div
71
46
  class="mermaidContainer"
72
47
  :class="{ fullscreen: isFullscreen }"
73
- @click="handleContainerClick"
48
+ @mousedown="startDrag"
49
+ @mousemove="onDrag"
50
+ @mouseup="endDrag"
51
+ @mouseleave="endDrag"
74
52
  >
75
53
  <div v-if="loading" class="mermaidLoading">
76
54
  <div class="spinner"></div>
@@ -84,11 +62,8 @@ Mermaid 图表组件,用于在页面中渲染 Mermaid 图表
84
62
  v-show="!loading && !error"
85
63
  ref="diagramRef"
86
64
  :class="['mermaidDiagram', props.class]"
87
- :style="{
88
- transform: `scale(${scale})`,
89
- transformOrigin: 'center center',
90
- }"
91
- @wheel.prevent="handleWheel"
65
+ :style="diagramTransform ? { transform: diagramTransform, transformOrigin: 'top left' } : undefined"
66
+ @wheel="handleWheel"
92
67
  ></div>
93
68
  </div>
94
69
  </div>
@@ -96,13 +71,11 @@ Mermaid 图表组件,用于在页面中渲染 Mermaid 图表
96
71
 
97
72
  <script setup lang="ts">
98
73
  import mermaid from 'mermaid'
99
- import { nextTick, onMounted, onBeforeUnmount, ref, watch } from 'vue'
74
+ import { computed, nextTick, onMounted, onBeforeUnmount, ref, watch } from 'vue'
100
75
 
101
76
  import {
102
- icon__zoom_in,
103
- icon__zoom_out,
104
- icon__zoom_reset,
105
- icon__zoom_fit,
77
+ icon__check,
78
+ icon__clipboard,
106
79
  icon__fullscreen,
107
80
  icon__fullscreen_exit,
108
81
  } from '../../assets/icons'
@@ -126,71 +99,91 @@ const mermaidRef = ref<HTMLElement | null>(null)
126
99
  const diagramRef = ref<HTMLElement | null>(null)
127
100
  const loading = ref(true)
128
101
  const error = ref<string | null>(null)
129
- const scale = ref(1)
130
102
  const isFullscreen = ref(false)
131
- const showToolbar = ref(false)
132
103
 
133
104
  // ===================================
134
- // #region 工具栏显示控制
105
+ // #region 复制源码
135
106
  // ===================================
136
- let hideTimer: ReturnType<typeof setTimeout> | null = null
137
-
138
- function handleContainerClick() {
139
- // 移动端点击切换工具栏显示
140
- if (window.innerWidth <= 768) {
141
- showToolbar.value = !showToolbar.value
142
-
143
- // 3秒后自动隐藏
144
- if (showToolbar.value) {
145
- if (hideTimer) clearTimeout(hideTimer)
146
- hideTimer = setTimeout(() => {
147
- showToolbar.value = false
148
- }, 3000)
149
- }
150
- }
107
+ type CopyState = 'idle' | 'copied' | 'failed'
108
+ const COPY_RESET_DELAY = 1000
109
+
110
+ const copyState = ref<CopyState>('idle')
111
+ const copyIcon = computed(() =>
112
+ copyState.value === 'copied' ? icon__check : icon__clipboard,
113
+ )
114
+ const copyTitle = computed(() => {
115
+ if (copyState.value === 'copied') return '已复制'
116
+ if (copyState.value === 'failed') return '复制失败'
117
+ return '复制源码'
118
+ })
119
+
120
+ let copyResetTimer: ReturnType<typeof setTimeout> | null = null
121
+
122
+ function copySource() {
123
+ const text = decodeURIComponent(props.graph)
124
+ navigator.clipboard
125
+ .writeText(text)
126
+ .then(() => {
127
+ copyState.value = 'copied'
128
+ })
129
+ .catch(() => {
130
+ copyState.value = 'failed'
131
+ })
132
+ .finally(() => {
133
+ if (copyResetTimer) clearTimeout(copyResetTimer)
134
+ copyResetTimer = setTimeout(() => {
135
+ copyState.value = 'idle'
136
+ }, COPY_RESET_DELAY)
137
+ })
151
138
  }
152
139
  // #endregion
153
140
 
154
141
  // ===================================
155
- // #region 缩放控制
142
+ // #region 全屏模式:拖拽平移 + Ctrl+滚轮缩放
156
143
  // ===================================
144
+ const scale = ref(1)
145
+ const panX = ref(0)
146
+ const panY = ref(0)
147
+ const dragging = ref(false)
148
+ let dragStartX = 0
149
+ let dragStartY = 0
150
+ let panStartX = 0
151
+ let panStartY = 0
152
+
157
153
  const MIN_SCALE = 0.1
158
154
  const MAX_SCALE = 5
159
- const SCALE_STEP = 0.1
155
+ const SCALE_STEP = 0.01
160
156
 
161
- function zoomIn() {
162
- scale.value = Math.min(scale.value + SCALE_STEP, MAX_SCALE)
163
- }
157
+ const diagramTransform = computed(() =>
158
+ isFullscreen.value
159
+ ? `translate(${panX.value}px, ${panY.value}px) scale(${scale.value})`
160
+ : undefined,
161
+ )
164
162
 
165
- function zoomOut() {
166
- scale.value = Math.max(scale.value - SCALE_STEP, MIN_SCALE)
163
+ function startDrag(e: MouseEvent) {
164
+ if (!isFullscreen.value) return
165
+ dragging.value = true
166
+ dragStartX = e.clientX
167
+ dragStartY = e.clientY
168
+ panStartX = panX.value
169
+ panStartY = panY.value
167
170
  }
168
171
 
169
- function resetZoom() {
170
- scale.value = 1
172
+ function onDrag(e: MouseEvent) {
173
+ if (!dragging.value) return
174
+ panX.value = panStartX + (e.clientX - dragStartX)
175
+ panY.value = panStartY + (e.clientY - dragStartY)
171
176
  }
172
177
 
173
- function fitToScreen() {
174
- if (!diagramRef.value || !mermaidRef.value) return
175
-
176
- const svg = diagramRef.value.querySelector('svg')
177
- if (!svg) return
178
-
179
- const containerRect = mermaidRef.value.getBoundingClientRect()
180
- const svgRect = svg.getBoundingClientRect()
181
-
182
- const scaleX = (containerRect.width * 0.9) / svgRect.width
183
- const scaleY = (containerRect.height * 0.9) / svgRect.height
184
-
185
- scale.value = Math.min(scaleX, scaleY, 1)
178
+ function endDrag() {
179
+ dragging.value = false
186
180
  }
187
181
 
188
182
  function handleWheel(e: WheelEvent) {
189
- if (e.ctrlKey || e.metaKey) {
190
- e.preventDefault()
191
- const delta = e.deltaY > 0 ? -SCALE_STEP : SCALE_STEP
192
- scale.value = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale.value + delta))
193
- }
183
+ if (!isFullscreen.value) return
184
+ e.preventDefault()
185
+ const delta = e.deltaY > 0 ? -SCALE_STEP : SCALE_STEP
186
+ scale.value = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale.value + delta))
194
187
  }
195
188
  // #endregion
196
189
 
@@ -213,29 +206,10 @@ function toggleFullscreen() {
213
206
 
214
207
  function handleFullscreenChange() {
215
208
  isFullscreen.value = !!document.fullscreenElement
216
- }
217
- // #endregion
218
-
219
- // ===================================
220
- // #region 键盘快捷键
221
- // ===================================
222
- function handleKeydown(e: KeyboardEvent) {
223
- if (e.ctrlKey || e.metaKey) {
224
- if (e.key === '=' || e.key === '+') {
225
- e.preventDefault()
226
- zoomIn()
227
- } else if (e.key === '-' || e.key === '_') {
228
- e.preventDefault()
229
- zoomOut()
230
- } else if (e.key === '0') {
231
- e.preventDefault()
232
- resetZoom()
233
- }
234
- } else if (e.key === 'F11') {
235
- e.preventDefault()
236
- toggleFullscreen()
237
- } else if (e.key === 'Escape' && isFullscreen.value) {
238
- toggleFullscreen()
209
+ if (!isFullscreen.value) {
210
+ scale.value = 1
211
+ panX.value = 0
212
+ panY.value = 0
239
213
  }
240
214
  }
241
215
  // #endregion
@@ -346,26 +320,21 @@ onMounted(async () => {
346
320
  // 观察主题变化
347
321
  const themeObserver = observeTheme()
348
322
 
349
- // 监听键盘事件
350
- document.addEventListener('keydown', handleKeydown)
351
-
352
323
  // 监听全屏变化
353
324
  document.addEventListener('fullscreenchange', handleFullscreenChange)
354
325
 
355
326
  // 清理函数
356
327
  onBeforeUnmount(() => {
357
328
  themeObserver.disconnect()
358
- document.removeEventListener('keydown', handleKeydown)
359
329
  document.removeEventListener('fullscreenchange', handleFullscreenChange)
360
330
  })
361
331
  })
362
332
 
363
- // 当图表内容变化时重新渲染并重置缩放
333
+ // 当图表内容变化时重新渲染
364
334
  watch(
365
335
  () => props.graph,
366
336
  () => {
367
337
  renderDiagram()
368
- resetZoom()
369
338
  }
370
339
  )
371
340
  // #endregion
@@ -388,79 +357,104 @@ watch(
388
357
  /* ===================================== */
389
358
 
390
359
  /* ===================================== */
391
- /* #region 工具栏 */
360
+ /* #region 右上角按钮组(与 code block 的 .tn-code-actions 风格一致) */
392
361
  /* ===================================== */
393
- .toolbar {
362
+ .mermaidActions {
394
363
  position: absolute;
395
- top: 0;
396
- left: 0;
397
- right: 0;
364
+ top: 8px;
365
+ right: 8px;
366
+ z-index: 10;
398
367
  display: flex;
399
368
  align-items: center;
400
- gap: 2px;
401
- padding: 6px 8px;
402
- background: var(--vp-c-bg);
403
- border-bottom: 1px solid var(--vp-c-divider);
404
- flex-wrap: wrap;
405
- transform: translateY(-100%);
406
- transition: transform 0.3s ease, opacity 0.3s ease;
369
+ justify-content: center;
370
+ gap: 4px;
371
+ padding: 2px;
372
+ background-color: color-mix(in srgb, var(--vp-code-block-bg) 92%, transparent);
373
+ border: 0.1px solid var(--vp-c-divider);
374
+ border-radius: 7px;
407
375
  opacity: 0;
408
- z-index: 10;
409
- pointer-events: none;
376
+ transition: opacity 0.2s;
377
+ }
410
378
 
411
- &.visible {
412
- transform: translateY(0);
413
- opacity: 1;
414
- pointer-events: auto;
415
- }
379
+ .mermaidWrapper:hover .mermaidActions,
380
+ .mermaidActions:hover,
381
+ .mermaidActions:focus-within,
382
+ .mermaidActions[data-copy-state='copied'],
383
+ .mermaidActions[data-copy-state='failed'] {
384
+ opacity: 1;
416
385
  }
417
386
 
418
- .toolbarBtn {
387
+ .mermaidActionCopy,
388
+ .mermaidActionFullscreen {
419
389
  display: inline-flex;
420
390
  align-items: center;
421
391
  justify-content: center;
422
392
  width: 28px;
423
393
  height: 28px;
424
394
  padding: 0;
425
- font-size: 12px;
426
395
  background: transparent;
427
396
  border: none;
428
397
  border-radius: 4px;
429
398
  cursor: pointer;
430
- transition: all 0.2s ease;
431
- opacity: 0.6;
432
399
 
433
400
  &:hover {
434
- background: var(--vp-c-brand-soft);
435
- opacity: 1;
401
+ background-color: var(--vp-c-default-soft);
402
+ color: var(--vp-c-brand-1);
403
+ transform: scale(1.05);
436
404
  }
437
405
 
438
406
  &:active {
439
407
  transform: scale(0.95);
440
408
  }
441
- }
442
-
443
- .iconBtn {
444
- padding: 4px;
445
409
 
446
- .btnIcon {
410
+ img {
447
411
  width: 16px;
448
412
  height: 16px;
449
- display: block;
450
- pointer-events: none;
451
413
  }
452
414
  }
453
415
 
454
- .zoomLevel {
455
- margin-left: auto;
456
- padding: 0 6px;
457
- font-size: 11px;
458
- font-weight: 500;
459
- color: var(--vp-c-text-3);
460
- user-select: none;
416
+ /* 与 .tn-code-action-copy 完全一致的复制反馈 */
417
+ .mermaidActionCopy {
418
+ position: relative;
419
+ }
420
+
421
+ .mermaidActionCopy[data-copy-state='copied'] {
422
+ background-color: var(--vp-c-green-soft);
423
+ }
424
+
425
+ .mermaidActionCopy[data-copy-state='failed'] {
426
+ background-color: var(--vp-c-danger-soft);
427
+ }
428
+
429
+ .mermaidActionCopy::after {
430
+ position: absolute;
431
+ top: 50%;
432
+ right: calc(100% + 6px);
433
+ padding: 4px 7px;
434
+ color: var(--vp-c-text-1);
435
+ background: var(--vp-c-bg-elv);
436
+ border: 1px solid var(--vp-c-divider);
437
+ border-radius: 6px;
438
+ box-shadow: var(--vp-shadow-2);
439
+ content: attr(title);
440
+ font-size: 12px;
441
+ line-height: 18px;
442
+ opacity: 0;
443
+ pointer-events: none;
444
+ transform: translateY(-50%) translateX(2px);
445
+ transition:
446
+ opacity 0.16s ease,
447
+ transform 0.16s ease;
448
+ white-space: nowrap;
449
+ }
450
+
451
+ .mermaidActionCopy[data-copy-state='copied']::after,
452
+ .mermaidActionCopy[data-copy-state='failed']::after {
453
+ opacity: 1;
454
+ transform: translateY(-50%) translateX(0);
461
455
  }
462
456
  /* ===================================== */
463
- /* #endregion 工具栏 */
457
+ /* #endregion 右上角按钮组 */
464
458
  /* ===================================== */
465
459
 
466
460
  /* ===================================== */
@@ -469,11 +463,6 @@ watch(
469
463
  .mermaidContainer {
470
464
  position: relative;
471
465
  min-height: 200px;
472
- max-height: 600px;
473
- overflow: auto;
474
- display: flex;
475
- align-items: center;
476
- justify-content: center;
477
466
  padding: 20px;
478
467
  background: var(--vp-c-bg);
479
468
 
@@ -483,28 +472,14 @@ watch(
483
472
  left: 0;
484
473
  right: 0;
485
474
  bottom: 0;
486
- max-height: none;
487
475
  z-index: 9999;
488
476
  background: var(--vp-c-bg);
489
477
  padding: 40px;
490
- }
478
+ overflow: hidden;
479
+ cursor: grab;
491
480
 
492
- /* 自定义滚动条 */
493
- &::-webkit-scrollbar {
494
- width: 8px;
495
- height: 8px;
496
- }
497
-
498
- &::-webkit-scrollbar-track {
499
- background: var(--vp-c-bg-soft);
500
- }
501
-
502
- &::-webkit-scrollbar-thumb {
503
- background: var(--vp-c-divider);
504
- border-radius: 4px;
505
-
506
- &:hover {
507
- background: var(--vp-c-text-3);
481
+ &:active {
482
+ cursor: grabbing;
508
483
  }
509
484
  }
510
485
  }
@@ -570,21 +545,16 @@ watch(
570
545
  /* #region 图表样式 */
571
546
  /* ===================================== */
572
547
  .mermaidDiagram {
573
- width: 100%;
574
- display: flex;
575
- align-items: center;
576
- justify-content: center;
577
- transition: transform 0.2s ease;
578
- cursor: grab;
579
-
580
- &:active {
581
- cursor: grabbing;
582
- }
583
-
584
548
  :global(svg) {
585
549
  max-width: 100%;
586
550
  height: auto;
587
551
  }
552
+
553
+ // 重置 VitePress 全局 line-height,避免穿透 SVG foreignObject 导致
554
+ // <p> 实际渲染高度超过 Mermaid 计算值,引发文本截断
555
+ :global(svg .label p) {
556
+ line-height: 1.5;
557
+ }
588
558
  }
589
559
  /* ===================================== */
590
560
  /* #endregion 图表样式 */
@@ -598,10 +568,6 @@ watch(
598
568
  border-color: var(--vp-c-divider);
599
569
  }
600
570
 
601
- :global(html.dark) .toolbar {
602
- background: var(--vp-code-block-bg);
603
- }
604
-
605
571
  :global(html.dark) .mermaidContainer {
606
572
  background: var(--vp-code-block-bg);
607
573
  }
@@ -613,31 +579,19 @@ watch(
613
579
  /* #region 响应式设计 */
614
580
  /* ===================================== */
615
581
  @media (max-width: 768px) {
616
- .toolbar {
617
- padding: 4px 6px;
618
- }
619
-
620
- .toolbarBtn {
582
+ .mermaidActionCopy,
583
+ .mermaidActionFullscreen {
621
584
  width: 24px;
622
585
  height: 24px;
623
- }
624
-
625
- .iconBtn {
626
- padding: 3px;
627
586
 
628
- .btnIcon {
587
+ img {
629
588
  width: 14px;
630
589
  height: 14px;
631
590
  }
632
591
  }
633
592
 
634
- .zoomLevel {
635
- font-size: 10px;
636
- }
637
-
638
593
  .mermaidContainer {
639
594
  padding: 12px;
640
- max-height: 400px;
641
595
 
642
596
  &.fullscreen {
643
597
  padding: 20px;