@tnotesjs/core 0.1.15 → 0.1.16

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 (115) hide show
  1. package/README.md +87 -87
  2. package/dist/{chunk-FVKQPBPP.js → chunk-4MO7F7Q4.js} +2005 -1769
  3. package/dist/cli/index.js +1 -1
  4. package/dist/vitepress/config/index.js +212 -4
  5. package/package.json +1 -1
  6. package/types/config.ts +61 -61
  7. package/types/index.ts +11 -11
  8. package/types/note.ts +33 -33
  9. package/vitepress/assets/icons/icon__check.svg +3 -3
  10. package/vitepress/assets/icons/icon__clipboard.svg +7 -7
  11. package/vitepress/assets/icons/icon__close.svg +1 -1
  12. package/vitepress/assets/icons/icon__collapse.svg +1 -1
  13. package/vitepress/assets/icons/icon__confirm.svg +1 -1
  14. package/vitepress/assets/icons/icon__copy.svg +4 -4
  15. package/vitepress/assets/icons/icon__focus.svg +1 -1
  16. package/vitepress/assets/icons/icon__fold.svg +3 -3
  17. package/vitepress/assets/icons/icon__folder.svg +1 -1
  18. package/vitepress/assets/icons/icon__fullscreen.svg +1 -1
  19. package/vitepress/assets/icons/icon__fullscreen_exit.svg +1 -1
  20. package/vitepress/assets/icons/icon__github.svg +3 -3
  21. package/vitepress/assets/icons/icon__mindmap.svg +1 -1
  22. package/vitepress/assets/icons/icon__next.svg +1 -1
  23. package/vitepress/assets/icons/icon__number_gray.svg +1 -1
  24. package/vitepress/assets/icons/icon__number_purple.svg +1 -1
  25. package/vitepress/assets/icons/icon__prev.svg +1 -1
  26. package/vitepress/assets/icons/icon__restore.svg +1 -1
  27. package/vitepress/assets/icons/icon__rotate.svg +4 -4
  28. package/vitepress/assets/icons/icon__search.svg +1 -1
  29. package/vitepress/assets/icons/icon__sidebar_collapsed.svg +1 -1
  30. package/vitepress/assets/icons/icon__sidebar_opened.svg +1 -1
  31. package/vitepress/assets/icons/icon__totop.svg +5 -5
  32. package/vitepress/assets/icons/icon__vscode.svg +5 -5
  33. package/vitepress/assets/icons/icon__zoom_fit.svg +1 -1
  34. package/vitepress/assets/icons/icon__zoom_in.svg +1 -1
  35. package/vitepress/assets/icons/icon__zoom_out.svg +1 -1
  36. package/vitepress/assets/icons/icon__zoom_reset.svg +1 -1
  37. package/vitepress/assets/icons/index.ts +39 -39
  38. package/vitepress/components/BilibiliOutsidePlayer/BilibiliOutsidePlayer.vue +20 -20
  39. package/vitepress/components/CodeBlockFullscreen/CodeBlockFullscreen.vue +373 -373
  40. package/vitepress/components/CodeBlockFullscreen/index.ts +115 -115
  41. package/vitepress/components/CodeBlockFullscreen/styles.css +64 -64
  42. package/vitepress/components/Discussions/Discussions.module.scss +32 -32
  43. package/vitepress/components/Discussions/Discussions.vue +211 -211
  44. package/vitepress/components/EnWordList/EnWordList.module.scss +124 -124
  45. package/vitepress/components/EnWordList/EnWordList.vue +543 -543
  46. package/vitepress/components/EnWordList/RightClickMenu.module.scss +22 -22
  47. package/vitepress/components/EnWordList/RightClickMenu.vue +66 -66
  48. package/vitepress/components/Footprints/Footprints.module.scss +93 -93
  49. package/vitepress/components/Footprints/Footprints.vue +377 -377
  50. package/vitepress/components/Layout/AboutModal.module.scss +233 -233
  51. package/vitepress/components/Layout/AboutModal.vue +105 -105
  52. package/vitepress/components/Layout/AboutPanel.vue +270 -270
  53. package/vitepress/components/Layout/ContentCollapse.vue +603 -603
  54. package/vitepress/components/Layout/CustomSidebar.vue +878 -608
  55. package/vitepress/components/Layout/DocBeforeControls.vue +149 -149
  56. package/vitepress/components/Layout/DocFooter.vue +233 -233
  57. package/vitepress/components/Layout/ImagePreview.module.scss +201 -201
  58. package/vitepress/components/Layout/ImagePreview.vue +281 -281
  59. package/vitepress/components/Layout/Layout.module.scss +661 -661
  60. package/vitepress/components/Layout/Layout.vue +618 -559
  61. package/vitepress/components/Layout/NoteStatus.vue +140 -140
  62. package/vitepress/components/Layout/SidebarItems.vue +1083 -268
  63. package/vitepress/components/Layout/SidebarNavBefore.vue +171 -108
  64. package/vitepress/components/Layout/Swiper.vue +167 -167
  65. package/vitepress/components/Layout/ToggleFullContent.module.scss +11 -11
  66. package/vitepress/components/Layout/ToggleFullContent.vue +35 -35
  67. package/vitepress/components/Layout/ToggleSidebar.module.scss +11 -11
  68. package/vitepress/components/Layout/ToggleSidebar.vue +36 -36
  69. package/vitepress/components/Layout/composables/useCollapseControl.ts +88 -88
  70. package/vitepress/components/Layout/composables/useNoteConfig.ts +119 -119
  71. package/vitepress/components/Layout/composables/useNoteSave.ts +178 -178
  72. package/vitepress/components/Layout/composables/useNoteValidation.ts +85 -85
  73. package/vitepress/components/Layout/composables/useRedirect.ts +113 -113
  74. package/vitepress/components/Layout/composables/useVSCodeIntegration.ts +85 -85
  75. package/vitepress/components/Layout/homeReadme.data.ts +124 -124
  76. package/vitepress/components/MarkMap/MarkMap.module.scss +159 -159
  77. package/vitepress/components/MarkMap/MarkMap.vue +405 -405
  78. package/vitepress/components/Mermaid/Mermaid.module.scss +275 -275
  79. package/vitepress/components/Mermaid/Mermaid.vue +365 -365
  80. package/vitepress/components/NotesTable/NotesTable.module.scss +77 -77
  81. package/vitepress/components/NotesTable/NotesTable.vue +98 -98
  82. package/vitepress/components/NotesTable/README.md +67 -67
  83. package/vitepress/components/Settings/Settings.module.scss +375 -433
  84. package/vitepress/components/Settings/Settings.vue +376 -306
  85. package/vitepress/components/SidebarCard/MindMapView.vue +484 -484
  86. package/vitepress/components/SidebarCard/NotesTrendChart.vue +108 -108
  87. package/vitepress/components/SidebarCard/SidebarCard.vue +946 -946
  88. package/vitepress/components/Tooltip/Tooltip.vue +70 -70
  89. package/vitepress/components/constants.ts +109 -91
  90. package/vitepress/components/notesConfig.data.ts +73 -73
  91. package/vitepress/components/sidebar.data.ts +59 -59
  92. package/vitepress/components/tnotes-config.data.ts +21 -21
  93. package/vitepress/components/utils.ts +26 -26
  94. package/vitepress/config/index.ts +169 -167
  95. package/vitepress/configs/constants.ts +26 -26
  96. package/vitepress/configs/head.config.ts +25 -25
  97. package/vitepress/configs/index.ts +9 -9
  98. package/vitepress/configs/markdown-it.d.ts +23 -23
  99. package/vitepress/configs/markdown.config.ts +366 -366
  100. package/vitepress/configs/theme.config.ts +108 -108
  101. package/vitepress/plugins/buildProgressPlugin.ts +434 -434
  102. package/vitepress/plugins/getNoteByConfigIdPlugin.ts +107 -107
  103. package/vitepress/plugins/renameNotePlugin.ts +68 -68
  104. package/vitepress/plugins/sidebarStructurePlugin.ts +260 -0
  105. package/vitepress/plugins/updateConfigPlugin.ts +63 -63
  106. package/vitepress/theme/index.ts +137 -137
  107. package/vitepress/theme/styles/base.scss +50 -50
  108. package/vitepress/theme/styles/components/404.scss +31 -31
  109. package/vitepress/theme/styles/components/collapse.scss +175 -175
  110. package/vitepress/theme/styles/components/markmap.scss +101 -101
  111. package/vitepress/theme/styles/components/swiper.scss +255 -255
  112. package/vitepress/theme/styles/index.scss +25 -25
  113. package/vitepress/theme/styles/layout.scss +70 -62
  114. package/vitepress/theme/styles/utilities.scss +39 -39
  115. package/vitepress/theme/styles/vitepress-override.scss +25 -25
@@ -1,544 +1,544 @@
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="$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>