@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,664 +1,664 @@
1
- <script setup>
2
- import { marked } from 'marked'
3
- import { computed, ref, onMounted, onUnmounted, nextTick } from 'vue'
4
-
5
- import {
6
- EN_WORDS_REPO_BASE_URL,
7
- EN_WORDS_REPO_BASE_RAW_URL,
8
- EN_WORD_LIST_COMP_IS_AUTO_SHOW_CARD,
9
- } from '../constants.ts'
10
- import RightClickMenu from './RightClickMenu.vue'
11
-
12
- const props = defineProps({
13
- words: {
14
- type: Array,
15
- default: () => [],
16
- },
17
- needSort: {
18
- type: Boolean,
19
- default: false,
20
- },
21
- })
22
-
23
- const isMobile = computed(() => {
24
- if (typeof navigator === 'undefined') return false
25
- return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
26
- navigator.userAgent
27
- )
28
- })
29
-
30
- // checkbox ---------------------------------------------------
31
-
32
- const pathname = typeof window !== 'undefined' ? window.location.pathname : ''
33
- const sortedWords = props.needSort
34
- ? computed(() =>
35
- [...new Set(props.words)].sort(
36
- (a, b) =>
37
- a.toLowerCase()[0].charCodeAt() - b.toLowerCase()[0].charCodeAt()
38
- )
39
- )
40
- : computed(() => [...new Set(props.words)])
41
- const checkedStates = ref({})
42
-
43
- const updateCheckedState = (word, isChecked) => {
44
- const key = `${pathname}-${word}`
45
- checkedStates.value[word] = isChecked
46
- localStorage.setItem(key, isChecked)
47
- }
48
-
49
- const checkAll = () => {
50
- Object.keys(checkedStates.value).forEach((word) => {
51
- updateCheckedState(word, true)
52
- })
53
- hideContextMenu()
54
- }
55
-
56
- const reset = () => {
57
- sortedWords.value.forEach((word) => {
58
- const key = `${pathname}-${word}`
59
- localStorage.removeItem(key)
60
- checkedStates.value[word] = false
61
- })
62
- hideContextMenu()
63
- }
64
-
65
- // word card ---------------------------------------------------
66
-
67
- const topZIndex = ref(10000)
68
-
69
- const isAutoShowCard = ref(false)
70
-
71
- // 卡片状态
72
- const showCard = ref(false)
73
- const cardX = ref(0)
74
- const cardY = ref(0)
75
- const cardContent = ref('')
76
- const wordCache = ref({})
77
-
78
- // 加载失败的词汇(也就是词库中不存在的词汇)
79
- const failedWords = ref({})
80
-
81
- // pinnedCards: { id, word, x, y, isDragging }
82
- const pinnedCards = ref([])
83
- let draggingCard = null
84
- let offsetX = 0
85
- let offsetY = 0
86
-
87
- const CARD_DEFAULT_WIDTH = 400
88
- const CARD_DEFAULT_HEIGHT = 500
89
-
90
- let resizingCard = null
91
- let startX = 0
92
- let startY = 0
93
- let startWidth = 0
94
- let startHeight = 0
95
-
96
- // 右键菜单状态
97
- const contextMenuVisible = ref(false)
98
- const contextMenuX = ref(0)
99
- const contextMenuY = ref(0)
100
- let currentWordForContextMenu = null
101
-
102
- // 防抖计时器
103
- let hoverTimer = null
104
-
105
- /**
106
- * 显示单词卡片
107
- */
108
- const showWordCard = async (e, word) => {
109
- cardContent.value = '<em>加载中……</em>'
110
- return new Promise((resolve) => {
111
- clearTimeout(hoverTimer)
112
- hoverTimer = setTimeout(async () => {
113
- const { clientX, clientY } = e
114
- cardX.value = clientX + 10
115
- cardY.value = clientY + 10
116
- showCard.value = true
117
-
118
- if (wordCache.value[word]) {
119
- cardContent.value = wordCache.value[word]
120
- resolve()
121
- return
122
- }
123
-
124
- const url = `${EN_WORDS_REPO_BASE_RAW_URL}${encodeURIComponent(
125
- word.toLowerCase().replaceAll(/\s/g, '_')
126
- )}.md`
127
- try {
128
- const res = await fetch(url)
129
- if (res.ok) {
130
- let text = await res.text()
131
- text = marked.parse(text)
132
- wordCache.value[word] = text
133
- cardContent.value = text
134
- } else {
135
- cardContent.value = `<em>无法加载单词内容</em>`
136
- }
137
- } catch (err) {
138
- console.error(err)
139
- cardContent.value = `<em>加载失败</em>`
140
- }
141
- resolve()
142
- }, 300)
143
- })
144
- }
145
-
146
- // const convertMarkdownToHTML = (text) => {
147
- // const lines = text.trim().split('\n')
148
- // let stack = [{ level: -1, html: [] }]
149
-
150
- // for (let line of lines) {
151
- // const match = line.match(/^(\s*)-\s(.*)/)
152
- // if (!match) continue
153
-
154
- // const indent = match[1].length
155
- // const content = match[2]
156
- // const currentLevel = stack[stack.length - 1]
157
-
158
- // const imageMatch = content.match(/^!\$$(.+?)$$/)
159
- // const processedContent = imageMatch
160
- // ? `<img src="${imageMatch[1]}" alt="" />`
161
- // : content
162
-
163
- // // 1. 深了:如果缩进比上一级更深,开启新子列表
164
- // if (indent > currentLevel.level) {
165
- // stack.push({ level: indent, html: [] })
166
- // }
167
- // // 2. 浅了:如果缩进更浅,关闭之前的列表直到匹配层级
168
- // else if (indent < currentLevel.level) {
169
- // while (stack.length > 1 && stack[stack.length - 2].level >= indent) {
170
- // const closed = stack.pop()
171
- // const innerHTML = closed.html.join('')
172
- // stack[stack.length - 1].html.push(`<ul>${innerHTML}</ul>`)
173
- // }
174
- // }
175
- // // 3. 一致:stack[-1] 是与当前 indent 层级一致的节点,添加当前 li 内容。
176
- // stack[stack.length - 1].html.push(`<li>${processedContent}</li>`)
177
- // }
178
-
179
- // // 清理栈中剩余的 ul
180
- // while (stack.length > 1) {
181
- // const closed = stack.pop()
182
- // const innerHTML = closed.html.join('')
183
- // stack[stack.length - 1].html.push(`<ul>${innerHTML}</ul>`)
184
- // }
185
- // console.log(stack)
186
-
187
- // return stack[0].html.join('')
188
- // }
189
-
190
- const preloadWords = async () => {
191
- const wordsToPreload = sortedWords.value
192
- if (!wordsToPreload.length) return
193
-
194
- for (let i = 0; i < wordsToPreload.length; i++) {
195
- const word = wordsToPreload[i]
196
-
197
- // 如果已经缓存过,跳过
198
- if (wordCache.value[word]) continue
199
-
200
- const url = `${EN_WORDS_REPO_BASE_RAW_URL}${encodeURIComponent(
201
- word.toLowerCase().replaceAll(/\s/g, '_')
202
- )}.md`
203
- try {
204
- const res = await fetch(url)
205
- if (res.ok) {
206
- let text = await res.text()
207
- text = marked.parse(text)
208
- wordCache.value[word] = text
209
- console.log(`✅ 预加载完成: ${word}`)
210
- } else {
211
- wordCache.value[word] = `<em>无法加载单词内容</em>`
212
- failedWords.value[word] = true
213
- }
214
- } catch (err) {
215
- console.error(`❌ 加载失败: ${word}`, err)
216
- wordCache.value[word] = `<em>加载失败</em>`
217
- failedWords.value[word] = true
218
- }
219
-
220
- // 可选:加个延迟避免并发请求过多
221
- await new Promise((resolve) => setTimeout(resolve, 200))
222
- }
223
- }
224
-
225
- const pinCard = (word) => {
226
- // const key = `${pathname}-${word}`
227
- // const storedState = localStorage.getItem(key)
228
- // if (!storedState || storedState !== 'true') {
229
- // updateCheckedState(word, true)
230
- // }
231
-
232
- // 如果已存在该卡片则不再重复添加
233
- if (pinnedCards.value.some((card) => card.word === word)) return
234
-
235
- pinnedCards.value.push({
236
- id: Date.now(),
237
- word,
238
- x: cardX.value,
239
- y: cardY.value,
240
- content: cardContent.value,
241
- width: CARD_DEFAULT_WIDTH,
242
- height: CARD_DEFAULT_HEIGHT,
243
- zIndex: topZIndex.value++,
244
- })
245
- }
246
-
247
- const bringToFront = (card) => {
248
- const index = pinnedCards.value.indexOf(card)
249
- if (index > -1) {
250
- pinnedCards.value = [
251
- ...pinnedCards.value.slice(0, index),
252
- ...pinnedCards.value.slice(index + 1),
253
- { ...card, zIndex: topZIndex.value++ },
254
- ]
255
- }
256
- }
257
-
258
- const removeCard = (id) => {
259
- pinnedCards.value = pinnedCards.value.filter((card) => card.id !== id)
260
- }
261
-
262
- const startDrag = (card, e) => {
263
- draggingCard = card
264
- offsetX = e.clientX - card.x
265
- offsetY = e.clientY - card.y
266
- document.addEventListener('mousemove', onDragging)
267
- document.addEventListener('mouseup', stopDrag)
268
- }
269
-
270
- const onDragging = (e) => {
271
- if (!draggingCard) return
272
- draggingCard.x = e.clientX - offsetX
273
- draggingCard.y = e.clientY - offsetY
274
- }
275
-
276
- const stopDrag = () => {
277
- draggingCard = null
278
- document.removeEventListener('mousemove', onDragging)
279
- document.removeEventListener('mouseup', stopDrag)
280
- }
281
-
282
- const showContextMenu = (e, word) => {
283
- e.preventDefault()
284
- currentWordForContextMenu = word
285
- contextMenuX.value = e.clientX
286
- contextMenuY.value = e.clientY
287
- contextMenuVisible.value = true
288
- }
289
-
290
- const hideContextMenu = () => {
291
- contextMenuVisible.value = false
292
- }
293
-
294
- const handleContextMenuPin = () => {
295
- if (currentWordForContextMenu) {
296
- const word = currentWordForContextMenu
297
- // 提前加载内容
298
- showWordCard(
299
- { clientX: contextMenuX.value, clientY: contextMenuY.value },
300
- word
301
- ).then(() => {
302
- pinCard(word)
303
- showCard.value = false
304
- })
305
- hideContextMenu()
306
- }
307
- }
308
-
309
- const startResize = (card, e) => {
310
- resizingCard = card
311
- startX = e.clientX
312
- startY = e.clientY
313
- startWidth = card.width
314
- startHeight = card.height
315
-
316
- document.addEventListener('mousemove', onResizing)
317
- document.addEventListener('mouseup', stopResize)
318
- }
319
-
320
- const onResizing = (e) => {
321
- if (!resizingCard) return
322
-
323
- const newWidth = startWidth + (e.clientX - startX)
324
- const newHeight = startHeight + (e.clientY - startY)
325
-
326
- // 设置最小尺寸
327
- if (newWidth > 200) resizingCard.width = newWidth
328
- if (newHeight > 100) resizingCard.height = newHeight
329
- }
330
-
331
- const stopResize = () => {
332
- resizingCard = null
333
- document.removeEventListener('mousemove', onResizing)
334
- document.removeEventListener('mouseup', stopResize)
335
- }
336
-
337
- /**
338
- * 处理鼠标离开事件
339
- */
340
- const handleMouseLeave = () => {
341
- setTimeout(() => {
342
- hideWordCard()
343
- }, 100)
344
- }
345
-
346
- /**
347
- * 隐藏单词卡片
348
- */
349
- const hideWordCard = () => {
350
- showCard.value = false
351
- }
352
-
353
- // pronounce ----------------------------------------------------------
354
-
355
- let currentPronounceAllIndex = ref(0)
356
- let isPronouncingAll = ref(false)
357
- let pronounceAllInterval = null
358
-
359
- const handlePronounceAll = (lang) => {
360
- if (isPronouncingAll.value) {
361
- // 如果正在播放,就停止
362
- stopPronounceAll()
363
- return
364
- }
365
-
366
- const wordsToSpeak = sortedWords.value
367
- if (!wordsToSpeak.length) return
368
-
369
- currentPronounceAllIndex.value = 0
370
- isPronouncingAll.value = true
371
-
372
- const speakNext = async () => {
373
- if (
374
- !isPronouncingAll.value ||
375
- currentPronounceAllIndex.value >= wordsToSpeak.length
376
- ) {
377
- stopPronounceAll()
378
- return
379
- }
380
-
381
- const word = wordsToSpeak[currentPronounceAllIndex.value]
382
- const utterance = new SpeechSynthesisUtterance(word)
383
- utterance.lang = lang
384
- speechSynthesis.speak(utterance)
385
-
386
- await nextTick()
387
- currentPronounceAllIndex.value++
388
- }
389
-
390
- speakNext()
391
-
392
- // 每隔 1.5 秒读一个词
393
- pronounceAllInterval = setInterval(speakNext, 1500)
394
-
395
- hideContextMenu()
396
- }
397
-
398
- const stopPronounceAll = () => {
399
- isPronouncingAll.value = false
400
- if (pronounceAllInterval) {
401
- clearInterval(pronounceAllInterval)
402
- pronounceAllInterval = null
403
- }
404
- speechSynthesis.cancel() // 停止所有未完成的语音
405
- }
406
-
407
- const handlePronounce = (word, lang = 'en-GB') => {
408
- if ('speechSynthesis' in window) {
409
- stopPronounceAll()
410
-
411
- const utterance = new SpeechSynthesisUtterance(word)
412
- utterance.lang = lang
413
- speechSynthesis.speak(utterance)
414
- hideContextMenu()
415
- } else {
416
- alert('您的浏览器不支持语音功能,请尝试使用 Chrome 或 Edge 浏览器。')
417
- }
418
- }
419
-
420
- // hooks ----------------------------------------------------------
421
-
422
- onMounted(() => {
423
- sortedWords.value.forEach((word) => {
424
- const key = `${pathname}-${word}`
425
- const storedState = localStorage.getItem(key)
426
- checkedStates.value[word] = storedState === 'true'
427
- })
428
-
429
- if (!isMobile.value) preloadWords()
430
-
431
- // 添加点击事件监听以隐藏右键菜单
432
- if (typeof document !== 'undefined') {
433
- document.body.addEventListener('click', hideContextMenu)
434
- }
435
- })
436
-
437
- /**
438
- * 销毁时清理定时器
439
- */
440
- onUnmounted(() => {
441
- clearTimeout(hoverTimer)
442
- if (typeof document !== 'undefined') {
443
- document.removeEventListener('mousemove', onDragging)
444
- document.removeEventListener('mouseup', stopDrag)
445
- document.body.removeEventListener('click', hideContextMenu)
446
- }
447
- })
448
- </script>
449
-
450
- <template>
451
- <div class="enWordList">
452
- <ol>
453
- <li
454
- v-for="(word, index) in sortedWords"
455
- :key="word"
456
- :class="{
457
- pronounced: isPronouncingAll && currentPronounceAllIndex === index + 1,
458
- }"
459
- >
460
- <span class="index">{{ index + 1 }}.</span>
461
- <input
462
- type="checkbox"
463
- :id="word"
464
- :checked="checkedStates[word]"
465
- @change="(e) => updateCheckedState(word, e.target.checked)"
466
- />
467
- <label :for="word">
468
- <a
469
- :href="`${EN_WORDS_REPO_BASE_URL}${encodeURIComponent(
470
- word.toLowerCase().replaceAll(/\s/g, '_')
471
- )}.md`"
472
- :class="{
473
- lineThrough: checkedStates[word],
474
- textRed: failedWords[word],
475
- }"
476
- @mouseenter="(e) => isAutoShowCard && showWordCard(e, word)"
477
- @mouseleave="handleMouseLeave"
478
- @contextmenu="(e) => showContextMenu(e, word)"
479
- @click.ctrl.exact="(e) => handlePronounce(word)"
480
- >
481
- {{ word }}
482
- </a>
483
- </label>
484
- </li>
485
- </ol>
486
-
487
- <div
488
- class="wordCard"
489
- :style="{ left: cardX + 'px', top: cardY + 'px' }"
490
- v-if="showCard"
491
- >
492
- <div class="wordCardContent" v-html="cardContent"></div>
493
- </div>
494
-
495
- <!-- pinned cards -->
496
- <div
497
- v-for="card in pinnedCards"
498
- :key="card.id"
499
- class="wordCard"
500
- :style="{
501
- left: card.x + 'px',
502
- top: card.y + 'px',
503
- width: card.width + 'px',
504
- height: card.height + 'px',
505
- zIndex: card.zIndex,
506
- }"
507
- @mousedown="(e) => startDrag(card, e)"
508
- @click="bringToFront(card)"
509
- >
510
- <div class="wordCardContentWrapper">
511
- <div class="wordCardContent" v-html="card.content"></div>
512
- </div>
513
- <button class="closeBtn" @click.stop="removeCard(card.id)">
514
-
515
- </button>
516
- <div
517
- class="resizeHandle"
518
- @mousedown.stop="startResize(card, $event)"
519
- ></div>
520
- </div>
521
- </div>
522
-
523
- <RightClickMenu
524
- v-if="!isMobile"
525
- :show="contextMenuVisible"
526
- :x="contextMenuX"
527
- :y="contextMenuY"
528
- :isAutoShowCard="isAutoShowCard"
529
- @pin="handleContextMenuPin"
530
- @pronounce="(lang) => handlePronounce(currentWordForContextMenu, lang)"
531
- @pronounceAll="(lang) => handlePronounceAll(lang)"
532
- @autoShowCard="
533
- () => {
534
- isAutoShowCard = !isAutoShowCard
535
- hideContextMenu()
536
- }
537
- "
538
- @checkAll="checkAll"
539
- @reset="reset"
540
- />
541
- </template>
542
-
543
- <style scoped lang="scss">
544
- /* EnWordList 组件样式 */
545
-
546
- .enWordList {
547
- // Checkbox 样式
548
- input[type='checkbox'] {
549
- margin: 8px;
550
- transform: scale(1.3);
551
- cursor: pointer;
552
- }
553
-
554
- // 链接样式
555
- a {
556
- text-decoration: none;
557
- color: #4fc3f7;
558
-
559
- &:hover {
560
- text-decoration: underline !important;
561
- }
562
-
563
- &.lineThrough {
564
- color: #999;
565
- text-decoration: line-through;
566
- }
567
-
568
- &.textRed {
569
- color: #f40 !important;
570
- }
571
- }
572
-
573
- // 单词列表样式
574
- ol {
575
- list-style-type: decimal;
576
- padding-left: 20px;
577
-
578
- li {
579
- display: flex;
580
- align-items: center;
581
- margin-bottom: 8px;
582
- transition: all 0.3s ease;
583
-
584
- &.pronounced {
585
- background-color: rgba(255, 255, 0, 0.1);
586
- }
587
- }
588
- }
589
-
590
- // 序号样式
591
- .index {
592
- margin-right: 10px;
593
- color: #aaa;
594
- }
595
- }
596
-
597
- // 单词卡片样式(🌑 暗色悬浮卡片)
598
- .wordCard {
599
- position: fixed;
600
- z-index: 9999;
601
- background: #1e1e1e;
602
- border: 1px solid #333;
603
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
604
- padding: 12px 16px;
605
- max-width: 600px;
606
- min-width: 200px;
607
- min-height: 100px;
608
- font-size: 14px;
609
- line-height: 1.4;
610
- border-radius: 8px;
611
- color: #eee;
612
- pointer-events: auto;
613
- font-family: sans-serif;
614
- cursor: move;
615
-
616
- // 关闭按钮
617
- .closeBtn {
618
- position: absolute;
619
- right: 5px;
620
- top: 5px;
621
- background: none;
622
- border: none;
623
- font-size: 16px;
624
- cursor: pointer;
625
- color: #ccc;
626
-
627
- &:hover {
628
- color: white;
629
- }
630
- }
631
- }
632
-
633
- // 卡片内容包裹器
634
- .wordCardContentWrapper {
635
- width: 100%;
636
- height: 100%;
637
- overflow: auto;
638
- }
639
-
640
- // 卡片内容样式
641
- .wordCardContent {
642
- :deep(ul) {
643
- margin: 0.5rem 0;
644
- padding-left: 1rem;
645
- }
646
- }
647
-
648
- // 调整大小手柄
649
- .resizeHandle {
650
- position: absolute;
651
- right: 0;
652
- bottom: 0;
653
- width: 12px;
654
- height: 12px;
655
- background-color: #666;
656
- cursor: nwse-resize;
657
- z-index: 2;
658
- border-radius: 50%;
659
-
660
- &:hover {
661
- background-color: #aaa;
662
- }
663
- }
664
- </style>
1
+ <script setup>
2
+ import { marked } from 'marked'
3
+ import { computed, ref, onMounted, onUnmounted, nextTick } from 'vue'
4
+
5
+ import {
6
+ EN_WORDS_REPO_BASE_URL,
7
+ EN_WORDS_REPO_BASE_RAW_URL,
8
+ EN_WORD_LIST_COMP_IS_AUTO_SHOW_CARD,
9
+ } from '../constants.ts'
10
+ import RightClickMenu from './RightClickMenu.vue'
11
+
12
+ const props = defineProps({
13
+ words: {
14
+ type: Array,
15
+ default: () => [],
16
+ },
17
+ needSort: {
18
+ type: Boolean,
19
+ default: false,
20
+ },
21
+ })
22
+
23
+ const isMobile = computed(() => {
24
+ if (typeof navigator === 'undefined') return false
25
+ return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
26
+ navigator.userAgent
27
+ )
28
+ })
29
+
30
+ // checkbox ---------------------------------------------------
31
+
32
+ const pathname = typeof window !== 'undefined' ? window.location.pathname : ''
33
+ const sortedWords = props.needSort
34
+ ? computed(() =>
35
+ [...new Set(props.words)].sort(
36
+ (a, b) =>
37
+ a.toLowerCase()[0].charCodeAt() - b.toLowerCase()[0].charCodeAt()
38
+ )
39
+ )
40
+ : computed(() => [...new Set(props.words)])
41
+ const checkedStates = ref({})
42
+
43
+ const updateCheckedState = (word, isChecked) => {
44
+ const key = `${pathname}-${word}`
45
+ checkedStates.value[word] = isChecked
46
+ localStorage.setItem(key, isChecked)
47
+ }
48
+
49
+ const checkAll = () => {
50
+ Object.keys(checkedStates.value).forEach((word) => {
51
+ updateCheckedState(word, true)
52
+ })
53
+ hideContextMenu()
54
+ }
55
+
56
+ const reset = () => {
57
+ sortedWords.value.forEach((word) => {
58
+ const key = `${pathname}-${word}`
59
+ localStorage.removeItem(key)
60
+ checkedStates.value[word] = false
61
+ })
62
+ hideContextMenu()
63
+ }
64
+
65
+ // word card ---------------------------------------------------
66
+
67
+ const topZIndex = ref(10000)
68
+
69
+ const isAutoShowCard = ref(false)
70
+
71
+ // 卡片状态
72
+ const showCard = ref(false)
73
+ const cardX = ref(0)
74
+ const cardY = ref(0)
75
+ const cardContent = ref('')
76
+ const wordCache = ref({})
77
+
78
+ // 加载失败的词汇(也就是词库中不存在的词汇)
79
+ const failedWords = ref({})
80
+
81
+ // pinnedCards: { id, word, x, y, isDragging }
82
+ const pinnedCards = ref([])
83
+ let draggingCard = null
84
+ let offsetX = 0
85
+ let offsetY = 0
86
+
87
+ const CARD_DEFAULT_WIDTH = 400
88
+ const CARD_DEFAULT_HEIGHT = 500
89
+
90
+ let resizingCard = null
91
+ let startX = 0
92
+ let startY = 0
93
+ let startWidth = 0
94
+ let startHeight = 0
95
+
96
+ // 右键菜单状态
97
+ const contextMenuVisible = ref(false)
98
+ const contextMenuX = ref(0)
99
+ const contextMenuY = ref(0)
100
+ let currentWordForContextMenu = null
101
+
102
+ // 防抖计时器
103
+ let hoverTimer = null
104
+
105
+ /**
106
+ * 显示单词卡片
107
+ */
108
+ const showWordCard = async (e, word) => {
109
+ cardContent.value = '<em>加载中……</em>'
110
+ return new Promise((resolve) => {
111
+ clearTimeout(hoverTimer)
112
+ hoverTimer = setTimeout(async () => {
113
+ const { clientX, clientY } = e
114
+ cardX.value = clientX + 10
115
+ cardY.value = clientY + 10
116
+ showCard.value = true
117
+
118
+ if (wordCache.value[word]) {
119
+ cardContent.value = wordCache.value[word]
120
+ resolve()
121
+ return
122
+ }
123
+
124
+ const url = `${EN_WORDS_REPO_BASE_RAW_URL}${encodeURIComponent(
125
+ word.toLowerCase().replaceAll(/\s/g, '_')
126
+ )}.md`
127
+ try {
128
+ const res = await fetch(url)
129
+ if (res.ok) {
130
+ let text = await res.text()
131
+ text = marked.parse(text)
132
+ wordCache.value[word] = text
133
+ cardContent.value = text
134
+ } else {
135
+ cardContent.value = `<em>无法加载单词内容</em>`
136
+ }
137
+ } catch (err) {
138
+ console.error(err)
139
+ cardContent.value = `<em>加载失败</em>`
140
+ }
141
+ resolve()
142
+ }, 300)
143
+ })
144
+ }
145
+
146
+ // const convertMarkdownToHTML = (text) => {
147
+ // const lines = text.trim().split('\n')
148
+ // let stack = [{ level: -1, html: [] }]
149
+
150
+ // for (let line of lines) {
151
+ // const match = line.match(/^(\s*)-\s(.*)/)
152
+ // if (!match) continue
153
+
154
+ // const indent = match[1].length
155
+ // const content = match[2]
156
+ // const currentLevel = stack[stack.length - 1]
157
+
158
+ // const imageMatch = content.match(/^!\$$(.+?)$$/)
159
+ // const processedContent = imageMatch
160
+ // ? `<img src="${imageMatch[1]}" alt="" />`
161
+ // : content
162
+
163
+ // // 1. 深了:如果缩进比上一级更深,开启新子列表
164
+ // if (indent > currentLevel.level) {
165
+ // stack.push({ level: indent, html: [] })
166
+ // }
167
+ // // 2. 浅了:如果缩进更浅,关闭之前的列表直到匹配层级
168
+ // else if (indent < currentLevel.level) {
169
+ // while (stack.length > 1 && stack[stack.length - 2].level >= indent) {
170
+ // const closed = stack.pop()
171
+ // const innerHTML = closed.html.join('')
172
+ // stack[stack.length - 1].html.push(`<ul>${innerHTML}</ul>`)
173
+ // }
174
+ // }
175
+ // // 3. 一致:stack[-1] 是与当前 indent 层级一致的节点,添加当前 li 内容。
176
+ // stack[stack.length - 1].html.push(`<li>${processedContent}</li>`)
177
+ // }
178
+
179
+ // // 清理栈中剩余的 ul
180
+ // while (stack.length > 1) {
181
+ // const closed = stack.pop()
182
+ // const innerHTML = closed.html.join('')
183
+ // stack[stack.length - 1].html.push(`<ul>${innerHTML}</ul>`)
184
+ // }
185
+ // console.log(stack)
186
+
187
+ // return stack[0].html.join('')
188
+ // }
189
+
190
+ const preloadWords = async () => {
191
+ const wordsToPreload = sortedWords.value
192
+ if (!wordsToPreload.length) return
193
+
194
+ for (let i = 0; i < wordsToPreload.length; i++) {
195
+ const word = wordsToPreload[i]
196
+
197
+ // 如果已经缓存过,跳过
198
+ if (wordCache.value[word]) continue
199
+
200
+ const url = `${EN_WORDS_REPO_BASE_RAW_URL}${encodeURIComponent(
201
+ word.toLowerCase().replaceAll(/\s/g, '_')
202
+ )}.md`
203
+ try {
204
+ const res = await fetch(url)
205
+ if (res.ok) {
206
+ let text = await res.text()
207
+ text = marked.parse(text)
208
+ wordCache.value[word] = text
209
+ console.log(`✅ 预加载完成: ${word}`)
210
+ } else {
211
+ wordCache.value[word] = `<em>无法加载单词内容</em>`
212
+ failedWords.value[word] = true
213
+ }
214
+ } catch (err) {
215
+ console.error(`❌ 加载失败: ${word}`, err)
216
+ wordCache.value[word] = `<em>加载失败</em>`
217
+ failedWords.value[word] = true
218
+ }
219
+
220
+ // 可选:加个延迟避免并发请求过多
221
+ await new Promise((resolve) => setTimeout(resolve, 200))
222
+ }
223
+ }
224
+
225
+ const pinCard = (word) => {
226
+ // const key = `${pathname}-${word}`
227
+ // const storedState = localStorage.getItem(key)
228
+ // if (!storedState || storedState !== 'true') {
229
+ // updateCheckedState(word, true)
230
+ // }
231
+
232
+ // 如果已存在该卡片则不再重复添加
233
+ if (pinnedCards.value.some((card) => card.word === word)) return
234
+
235
+ pinnedCards.value.push({
236
+ id: Date.now(),
237
+ word,
238
+ x: cardX.value,
239
+ y: cardY.value,
240
+ content: cardContent.value,
241
+ width: CARD_DEFAULT_WIDTH,
242
+ height: CARD_DEFAULT_HEIGHT,
243
+ zIndex: topZIndex.value++,
244
+ })
245
+ }
246
+
247
+ const bringToFront = (card) => {
248
+ const index = pinnedCards.value.indexOf(card)
249
+ if (index > -1) {
250
+ pinnedCards.value = [
251
+ ...pinnedCards.value.slice(0, index),
252
+ ...pinnedCards.value.slice(index + 1),
253
+ { ...card, zIndex: topZIndex.value++ },
254
+ ]
255
+ }
256
+ }
257
+
258
+ const removeCard = (id) => {
259
+ pinnedCards.value = pinnedCards.value.filter((card) => card.id !== id)
260
+ }
261
+
262
+ const startDrag = (card, e) => {
263
+ draggingCard = card
264
+ offsetX = e.clientX - card.x
265
+ offsetY = e.clientY - card.y
266
+ document.addEventListener('mousemove', onDragging)
267
+ document.addEventListener('mouseup', stopDrag)
268
+ }
269
+
270
+ const onDragging = (e) => {
271
+ if (!draggingCard) return
272
+ draggingCard.x = e.clientX - offsetX
273
+ draggingCard.y = e.clientY - offsetY
274
+ }
275
+
276
+ const stopDrag = () => {
277
+ draggingCard = null
278
+ document.removeEventListener('mousemove', onDragging)
279
+ document.removeEventListener('mouseup', stopDrag)
280
+ }
281
+
282
+ const showContextMenu = (e, word) => {
283
+ e.preventDefault()
284
+ currentWordForContextMenu = word
285
+ contextMenuX.value = e.clientX
286
+ contextMenuY.value = e.clientY
287
+ contextMenuVisible.value = true
288
+ }
289
+
290
+ const hideContextMenu = () => {
291
+ contextMenuVisible.value = false
292
+ }
293
+
294
+ const handleContextMenuPin = () => {
295
+ if (currentWordForContextMenu) {
296
+ const word = currentWordForContextMenu
297
+ // 提前加载内容
298
+ showWordCard(
299
+ { clientX: contextMenuX.value, clientY: contextMenuY.value },
300
+ word
301
+ ).then(() => {
302
+ pinCard(word)
303
+ showCard.value = false
304
+ })
305
+ hideContextMenu()
306
+ }
307
+ }
308
+
309
+ const startResize = (card, e) => {
310
+ resizingCard = card
311
+ startX = e.clientX
312
+ startY = e.clientY
313
+ startWidth = card.width
314
+ startHeight = card.height
315
+
316
+ document.addEventListener('mousemove', onResizing)
317
+ document.addEventListener('mouseup', stopResize)
318
+ }
319
+
320
+ const onResizing = (e) => {
321
+ if (!resizingCard) return
322
+
323
+ const newWidth = startWidth + (e.clientX - startX)
324
+ const newHeight = startHeight + (e.clientY - startY)
325
+
326
+ // 设置最小尺寸
327
+ if (newWidth > 200) resizingCard.width = newWidth
328
+ if (newHeight > 100) resizingCard.height = newHeight
329
+ }
330
+
331
+ const stopResize = () => {
332
+ resizingCard = null
333
+ document.removeEventListener('mousemove', onResizing)
334
+ document.removeEventListener('mouseup', stopResize)
335
+ }
336
+
337
+ /**
338
+ * 处理鼠标离开事件
339
+ */
340
+ const handleMouseLeave = () => {
341
+ setTimeout(() => {
342
+ hideWordCard()
343
+ }, 100)
344
+ }
345
+
346
+ /**
347
+ * 隐藏单词卡片
348
+ */
349
+ const hideWordCard = () => {
350
+ showCard.value = false
351
+ }
352
+
353
+ // pronounce ----------------------------------------------------------
354
+
355
+ let currentPronounceAllIndex = ref(0)
356
+ let isPronouncingAll = ref(false)
357
+ let pronounceAllInterval = null
358
+
359
+ const handlePronounceAll = (lang) => {
360
+ if (isPronouncingAll.value) {
361
+ // 如果正在播放,就停止
362
+ stopPronounceAll()
363
+ return
364
+ }
365
+
366
+ const wordsToSpeak = sortedWords.value
367
+ if (!wordsToSpeak.length) return
368
+
369
+ currentPronounceAllIndex.value = 0
370
+ isPronouncingAll.value = true
371
+
372
+ const speakNext = async () => {
373
+ if (
374
+ !isPronouncingAll.value ||
375
+ currentPronounceAllIndex.value >= wordsToSpeak.length
376
+ ) {
377
+ stopPronounceAll()
378
+ return
379
+ }
380
+
381
+ const word = wordsToSpeak[currentPronounceAllIndex.value]
382
+ const utterance = new SpeechSynthesisUtterance(word)
383
+ utterance.lang = lang
384
+ speechSynthesis.speak(utterance)
385
+
386
+ await nextTick()
387
+ currentPronounceAllIndex.value++
388
+ }
389
+
390
+ speakNext()
391
+
392
+ // 每隔 1.5 秒读一个词
393
+ pronounceAllInterval = setInterval(speakNext, 1500)
394
+
395
+ hideContextMenu()
396
+ }
397
+
398
+ const stopPronounceAll = () => {
399
+ isPronouncingAll.value = false
400
+ if (pronounceAllInterval) {
401
+ clearInterval(pronounceAllInterval)
402
+ pronounceAllInterval = null
403
+ }
404
+ speechSynthesis.cancel() // 停止所有未完成的语音
405
+ }
406
+
407
+ const handlePronounce = (word, lang = 'en-GB') => {
408
+ if ('speechSynthesis' in window) {
409
+ stopPronounceAll()
410
+
411
+ const utterance = new SpeechSynthesisUtterance(word)
412
+ utterance.lang = lang
413
+ speechSynthesis.speak(utterance)
414
+ hideContextMenu()
415
+ } else {
416
+ alert('您的浏览器不支持语音功能,请尝试使用 Chrome 或 Edge 浏览器。')
417
+ }
418
+ }
419
+
420
+ // hooks ----------------------------------------------------------
421
+
422
+ onMounted(() => {
423
+ sortedWords.value.forEach((word) => {
424
+ const key = `${pathname}-${word}`
425
+ const storedState = localStorage.getItem(key)
426
+ checkedStates.value[word] = storedState === 'true'
427
+ })
428
+
429
+ if (!isMobile.value) preloadWords()
430
+
431
+ // 添加点击事件监听以隐藏右键菜单
432
+ if (typeof document !== 'undefined') {
433
+ document.body.addEventListener('click', hideContextMenu)
434
+ }
435
+ })
436
+
437
+ /**
438
+ * 销毁时清理定时器
439
+ */
440
+ onUnmounted(() => {
441
+ clearTimeout(hoverTimer)
442
+ if (typeof document !== 'undefined') {
443
+ document.removeEventListener('mousemove', onDragging)
444
+ document.removeEventListener('mouseup', stopDrag)
445
+ document.body.removeEventListener('click', hideContextMenu)
446
+ }
447
+ })
448
+ </script>
449
+
450
+ <template>
451
+ <div class="enWordList">
452
+ <ol>
453
+ <li
454
+ v-for="(word, index) in sortedWords"
455
+ :key="word"
456
+ :class="{
457
+ pronounced: isPronouncingAll && currentPronounceAllIndex === index + 1,
458
+ }"
459
+ >
460
+ <span class="index">{{ index + 1 }}.</span>
461
+ <input
462
+ type="checkbox"
463
+ :id="word"
464
+ :checked="checkedStates[word]"
465
+ @change="(e) => updateCheckedState(word, e.target.checked)"
466
+ />
467
+ <label :for="word">
468
+ <a
469
+ :href="`${EN_WORDS_REPO_BASE_URL}${encodeURIComponent(
470
+ word.toLowerCase().replaceAll(/\s/g, '_')
471
+ )}.md`"
472
+ :class="{
473
+ lineThrough: checkedStates[word],
474
+ textRed: failedWords[word],
475
+ }"
476
+ @mouseenter="(e) => isAutoShowCard && showWordCard(e, word)"
477
+ @mouseleave="handleMouseLeave"
478
+ @contextmenu="(e) => showContextMenu(e, word)"
479
+ @click.ctrl.exact="(e) => handlePronounce(word)"
480
+ >
481
+ {{ word }}
482
+ </a>
483
+ </label>
484
+ </li>
485
+ </ol>
486
+
487
+ <div
488
+ class="wordCard"
489
+ :style="{ left: cardX + 'px', top: cardY + 'px' }"
490
+ v-if="showCard"
491
+ >
492
+ <div class="wordCardContent" v-html="cardContent"></div>
493
+ </div>
494
+
495
+ <!-- pinned cards -->
496
+ <div
497
+ v-for="card in pinnedCards"
498
+ :key="card.id"
499
+ class="wordCard"
500
+ :style="{
501
+ left: card.x + 'px',
502
+ top: card.y + 'px',
503
+ width: card.width + 'px',
504
+ height: card.height + 'px',
505
+ zIndex: card.zIndex,
506
+ }"
507
+ @mousedown="(e) => startDrag(card, e)"
508
+ @click="bringToFront(card)"
509
+ >
510
+ <div class="wordCardContentWrapper">
511
+ <div class="wordCardContent" v-html="card.content"></div>
512
+ </div>
513
+ <button class="closeBtn" @click.stop="removeCard(card.id)">
514
+
515
+ </button>
516
+ <div
517
+ class="resizeHandle"
518
+ @mousedown.stop="startResize(card, $event)"
519
+ ></div>
520
+ </div>
521
+ </div>
522
+
523
+ <RightClickMenu
524
+ v-if="!isMobile"
525
+ :show="contextMenuVisible"
526
+ :x="contextMenuX"
527
+ :y="contextMenuY"
528
+ :isAutoShowCard="isAutoShowCard"
529
+ @pin="handleContextMenuPin"
530
+ @pronounce="(lang) => handlePronounce(currentWordForContextMenu, lang)"
531
+ @pronounceAll="(lang) => handlePronounceAll(lang)"
532
+ @autoShowCard="
533
+ () => {
534
+ isAutoShowCard = !isAutoShowCard
535
+ hideContextMenu()
536
+ }
537
+ "
538
+ @checkAll="checkAll"
539
+ @reset="reset"
540
+ />
541
+ </template>
542
+
543
+ <style scoped lang="scss">
544
+ /* EnWordList 组件样式 */
545
+
546
+ .enWordList {
547
+ // Checkbox 样式
548
+ input[type='checkbox'] {
549
+ margin: 8px;
550
+ transform: scale(1.3);
551
+ cursor: pointer;
552
+ }
553
+
554
+ // 链接样式
555
+ a {
556
+ text-decoration: none;
557
+ color: #4fc3f7;
558
+
559
+ &:hover {
560
+ text-decoration: underline !important;
561
+ }
562
+
563
+ &.lineThrough {
564
+ color: #999;
565
+ text-decoration: line-through;
566
+ }
567
+
568
+ &.textRed {
569
+ color: #f40 !important;
570
+ }
571
+ }
572
+
573
+ // 单词列表样式
574
+ ol {
575
+ list-style-type: decimal;
576
+ padding-left: 20px;
577
+
578
+ li {
579
+ display: flex;
580
+ align-items: center;
581
+ margin-bottom: 8px;
582
+ transition: all 0.3s ease;
583
+
584
+ &.pronounced {
585
+ background-color: rgba(255, 255, 0, 0.1);
586
+ }
587
+ }
588
+ }
589
+
590
+ // 序号样式
591
+ .index {
592
+ margin-right: 10px;
593
+ color: #aaa;
594
+ }
595
+ }
596
+
597
+ // 单词卡片样式(🌑 暗色悬浮卡片)
598
+ .wordCard {
599
+ position: fixed;
600
+ z-index: 9999;
601
+ background: #1e1e1e;
602
+ border: 1px solid #333;
603
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
604
+ padding: 12px 16px;
605
+ max-width: 600px;
606
+ min-width: 200px;
607
+ min-height: 100px;
608
+ font-size: 14px;
609
+ line-height: 1.4;
610
+ border-radius: 8px;
611
+ color: #eee;
612
+ pointer-events: auto;
613
+ font-family: sans-serif;
614
+ cursor: move;
615
+
616
+ // 关闭按钮
617
+ .closeBtn {
618
+ position: absolute;
619
+ right: 5px;
620
+ top: 5px;
621
+ background: none;
622
+ border: none;
623
+ font-size: 16px;
624
+ cursor: pointer;
625
+ color: #ccc;
626
+
627
+ &:hover {
628
+ color: white;
629
+ }
630
+ }
631
+ }
632
+
633
+ // 卡片内容包裹器
634
+ .wordCardContentWrapper {
635
+ width: 100%;
636
+ height: 100%;
637
+ overflow: auto;
638
+ }
639
+
640
+ // 卡片内容样式
641
+ .wordCardContent {
642
+ :deep(ul) {
643
+ margin: 0.5rem 0;
644
+ padding-left: 1rem;
645
+ }
646
+ }
647
+
648
+ // 调整大小手柄
649
+ .resizeHandle {
650
+ position: absolute;
651
+ right: 0;
652
+ bottom: 0;
653
+ width: 12px;
654
+ height: 12px;
655
+ background-color: #666;
656
+ cursor: nwse-resize;
657
+ z-index: 2;
658
+ border-radius: 50%;
659
+
660
+ &:hover {
661
+ background-color: #aaa;
662
+ }
663
+ }
664
+ </style>