@tnotesjs/core 0.1.21 → 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 (122) hide show
  1. package/README.md +87 -87
  2. package/dist/{chunk-4MO7F7Q4.js → chunk-BSTQOJVE.js} +1 -1
  3. package/dist/{chunk-SQVHJR2Z.js → chunk-O4DCXHOV.js} +0 -3
  4. package/dist/cli/index.js +2 -2
  5. package/dist/index.js +1 -1
  6. package/dist/vitepress/config/index.js +2 -2
  7. package/package.json +3 -4
  8. package/types/config.ts +60 -61
  9. package/types/index.ts +11 -11
  10. package/types/note.ts +33 -33
  11. package/vitepress/assets/icons/icon__check.svg +3 -3
  12. package/vitepress/assets/icons/icon__clipboard.svg +7 -7
  13. package/vitepress/assets/icons/icon__close.svg +1 -1
  14. package/vitepress/assets/icons/icon__collapse.svg +1 -1
  15. package/vitepress/assets/icons/icon__confirm.svg +1 -1
  16. package/vitepress/assets/icons/icon__copy.svg +4 -4
  17. package/vitepress/assets/icons/icon__focus.svg +1 -1
  18. package/vitepress/assets/icons/icon__fold.svg +3 -3
  19. package/vitepress/assets/icons/icon__folder.svg +1 -1
  20. package/vitepress/assets/icons/icon__fullscreen.svg +1 -1
  21. package/vitepress/assets/icons/icon__fullscreen_exit.svg +1 -1
  22. package/vitepress/assets/icons/icon__github.svg +3 -3
  23. package/vitepress/assets/icons/icon__mindmap.svg +1 -1
  24. package/vitepress/assets/icons/icon__next.svg +1 -1
  25. package/vitepress/assets/icons/icon__number_gray.svg +1 -1
  26. package/vitepress/assets/icons/icon__number_purple.svg +1 -1
  27. package/vitepress/assets/icons/icon__prev.svg +1 -1
  28. package/vitepress/assets/icons/icon__restore.svg +1 -1
  29. package/vitepress/assets/icons/icon__rotate.svg +4 -4
  30. package/vitepress/assets/icons/icon__search.svg +1 -1
  31. package/vitepress/assets/icons/icon__sidebar_collapsed.svg +1 -1
  32. package/vitepress/assets/icons/icon__sidebar_opened.svg +1 -1
  33. package/vitepress/assets/icons/icon__totop.svg +5 -5
  34. package/vitepress/assets/icons/icon__vscode.svg +5 -5
  35. package/vitepress/assets/icons/icon__zoom_fit.svg +1 -1
  36. package/vitepress/assets/icons/icon__zoom_in.svg +1 -1
  37. package/vitepress/assets/icons/icon__zoom_out.svg +1 -1
  38. package/vitepress/assets/icons/icon__zoom_reset.svg +1 -1
  39. package/vitepress/assets/icons/index.ts +39 -39
  40. package/vitepress/components/BilibiliOutsidePlayer/BilibiliOutsidePlayer.vue +20 -20
  41. package/vitepress/components/CodeBlockFullscreen/CodeBlockFullscreen.vue +369 -369
  42. package/vitepress/components/CodeBlockFullscreen/index.ts +209 -213
  43. package/vitepress/components/CodeBlockFullscreen/styles.css +126 -126
  44. package/vitepress/components/Discussions/Discussions.vue +244 -211
  45. package/vitepress/components/EnWordList/EnWordList.vue +663 -543
  46. package/vitepress/components/EnWordList/RightClickMenu.vue +89 -66
  47. package/vitepress/components/Footprints/Footprints.vue +472 -377
  48. package/vitepress/components/Layout/AboutModal.vue +331 -105
  49. package/vitepress/components/Layout/AboutPanel.vue +484 -270
  50. package/vitepress/components/Layout/ContentCollapse.vue +590 -603
  51. package/vitepress/components/Layout/CustomSidebar.vue +817 -817
  52. package/vitepress/components/Layout/DocBeforeControls.vue +351 -149
  53. package/vitepress/components/Layout/DocFooter.vue +233 -233
  54. package/vitepress/components/Layout/ImagePreview.vue +484 -281
  55. package/vitepress/components/Layout/Layout.vue +744 -621
  56. package/vitepress/components/Layout/NavBarSettingsTrigger.vue +0 -2
  57. package/vitepress/components/Layout/NoteStatus.vue +140 -140
  58. package/vitepress/components/Layout/SidebarItems.vue +1083 -1083
  59. package/vitepress/components/Layout/SidebarNavBefore.vue +170 -171
  60. package/vitepress/components/Layout/SidebarResizeHandle.vue +311 -319
  61. package/vitepress/components/Layout/Swiper.vue +167 -167
  62. package/vitepress/components/Layout/ToggleFullContent.vue +47 -35
  63. package/vitepress/components/Layout/ToggleSidebar.vue +37 -26
  64. package/vitepress/components/Layout/composables/useCollapseControl.ts +88 -88
  65. package/vitepress/components/Layout/composables/useNoteConfig.ts +119 -119
  66. package/vitepress/components/Layout/composables/useNoteSave.ts +178 -178
  67. package/vitepress/components/Layout/composables/useNoteValidation.ts +85 -85
  68. package/vitepress/components/Layout/composables/useRedirect.ts +113 -113
  69. package/vitepress/components/Layout/composables/useSidebarLayout.ts +122 -122
  70. package/vitepress/components/Layout/composables/useVSCodeIntegration.ts +85 -85
  71. package/vitepress/components/Layout/homeReadme.data.ts +124 -124
  72. package/vitepress/components/LoadingPage/LoadingPage.vue +1 -6
  73. package/vitepress/components/MarkMap/MarkMap.vue +564 -405
  74. package/vitepress/components/Mermaid/Mermaid.vue +643 -365
  75. package/vitepress/components/NotesTable/NotesTable.vue +174 -98
  76. package/vitepress/components/NotesTable/README.md +67 -67
  77. package/vitepress/components/Settings/Settings.vue +711 -334
  78. package/vitepress/components/Settings/SettingsDialog.vue +1 -11
  79. package/vitepress/components/SidebarCard/MindMapView.vue +484 -484
  80. package/vitepress/components/SidebarCard/NotesTrendChart.vue +108 -108
  81. package/vitepress/components/SidebarCard/SidebarCard.vue +945 -946
  82. package/vitepress/components/Tooltip/Tooltip.vue +70 -70
  83. package/vitepress/components/constants.ts +109 -109
  84. package/vitepress/components/notesConfig.data.ts +73 -73
  85. package/vitepress/components/sidebar.data.ts +59 -59
  86. package/vitepress/components/tnotes-config.data.ts +21 -21
  87. package/vitepress/components/utils.ts +26 -26
  88. package/vitepress/config/index.ts +169 -169
  89. package/vitepress/configs/constants.ts +26 -26
  90. package/vitepress/configs/head.config.ts +25 -25
  91. package/vitepress/configs/index.ts +9 -9
  92. package/vitepress/configs/markdown-it.d.ts +23 -23
  93. package/vitepress/configs/markdown.config.ts +368 -367
  94. package/vitepress/configs/theme.config.ts +108 -108
  95. package/vitepress/plugins/buildProgressPlugin.ts +434 -434
  96. package/vitepress/plugins/getNoteByConfigIdPlugin.ts +107 -107
  97. package/vitepress/plugins/renameNotePlugin.ts +68 -68
  98. package/vitepress/plugins/sidebarStructurePlugin.ts +260 -260
  99. package/vitepress/plugins/updateConfigPlugin.ts +63 -63
  100. package/vitepress/theme/index.ts +137 -137
  101. package/vitepress/theme/styles/base.scss +66 -64
  102. package/vitepress/theme/styles/components/404.scss +31 -31
  103. package/vitepress/theme/styles/components/collapse.scss +172 -172
  104. package/vitepress/theme/styles/components/markmap.scss +101 -101
  105. package/vitepress/theme/styles/components/swiper.scss +255 -255
  106. package/vitepress/theme/styles/index.scss +25 -25
  107. package/vitepress/theme/styles/layout.scss +78 -78
  108. package/vitepress/theme/styles/utilities.scss +39 -39
  109. package/vitepress/theme/styles/vitepress-override.scss +25 -25
  110. package/vitepress/components/Discussions/Discussions.module.scss +0 -32
  111. package/vitepress/components/EnWordList/EnWordList.module.scss +0 -124
  112. package/vitepress/components/EnWordList/RightClickMenu.module.scss +0 -22
  113. package/vitepress/components/Footprints/Footprints.module.scss +0 -93
  114. package/vitepress/components/Layout/AboutModal.module.scss +0 -233
  115. package/vitepress/components/Layout/ImagePreview.module.scss +0 -201
  116. package/vitepress/components/Layout/Layout.module.scss +0 -661
  117. package/vitepress/components/Layout/ToggleFullContent.module.scss +0 -11
  118. package/vitepress/components/Layout/ToggleSidebar.module.scss +0 -11
  119. package/vitepress/components/MarkMap/MarkMap.module.scss +0 -159
  120. package/vitepress/components/Mermaid/Mermaid.module.scss +0 -275
  121. package/vitepress/components/NotesTable/NotesTable.module.scss +0 -77
  122. package/vitepress/components/Settings/Settings.module.scss +0 -375
@@ -1,544 +1,664 @@
1
- <script setup>
2
- import { marked } from 'marked'
1
+ <script setup>
2
+ import { marked } from 'marked'
3
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="$style.enWordList">
452
- <ol>
453
- <li
454
- v-for="(word, index) in sortedWords"
455
- :key="word"
456
- :class="{
457
- [$style.pronounced]:
458
- isPronouncingAll && currentPronounceAllIndex === index + 1,
459
- }"
460
- >
461
- <span :class="$style.index">{{ index + 1 }}.</span>
462
- <input
463
- type="checkbox"
464
- :id="word"
465
- :checked="checkedStates[word]"
466
- @change="(e) => updateCheckedState(word, e.target.checked)"
467
- />
468
- <label :for="word">
469
- <a
470
- :href="`${EN_WORDS_REPO_BASE_URL}${encodeURIComponent(
471
- word.toLowerCase().replaceAll(/\s/g, '_')
472
- )}.md`"
473
- :class="{
474
- [$style.lineThrough]: checkedStates[word],
475
- [$style.textRed]: failedWords[word],
476
- }"
477
- @mouseenter="(e) => isAutoShowCard && showWordCard(e, word)"
478
- @mouseleave="handleMouseLeave"
479
- @contextmenu="(e) => showContextMenu(e, word)"
480
- @click.ctrl.exact="(e) => handlePronounce(word)"
481
- >
482
- {{ word }}
483
- </a>
484
- </label>
485
- </li>
486
- </ol>
487
-
488
- <div
489
- :class="$style.wordCard"
490
- :style="{ left: cardX + 'px', top: cardY + 'px' }"
491
- v-if="showCard"
492
- >
493
- <div :class="$style.wordCardContent" v-html="cardContent"></div>
494
- </div>
495
-
496
- <!-- pinned cards -->
497
- <div
498
- v-for="card in pinnedCards"
499
- :key="card.id"
500
- :class="$style.wordCard"
501
- :style="{
502
- left: card.x + 'px',
503
- top: card.y + 'px',
504
- width: card.width + 'px',
505
- height: card.height + 'px',
506
- zIndex: card.zIndex,
507
- }"
508
- @mousedown="(e) => startDrag(card, e)"
509
- @click="bringToFront(card)"
510
- >
511
- <div :class="$style.wordCardContentWrapper">
512
- <div :class="$style.wordCardContent" v-html="card.content"></div>
513
- </div>
514
- <button :class="$style.closeBtn" @click.stop="removeCard(card.id)">
515
-
516
- </button>
517
- <div
518
- :class="$style.resizeHandle"
519
- @mousedown.stop="startResize(card, $event)"
520
- ></div>
521
- </div>
522
- </div>
523
-
524
- <RightClickMenu
525
- v-if="!isMobile"
526
- :show="contextMenuVisible"
527
- :x="contextMenuX"
528
- :y="contextMenuY"
529
- :isAutoShowCard="isAutoShowCard"
530
- @pin="handleContextMenuPin"
531
- @pronounce="(lang) => handlePronounce(currentWordForContextMenu, lang)"
532
- @pronounceAll="(lang) => handlePronounceAll(lang)"
533
- @autoShowCard="
534
- () => {
535
- isAutoShowCard = !isAutoShowCard
536
- hideContextMenu()
537
- }
538
- "
539
- @checkAll="checkAll"
540
- @reset="reset"
541
- />
542
- </template>
543
-
544
- <style module src="./EnWordList.module.scss"></style>
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>