@tnotesjs/ui 0.4.0 → 0.4.1

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/ui",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Shared Vue UI for TNotes built-in blocks — consumed by the TNotes SSG and Desk.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -83,7 +83,7 @@
83
83
  "react-dom": "18.3.1",
84
84
  "sass": "^1.90.0",
85
85
  "shiki": "4.4.2",
86
- "@tnotesjs/mindmap-core": "^0.2.3"
86
+ "@tnotesjs/mindmap-core": "^0.2.4"
87
87
  },
88
88
  "devDependencies": {
89
89
  "@eslint/js": "^10.0.1",
@@ -186,6 +186,9 @@ watch(
186
186
  <style scoped lang="scss">
187
187
  .tn-footprints {
188
188
  margin: 1rem 0;
189
+ padding: 12px 16px;
190
+ border: 1px solid var(--tn-c-divider);
191
+ border-radius: 8px;
189
192
  color: var(--tn-c-text);
190
193
  }
191
194
 
@@ -312,6 +315,7 @@ watch(
312
315
  .tn-footprints__modal-img {
313
316
  max-width: min(92vw, 960px);
314
317
  max-height: 88vh;
318
+ border-radius: 4px;
315
319
  object-fit: contain;
316
320
  }
317
321
 
@@ -225,6 +225,7 @@ onBeforeUnmount(() => {
225
225
  .tn-image-preview > img {
226
226
  max-width: 90vw;
227
227
  max-height: 90vh;
228
+ border-radius: 4px;
228
229
  user-select: none;
229
230
  cursor: grab;
230
231
  transition: transform 100ms ease-out;
@@ -502,8 +502,7 @@ watch(
502
502
  .tn-mermaid {
503
503
  position: relative;
504
504
  margin: 1rem 0;
505
- /* Rest: no frame. Border appears with the action menu (hover / menu focus / copy feedback). */
506
- border: 1px solid transparent;
505
+ border: 1px solid var(--tn-c-divider);
507
506
  border-radius: 8px;
508
507
  background: var(--tn-c-bg);
509
508
  overflow: hidden;
@@ -1185,11 +1185,13 @@ onBeforeUnmount(() => {
1185
1185
  }
1186
1186
  .mindmap-canvas-host:deep(.mm-edit-input .inline-run.code) {
1187
1187
  padding: 0 4px;
1188
+ border: 1px solid var(--tn-c-inline-code-border);
1188
1189
  border-radius: 4px;
1189
- background: color-mix(in srgb, var(--mm-canvas-bg) 82%, var(--mm-text) 18%);
1190
- color: var(--tn-c-danger, #e85d5d);
1190
+ background: var(--tn-c-inline-code-bg);
1191
+ color: inherit;
1191
1192
  font-family: var(--tn-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
1192
- font-size: 0.92em;
1193
+ font-size: 1em;
1194
+ font-weight: 400;
1193
1195
  }
1194
1196
  .mindmap-canvas-host:deep(.mm-edit-input .inline-run.link) {
1195
1197
  color: var(--mm-accent);
@@ -1326,7 +1328,7 @@ onBeforeUnmount(() => {
1326
1328
  max-width: min(100%, 560px);
1327
1329
  max-height: 360px;
1328
1330
  margin: 5px 0 12px 25px;
1329
- border-radius: 6px;
1331
+ border-radius: 4px;
1330
1332
  }
1331
1333
  .mindmap-outline :deep(.is-bold) {
1332
1334
  font-weight: 700;
@@ -1346,11 +1348,14 @@ onBeforeUnmount(() => {
1346
1348
  background: var(--tn-c-mark-bg, rgba(255, 221, 0, 0.45));
1347
1349
  }
1348
1350
  .mindmap-outline :deep(.is-code) {
1349
- padding: 1px 5px;
1351
+ padding: 0 4px;
1352
+ border: 1px solid var(--tn-c-inline-code-border);
1350
1353
  border-radius: 4px;
1351
- background: var(--tn-c-bg-soft);
1352
- color: var(--tn-c-danger);
1354
+ background: var(--tn-c-inline-code-bg);
1355
+ color: inherit;
1353
1356
  font-family: var(--tn-font-mono);
1357
+ font-size: 1em;
1358
+ font-weight: 400;
1354
1359
  }
1355
1360
  .mindmap-outline :deep(.is-link) {
1356
1361
  color: var(--tn-c-brand);
@@ -2345,7 +2345,7 @@ function onDragUp() {
2345
2345
  position: relative;
2346
2346
  max-width: 100%;
2347
2347
  line-height: 0;
2348
- border-radius: 6px;
2348
+ border-radius: 4px;
2349
2349
  overflow: hidden;
2350
2350
  background: color-mix(in srgb, var(--mm-text-dim) 12%, transparent);
2351
2351
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--mm-text-dim) 25%, transparent);
@@ -419,12 +419,14 @@ onBeforeUnmount(emitCommit)
419
419
  background: var(--tn-c-mark-bg, rgba(255, 221, 0, 0.45));
420
420
  }
421
421
  .rich-inline-editor :deep(.inline-run.code) {
422
- padding: 2px 6px;
423
- border-radius: 5px;
424
- background: color-mix(in srgb, var(--mm-text) 18%, transparent);
425
- color: #f08a6e;
426
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
427
- font-size: 0.92em;
422
+ padding: 0 4px;
423
+ border: 1px solid var(--tn-c-inline-code-border, #e7e9e8);
424
+ border-radius: 4px;
425
+ background: var(--tn-c-inline-code-bg, rgba(0, 0, 0, 0.06));
426
+ color: inherit;
427
+ font-family: var(--tn-font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
428
+ font-size: 1em;
429
+ font-weight: 400;
428
430
  box-decoration-break: clone;
429
431
  -webkit-box-decoration-break: clone;
430
432
  }
@@ -1,5 +1,10 @@
1
1
  <template>
2
- <div v-if="show" class="rightClickMenu" :style="{ left: x + 'px', top: y + 'px' }">
2
+ <div
3
+ v-if="show"
4
+ class="rightClickMenu"
5
+ :style="{ left: x + 'px', top: y + 'px' }"
6
+ @mousedown.stop
7
+ >
3
8
  <div v-if="showPin" class="menuItem" @click="handlePin">📌 Pin</div>
4
9
  <div class="menuItem" @click="(e) => handlePronounce(e, 'en-GB')">📢 Pronounce(英)</div>
5
10
  <div class="menuItem" @click="(e) => handlePronounce(e, 'en-US')">📢 Pronounce(美)</div>
@@ -61,12 +66,12 @@ const handleReset = () => {
61
66
  .rightClickMenu {
62
67
  position: fixed;
63
68
  z-index: 99999;
64
- background: #2c2c2c;
65
- border: 1px solid #444;
66
- border-radius: 6px;
67
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
69
+ background: var(--tn-c-bg-elv, var(--tn-c-bg));
70
+ border: 1px solid var(--tn-c-divider);
71
+ border-radius: 8px;
72
+ box-shadow: var(--tn-shadow-2);
68
73
  font-size: 13px;
69
- color: #eee;
74
+ color: var(--tn-c-text);
70
75
  cursor: pointer;
71
76
  user-select: none;
72
77
  }
@@ -75,7 +80,7 @@ const handleReset = () => {
75
80
  padding: 8px 16px;
76
81
 
77
82
  &:hover {
78
- background-color: #444;
83
+ background: var(--tn-c-hover);
79
84
  }
80
85
  }
81
86
  </style>
@@ -48,6 +48,7 @@ const sortedWords = computed(() => {
48
48
  if (!props.needSort) return unique
49
49
  return unique.sort((a, b) => a.toLowerCase().charCodeAt(0) - b.toLowerCase().charCodeAt(0))
50
50
  })
51
+ const islandWords = computed(() => encodeURIComponent(JSON.stringify(sortedWords.value)))
51
52
  const checkedStates = ref({})
52
53
 
53
54
  const updateCheckedState = (word, isChecked) => {
@@ -84,6 +85,7 @@ const cardX = ref(0)
84
85
  const cardY = ref(0)
85
86
  const cardContent = ref('')
86
87
  const wordCache = ref({})
88
+ const wordMarkdown = ref({})
87
89
 
88
90
  // 加载失败的词汇(也就是词库中不存在的词汇)
89
91
  const failedWords = ref({})
@@ -112,6 +114,66 @@ let currentWordForContextMenu = null
112
114
  // 防抖计时器
113
115
  let hoverTimer = null
114
116
 
117
+ function wordSlug(word) {
118
+ return encodeURIComponent(word.toLowerCase().replaceAll(/\s/g, '_'))
119
+ }
120
+
121
+ function wordGithubUrl(word) {
122
+ return `${props.wordsBaseUrl}${wordSlug(word)}.md`
123
+ }
124
+
125
+ async function ensureWord(word) {
126
+ if (wordCache.value[word]) return
127
+ const url = `${props.wordsRawBaseUrl}${wordSlug(word)}.md`
128
+ try {
129
+ const res = await fetch(url)
130
+ if (res.ok) {
131
+ const text = await res.text()
132
+ wordMarkdown.value[word] = text
133
+ wordCache.value[word] = marked.parse(text)
134
+ } else {
135
+ wordCache.value[word] = `<em>无法加载单词内容</em>`
136
+ failedWords.value[word] = true
137
+ }
138
+ } catch (err) {
139
+ console.error(err)
140
+ wordCache.value[word] = `<em>加载失败</em>`
141
+ failedWords.value[word] = true
142
+ }
143
+ }
144
+
145
+ const copiedCardId = ref(null)
146
+ let copiedTimer = null
147
+
148
+ async function copyCardMarkdown(card, event) {
149
+ event.stopPropagation()
150
+ const markdown = wordMarkdown.value[card.word]
151
+ if (!markdown) return
152
+ try {
153
+ await navigator.clipboard.writeText(markdown)
154
+ } catch {
155
+ const field = document.createElement('textarea')
156
+ field.value = markdown
157
+ field.setAttribute('readonly', '')
158
+ field.style.position = 'fixed'
159
+ field.style.left = '-9999px'
160
+ document.body.append(field)
161
+ field.select()
162
+ document.execCommand('copy')
163
+ field.remove()
164
+ }
165
+ copiedCardId.value = card.id
166
+ clearTimeout(copiedTimer)
167
+ copiedTimer = setTimeout(() => {
168
+ copiedCardId.value = null
169
+ }, 1500)
170
+ }
171
+
172
+ function openCardOnGithub(card, event) {
173
+ event.stopPropagation()
174
+ window.open(wordGithubUrl(card.word), '_blank', 'noopener')
175
+ }
176
+
115
177
  /**
116
178
  * 显示单词卡片
117
179
  */
@@ -128,29 +190,8 @@ const showWordCard = async (e, word) => {
128
190
  cardY.value = clientY + 10
129
191
  showCard.value = true
130
192
 
131
- if (wordCache.value[word]) {
132
- cardContent.value = wordCache.value[word]
133
- resolve()
134
- return
135
- }
136
-
137
- const url = `${props.wordsRawBaseUrl}${encodeURIComponent(
138
- word.toLowerCase().replaceAll(/\s/g, '_')
139
- )}.md`
140
- try {
141
- const res = await fetch(url)
142
- if (res.ok) {
143
- let text = await res.text()
144
- text = marked.parse(text)
145
- wordCache.value[word] = text
146
- cardContent.value = text
147
- } else {
148
- cardContent.value = `<em>无法加载单词内容</em>`
149
- }
150
- } catch (err) {
151
- console.error(err)
152
- cardContent.value = `<em>加载失败</em>`
153
- }
193
+ await ensureWord(word)
194
+ cardContent.value = wordCache.value[word] || `<em>加载失败</em>`
154
195
  resolve()
155
196
  }, 300)
156
197
  })
@@ -208,28 +249,8 @@ const preloadWords = async () => {
208
249
  for (let i = 0; i < wordsToPreload.length; i++) {
209
250
  const word = wordsToPreload[i]
210
251
 
211
- // 如果已经缓存过,跳过
212
252
  if (wordCache.value[word]) continue
213
-
214
- const url = `${props.wordsRawBaseUrl}${encodeURIComponent(
215
- word.toLowerCase().replaceAll(/\s/g, '_')
216
- )}.md`
217
- try {
218
- const res = await fetch(url)
219
- if (res.ok) {
220
- let text = await res.text()
221
- text = marked.parse(text)
222
- wordCache.value[word] = text
223
- console.log(`✅ 预加载完成: ${word}`)
224
- } else {
225
- wordCache.value[word] = `<em>无法加载单词内容</em>`
226
- failedWords.value[word] = true
227
- }
228
- } catch (err) {
229
- console.error(`❌ 加载失败: ${word}`, err)
230
- wordCache.value[word] = `<em>加载失败</em>`
231
- failedWords.value[word] = true
232
- }
253
+ await ensureWord(word)
233
254
 
234
255
  // 可选:加个延迟避免并发请求过多
235
256
  await new Promise((resolve) => setTimeout(resolve, 200))
@@ -446,6 +467,7 @@ onMounted(() => {
446
467
  */
447
468
  onUnmounted(() => {
448
469
  clearTimeout(hoverTimer)
470
+ clearTimeout(copiedTimer)
449
471
  if (typeof document !== 'undefined') {
450
472
  document.removeEventListener('mousemove', onDragging)
451
473
  document.removeEventListener('mouseup', stopDrag)
@@ -455,7 +477,12 @@ onUnmounted(() => {
455
477
  </script>
456
478
 
457
479
  <template>
458
- <div class="tn-word-list">
480
+ <div
481
+ class="tn-word-list"
482
+ data-tn-island="word-list"
483
+ :data-words="islandWords"
484
+ :data-need-sort="needSort ? 'true' : 'false'"
485
+ >
459
486
  <ol>
460
487
  <li
461
488
  v-for="(word, index) in sortedWords"
@@ -515,10 +542,60 @@ onUnmounted(() => {
515
542
  @mousedown="(e) => startDrag(card, e)"
516
543
  @click="bringToFront(card)"
517
544
  >
545
+ <div class="wordCardTools">
546
+ <button
547
+ type="button"
548
+ class="cardTool"
549
+ aria-label="在 GitHub 打开"
550
+ title="在 GitHub 打开"
551
+ @mousedown.stop
552
+ @click.stop="openCardOnGithub(card, $event)"
553
+ >
554
+ <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
555
+ <path
556
+ fill="currentColor"
557
+ d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"
558
+ />
559
+ </svg>
560
+ </button>
561
+ <button
562
+ type="button"
563
+ class="cardTool"
564
+ :aria-label="copiedCardId === card.id ? '已复制' : '复制 Markdown'"
565
+ :title="copiedCardId === card.id ? '已复制' : '复制 Markdown'"
566
+ @mousedown.stop
567
+ @click.stop="copyCardMarkdown(card, $event)"
568
+ >
569
+ <svg
570
+ v-if="copiedCardId !== card.id"
571
+ viewBox="0 0 24 24"
572
+ width="16"
573
+ height="16"
574
+ aria-hidden="true"
575
+ >
576
+ <path
577
+ fill="none"
578
+ stroke="currentColor"
579
+ stroke-width="1.8"
580
+ d="M9 9h10v10H9zM5 15V5h10"
581
+ />
582
+ </svg>
583
+ <svg v-else viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
584
+ <path
585
+ fill="none"
586
+ stroke="currentColor"
587
+ stroke-width="1.8"
588
+ stroke-linecap="round"
589
+ stroke-linejoin="round"
590
+ d="m5 12 5 5L20 7"
591
+ />
592
+ </svg>
593
+ </button>
594
+ </div>
518
595
  <div class="wordCardContentWrapper">
519
596
  <div class="wordCardContent" v-html="card.content"></div>
520
597
  </div>
521
- <button class="closeBtn" @click.stop="removeCard(card.id)">✖</button>
598
+ <button class="closeBtn" @mousedown.stop @click.stop="removeCard(card.id)">✖</button>
522
599
  <div class="resizeHandle" @mousedown.stop="startResize(card, $event)"></div>
523
600
  </div>
524
601
  </template>
@@ -549,11 +626,41 @@ onUnmounted(() => {
549
626
 
550
627
  <style scoped lang="scss">
551
628
  .tn-word-list {
629
+ padding: 12px 16px;
630
+ border: 1px solid var(--tn-c-divider);
631
+ border-radius: 8px;
632
+
552
633
  // Checkbox 样式
553
634
  input[type='checkbox'] {
554
- margin: 8px;
555
- transform: scale(1.3);
635
+ appearance: none;
636
+ -webkit-appearance: none;
637
+ position: relative;
638
+ box-sizing: border-box;
639
+ flex: none;
640
+ width: 16px;
641
+ height: 16px;
642
+ margin: 0 8px;
643
+ border: 1px solid var(--tn-c-task-check-border);
644
+ border-radius: 4px;
645
+ background: var(--tn-c-task-check-bg);
556
646
  cursor: pointer;
647
+
648
+ &:checked {
649
+ border-color: var(--tn-c-task-check);
650
+ background: var(--tn-c-task-check);
651
+ }
652
+
653
+ &:checked::after {
654
+ content: '';
655
+ position: absolute;
656
+ left: 4.5px;
657
+ top: 1px;
658
+ width: 4px;
659
+ height: 9px;
660
+ border: solid #fff;
661
+ border-width: 0 2px 2px 0;
662
+ transform: rotate(45deg);
663
+ }
557
664
  }
558
665
 
559
666
  // 链接样式
@@ -577,8 +684,9 @@ onUnmounted(() => {
577
684
 
578
685
  // 单词列表样式
579
686
  ol {
580
- list-style-type: decimal;
581
- padding-left: 20px;
687
+ list-style: none;
688
+ margin: 0;
689
+ padding-left: 0;
582
690
 
583
691
  li {
584
692
  display: flex;
@@ -599,13 +707,12 @@ onUnmounted(() => {
599
707
  }
600
708
  }
601
709
 
602
- // 单词卡片样式(🌑 暗色悬浮卡片)
603
710
  .wordCard {
604
711
  position: fixed;
605
712
  z-index: 9999;
606
- background: #1e1e1e;
607
- border: 1px solid #333;
608
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
713
+ background: var(--tn-c-bg-elv, var(--tn-c-bg));
714
+ border: 1px solid var(--tn-c-divider);
715
+ box-shadow: var(--tn-shadow-2);
609
716
  padding: 12px 16px;
610
717
  max-width: 600px;
611
718
  min-width: 200px;
@@ -613,12 +720,11 @@ onUnmounted(() => {
613
720
  font-size: 14px;
614
721
  line-height: 1.4;
615
722
  border-radius: 8px;
616
- color: #eee;
723
+ color: var(--tn-c-text);
617
724
  pointer-events: auto;
618
- font-family: sans-serif;
725
+ font-family: var(--tn-font-sans, sans-serif);
619
726
  cursor: move;
620
727
 
621
- // 关闭按钮
622
728
  .closeBtn {
623
729
  position: absolute;
624
730
  right: 5px;
@@ -627,19 +733,49 @@ onUnmounted(() => {
627
733
  border: none;
628
734
  font-size: 16px;
629
735
  cursor: pointer;
630
- color: #ccc;
736
+ color: var(--tn-c-text-2);
631
737
 
632
738
  &:hover {
633
- color: white;
739
+ color: var(--tn-c-text);
634
740
  }
635
741
  }
636
742
  }
637
743
 
638
744
  // 卡片内容包裹器
745
+ .wordCardTools {
746
+ position: absolute;
747
+ top: 6px;
748
+ left: 8px;
749
+ z-index: 2;
750
+ display: flex;
751
+ gap: 2px;
752
+ }
753
+
754
+ .cardTool {
755
+ display: inline-flex;
756
+ align-items: center;
757
+ justify-content: center;
758
+ width: 24px;
759
+ height: 24px;
760
+ padding: 0;
761
+ border: 0;
762
+ border-radius: 4px;
763
+ background: transparent;
764
+ color: var(--tn-c-text-2);
765
+ cursor: pointer;
766
+
767
+ &:hover {
768
+ background: var(--tn-c-hover);
769
+ color: var(--tn-c-text);
770
+ }
771
+ }
772
+
639
773
  .wordCardContentWrapper {
640
774
  width: 100%;
641
775
  height: 100%;
642
776
  overflow: auto;
777
+ box-sizing: border-box;
778
+ padding-top: 22px;
643
779
  }
644
780
 
645
781
  // 卡片内容样式
@@ -657,13 +793,13 @@ onUnmounted(() => {
657
793
  bottom: 0;
658
794
  width: 12px;
659
795
  height: 12px;
660
- background-color: #666;
796
+ background-color: var(--tn-c-text-2);
661
797
  cursor: nwse-resize;
662
798
  z-index: 2;
663
799
  border-radius: 50%;
664
800
 
665
801
  &:hover {
666
- background-color: #aaa;
802
+ background-color: var(--tn-c-text);
667
803
  }
668
804
  }
669
805
  </style>
@@ -105,12 +105,13 @@ defineExpose({ state, errorReason })
105
105
  align-items: center;
106
106
  justify-content: center;
107
107
  overflow: hidden;
108
- border-radius: 8px;
108
+ border-radius: 4px;
109
109
  background: var(--tn-excalidraw-bg, transparent);
110
110
  }
111
111
  .tn-excalidraw-view__image {
112
112
  max-width: 100%;
113
113
  max-height: 100%;
114
+ border-radius: 4px;
114
115
  object-fit: contain;
115
116
  }
116
117
  .tn-excalidraw-view__placeholder {
@@ -47,15 +47,15 @@
47
47
  line-height: var(--tn-heading-6-line);
48
48
  }
49
49
  :is(.tn-prose, .vp-doc) :where(a) {
50
- color: var(--tn-c-brand);
50
+ color: var(--tn-c-link);
51
51
  text-decoration: none;
52
- font-weight: 500;
52
+ font-weight: 400;
53
53
  overflow-wrap: anywhere;
54
54
  word-break: break-word;
55
55
  }
56
56
  :is(.tn-prose, .vp-doc) :where(a:hover) {
57
- color: var(--tn-c-brand-hover);
58
- text-decoration: underline;
57
+ color: var(--tn-c-link);
58
+ text-decoration: none;
59
59
  }
60
60
  :is(.tn-prose, .vp-doc) :where(ul, ol) {
61
61
  padding-left: 1.5rem;
@@ -106,11 +106,15 @@
106
106
  background-size: 12px 12px;
107
107
  }
108
108
  :is(.tn-prose, .vp-doc) :where(:not(pre) > code) {
109
- color: var(--tn-c-brand);
110
- background: var(--tn-c-default-soft);
109
+ color: inherit;
110
+ background: var(--tn-c-inline-code-bg);
111
+ border: 1px solid var(--tn-c-inline-code-border);
111
112
  border-radius: 4px;
112
- font: 0.875em var(--tn-font-mono);
113
- padding: 3px 6px;
113
+ margin: 1px 3px;
114
+ padding: 0 4px;
115
+ font-family: var(--tn-font-mono);
116
+ font-size: 1em;
117
+ font-weight: 400;
114
118
  }
115
119
  :is(.tn-prose, .vp-doc) :where(mark) {
116
120
  color: inherit;
@@ -161,6 +165,13 @@
161
165
  max-width: 100%;
162
166
  height: auto;
163
167
  }
168
+ /* 笔记里的图片默认 4px 圆角。Desk 和静态站共用;按钮里的图标保持直角。 */
169
+ :is(.tn-prose, .vp-doc, .live-editor) :where(img) {
170
+ border-radius: 4px;
171
+ }
172
+ :is(.tn-prose, .vp-doc, .live-editor) button img {
173
+ border-radius: 0;
174
+ }
164
175
  :is(.tn-prose, .vp-doc) :where(figure.tn-image) {
165
176
  display: block;
166
177
  width: fit-content;
@@ -180,6 +191,14 @@
180
191
  width: 100%;
181
192
  height: auto;
182
193
  }
194
+
195
+ /* 正文图片边界。Desk 可视化图和静态站图共用;Swiper 的边画在 .tn-swiper 上。 */
196
+ :is(.tn-prose, .vp-doc) :where(figure.tn-image > img),
197
+ .cm-lp-image-frame > img,
198
+ :is(.tn-prose, .vp-doc, .cm-lp-card) :where(.tn-custom-block img) {
199
+ border: 1px solid var(--tn-c-media-edge);
200
+ border-radius: 4px;
201
+ }
183
202
  :is(.tn-prose, .vp-doc) :where(figure.tn-image > figcaption),
184
203
  :is(.tn-prose, .vp-doc) :where(figcaption) {
185
204
  display: block;
@@ -2,16 +2,16 @@
2
2
 
3
3
  .tn-swiper {
4
4
  margin: 1rem 0;
5
- overflow: visible;
6
- border-radius: 8px;
7
- box-shadow: 0 2px 8px rgb(0 0 0 / 0.18);
5
+ overflow: hidden;
6
+ border: 1px solid var(--tn-c-media-edge);
7
+ border-radius: 4px;
8
+ box-shadow: none;
8
9
  background: transparent;
9
10
  padding: 0;
10
- border: none;
11
11
  }
12
12
 
13
13
  .tn-swiper:hover {
14
- box-shadow: 0 4px 16px rgb(0 0 0 / 0.28);
14
+ box-shadow: none;
15
15
  }
16
16
 
17
17
  .tn-swiper-tabs {
@@ -22,7 +22,6 @@
22
22
  overflow-x: auto;
23
23
  overflow-y: hidden;
24
24
  border-bottom: 1px solid var(--tn-c-divider);
25
- border-radius: 8px 8px 0 0;
26
25
  background: var(--tn-c-bg-soft);
27
26
  box-shadow: inset 0 -1px var(--tn-c-divider);
28
27
  }
@@ -125,7 +124,6 @@
125
124
  width: 100%;
126
125
  overflow: hidden;
127
126
  margin: 0;
128
- border-radius: 0 0 8px 8px;
129
127
  background: var(--tn-c-bg);
130
128
  }
131
129
 
@@ -147,5 +145,35 @@
147
145
  max-width: 100%;
148
146
  height: auto;
149
147
  margin: 0 auto;
148
+ border: 0;
149
+ border-radius: 4px;
150
150
  object-fit: contain;
151
151
  }
152
+
153
+ .tn-swiper .swiper-slide.is-align-left img {
154
+ margin-left: 0;
155
+ margin-right: auto;
156
+ }
157
+
158
+ .tn-swiper .swiper-slide.is-align-right img {
159
+ margin-left: auto;
160
+ margin-right: 0;
161
+ }
162
+
163
+ .tn-swiper.is-empty .swiper-container {
164
+ display: flex;
165
+ align-items: center;
166
+ justify-content: center;
167
+ min-height: 180px;
168
+ border: 0;
169
+ border-radius: 0;
170
+ background: var(--tn-c-bg-soft);
171
+ }
172
+
173
+ .tn-swiper-empty {
174
+ margin: 0;
175
+ color: var(--tn-c-text-2);
176
+ font-size: 14px;
177
+ line-height: 1.5;
178
+ text-align: center;
179
+ }
@@ -12,9 +12,18 @@
12
12
  --tn-c-bg-input: #f6f6f7;
13
13
  --tn-c-divider: #e2e2e3;
14
14
  --tn-c-divider-strong: #c2c2c4;
15
+ /* 正文图片和 Swiper 外框。浅色里分割线就能和白底分开。 */
16
+ --tn-c-media-edge: var(--tn-c-divider);
15
17
  --tn-c-hover: rgba(142, 150, 170, 0.14);
16
18
  --tn-c-highlight: rgba(100, 108, 255, 0.16);
17
19
  --tn-c-brand-soft: var(--tn-c-highlight);
20
+ /* 阅读选区。语雀正文 ::selection:rgba(27, 162, 227, 0.2),字色不改。 */
21
+ --tn-c-selection: rgba(27, 162, 227, 0.2);
22
+ /* 正文链接。语雀看得见的字色是 #3B82CE,悬停不变,没有底。 */
23
+ --tn-c-link: #3b82ce;
24
+ /* 行内代码。语雀 ne-code-content:黑 6% 底,浅边 #e7e9e8,字色跟正文。 */
25
+ --tn-c-inline-code-bg: rgba(0, 0, 0, 0.06);
26
+ --tn-c-inline-code-border: #e7e9e8;
18
27
  --tn-c-success: #18794e;
19
28
  --tn-c-warning: #915930;
20
29
  --tn-c-danger: #b8272c;
@@ -30,11 +39,11 @@
30
39
  --tn-c-task-check-bg: #ffffff;
31
40
  --tn-shadow-2: 0 4px 12px rgba(0, 0, 0, 0.12);
32
41
  --tn-font-sans:
33
- 'Punctuation SC', Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
34
- 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
42
+ 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial,
43
+ sans-serif, 'Segoe UI', 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji';
35
44
  --tn-font-mono: 'SFMono-Regular', 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
36
- --tn-prose-size: 16px;
37
- --tn-prose-line-height: 28px;
45
+ --tn-prose-size: 15px;
46
+ --tn-prose-line-height: 26.1px;
38
47
  --tn-heading-weight: 600;
39
48
  --tn-heading-1-size: 32px;
40
49
  --tn-heading-1-line: 40px;
@@ -63,7 +72,14 @@
63
72
  --tn-c-bg-input: #161618;
64
73
  --tn-c-divider: #2e2e32;
65
74
  --tn-c-divider-strong: #3c3f44;
75
+ /* 暗色分割线贴近编辑区,掺文字色后白底图和深色图都能看见边。 */
76
+ --tn-c-media-edge: color-mix(in srgb, var(--tn-c-text-2) 42%, var(--tn-c-bg));
66
77
  --tn-c-hover: rgba(101, 117, 133, 0.16);
78
+ /* 同一青色。暗底上 0.2 太淡,提高到 0.35 才看得清。 */
79
+ --tn-c-selection: rgba(27, 162, 227, 0.35);
80
+ --tn-c-link: #8ebef5;
81
+ --tn-c-inline-code-bg: rgba(255, 255, 255, 0.08);
82
+ --tn-c-inline-code-border: rgba(255, 255, 255, 0.12);
67
83
  --tn-c-success: #3dd68c;
68
84
  --tn-c-warning: #f9b44e;
69
85
  --tn-c-danger: #f66f81;
@@ -47,6 +47,13 @@ export function wrapSlideIndex(index: number, length: number, delta: -1 | 1): nu
47
47
  return (index + delta + length) % length
48
48
  }
49
49
 
50
+ export const SWIPER_EMPTY_TEXT = 'empty swiper'
51
+
52
+ /** 没有图片幻灯片时的壳。Desk 与站点用同一段结构。 */
53
+ export function emptySwiperMarkup(): string {
54
+ return `<div class="tn-swiper is-empty"><div class="swiper-container"><p class="tn-swiper-empty">${SWIPER_EMPTY_TEXT}</p></div></div>`
55
+ }
56
+
50
57
  const READY = 'tnSwiperReady'
51
58
 
52
59
  /**
@@ -76,6 +83,7 @@ export function hydrateTnSwipers(root: ParentNode = document): number {
76
83
  }
77
84
 
78
85
  function hydrateOne(host: HTMLElement): boolean {
86
+ if (host.classList.contains('is-empty')) return true
79
87
  const tabs = host.querySelector<HTMLElement>('.tn-swiper-tabs')
80
88
  const wrapper = host.querySelector<HTMLElement>('.swiper-wrapper')
81
89
  if (!wrapper) return false