@tnotesjs/core 0.0.6 → 0.0.8

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 (116) hide show
  1. package/README.md +87 -84
  2. package/dist/{chunk-JDVN2QYW.js → chunk-JUMVJKWV.js} +615 -614
  3. package/dist/cli/index.js +125 -125
  4. package/dist/vitepress/config/index.js +184 -210
  5. package/package.json +15 -3
  6. package/types/config.ts +61 -61
  7. package/types/index.ts +11 -11
  8. package/types/note.ts +33 -33
  9. package/types/shims.d.ts +3 -4
  10. package/vitepress/assets/icons/icon__check.svg +3 -3
  11. package/vitepress/assets/icons/icon__clipboard.svg +7 -7
  12. package/vitepress/assets/icons/icon__close.svg +1 -1
  13. package/vitepress/assets/icons/icon__collapse.svg +1 -1
  14. package/vitepress/assets/icons/icon__confirm.svg +1 -1
  15. package/vitepress/assets/icons/icon__copy.svg +4 -4
  16. package/vitepress/assets/icons/icon__focus.svg +1 -1
  17. package/vitepress/assets/icons/icon__fold.svg +3 -3
  18. package/vitepress/assets/icons/icon__folder.svg +1 -1
  19. package/vitepress/assets/icons/icon__fullscreen.svg +1 -1
  20. package/vitepress/assets/icons/icon__fullscreen_exit.svg +1 -1
  21. package/vitepress/assets/icons/icon__github.svg +3 -3
  22. package/vitepress/assets/icons/icon__mindmap.svg +1 -1
  23. package/vitepress/assets/icons/icon__next.svg +1 -1
  24. package/vitepress/assets/icons/icon__number_gray.svg +1 -1
  25. package/vitepress/assets/icons/icon__number_purple.svg +1 -1
  26. package/vitepress/assets/icons/icon__prev.svg +1 -1
  27. package/vitepress/assets/icons/icon__restore.svg +1 -1
  28. package/vitepress/assets/icons/icon__rotate.svg +4 -4
  29. package/vitepress/assets/icons/icon__search.svg +1 -1
  30. package/vitepress/assets/icons/icon__sidebar_collapsed.svg +1 -1
  31. package/vitepress/assets/icons/icon__sidebar_opened.svg +1 -1
  32. package/vitepress/assets/icons/icon__totop.svg +5 -5
  33. package/vitepress/assets/icons/icon__vscode.svg +5 -5
  34. package/vitepress/assets/icons/icon__zoom_fit.svg +1 -1
  35. package/vitepress/assets/icons/icon__zoom_in.svg +1 -1
  36. package/vitepress/assets/icons/icon__zoom_out.svg +1 -1
  37. package/vitepress/assets/icons/icon__zoom_reset.svg +1 -1
  38. package/vitepress/assets/icons/index.ts +38 -38
  39. package/vitepress/components/BilibiliOutsidePlayer/BilibiliOutsidePlayer.vue +20 -20
  40. package/vitepress/components/CodeBlockFullscreen/CodeBlockFullscreen.vue +373 -372
  41. package/vitepress/components/CodeBlockFullscreen/index.ts +115 -114
  42. package/vitepress/components/CodeBlockFullscreen/styles.css +64 -64
  43. package/vitepress/components/Discussions/Discussions.module.scss +32 -32
  44. package/vitepress/components/Discussions/Discussions.vue +211 -210
  45. package/vitepress/components/EnWordList/EnWordList.module.scss +124 -124
  46. package/vitepress/components/EnWordList/EnWordList.vue +543 -542
  47. package/vitepress/components/EnWordList/RightClickMenu.module.scss +22 -22
  48. package/vitepress/components/EnWordList/RightClickMenu.vue +66 -66
  49. package/vitepress/components/Footprints/Footprints.module.scss +93 -93
  50. package/vitepress/components/Footprints/Footprints.vue +377 -377
  51. package/vitepress/components/Layout/AboutModal.module.scss +233 -233
  52. package/vitepress/components/Layout/AboutModal.vue +105 -105
  53. package/vitepress/components/Layout/AboutPanel.vue +270 -265
  54. package/vitepress/components/Layout/ContentCollapse.vue +603 -602
  55. package/vitepress/components/Layout/CustomSidebar.vue +608 -605
  56. package/vitepress/components/Layout/DocBeforeControls.vue +149 -139
  57. package/vitepress/components/Layout/DocFooter.vue +233 -232
  58. package/vitepress/components/Layout/ImagePreview.module.scss +201 -201
  59. package/vitepress/components/Layout/ImagePreview.vue +281 -280
  60. package/vitepress/components/Layout/Layout.module.scss +661 -661
  61. package/vitepress/components/Layout/Layout.vue +539 -542
  62. package/vitepress/components/Layout/NoteStatus.vue +140 -139
  63. package/vitepress/components/Layout/SidebarItems.vue +267 -262
  64. package/vitepress/components/Layout/SidebarNavBefore.vue +92 -92
  65. package/vitepress/components/Layout/Swiper.vue +167 -167
  66. package/vitepress/components/Layout/ToggleFullContent.module.scss +11 -11
  67. package/vitepress/components/Layout/ToggleFullContent.vue +35 -34
  68. package/vitepress/components/Layout/ToggleSidebar.module.scss +11 -11
  69. package/vitepress/components/Layout/ToggleSidebar.vue +36 -35
  70. package/vitepress/components/Layout/composables/useCollapseControl.ts +88 -88
  71. package/vitepress/components/Layout/composables/useNoteConfig.ts +119 -121
  72. package/vitepress/components/Layout/composables/useNoteSave.ts +173 -172
  73. package/vitepress/components/Layout/composables/useNoteValidation.ts +85 -85
  74. package/vitepress/components/Layout/composables/useRedirect.ts +113 -110
  75. package/vitepress/components/Layout/composables/useVSCodeIntegration.ts +86 -83
  76. package/vitepress/components/Layout/homeReadme.data.ts +124 -124
  77. package/vitepress/components/LoadingPage/LoadingPage.vue +192 -192
  78. package/vitepress/components/MarkMap/MarkMap.module.scss +159 -159
  79. package/vitepress/components/MarkMap/MarkMap.vue +405 -404
  80. package/vitepress/components/Mermaid/Mermaid.module.scss +275 -275
  81. package/vitepress/components/Mermaid/Mermaid.vue +365 -363
  82. package/vitepress/components/NotesTable/NotesTable.module.scss +77 -77
  83. package/vitepress/components/NotesTable/NotesTable.vue +98 -97
  84. package/vitepress/components/NotesTable/README.md +67 -67
  85. package/vitepress/components/Settings/Settings.module.scss +433 -433
  86. package/vitepress/components/Settings/Settings.vue +306 -305
  87. package/vitepress/components/SidebarCard/MindMapView.vue +484 -483
  88. package/vitepress/components/SidebarCard/NotesTrendChart.vue +108 -108
  89. package/vitepress/components/SidebarCard/SidebarCard.vue +946 -948
  90. package/vitepress/components/Tooltip/Tooltip.vue +70 -70
  91. package/vitepress/components/constants.ts +91 -91
  92. package/vitepress/components/notesConfig.data.ts +73 -73
  93. package/vitepress/components/sidebar.data.ts +59 -59
  94. package/vitepress/components/tnotes-config.data.ts +21 -21
  95. package/vitepress/components/utils.ts +26 -26
  96. package/vitepress/config/index.ts +160 -146
  97. package/vitepress/configs/constants.ts +26 -26
  98. package/vitepress/configs/head.config.ts +25 -24
  99. package/vitepress/configs/index.ts +9 -9
  100. package/vitepress/configs/markdown-it.d.ts +23 -20
  101. package/vitepress/configs/markdown.config.ts +366 -365
  102. package/vitepress/configs/theme.config.ts +108 -108
  103. package/vitepress/plugins/buildProgressPlugin.ts +434 -470
  104. package/vitepress/plugins/getNoteByConfigIdPlugin.ts +107 -106
  105. package/vitepress/plugins/renameNotePlugin.ts +60 -59
  106. package/vitepress/plugins/updateConfigPlugin.ts +63 -62
  107. package/vitepress/theme/index.ts +95 -93
  108. package/vitepress/theme/styles/base.scss +50 -50
  109. package/vitepress/theme/styles/components/404.scss +31 -31
  110. package/vitepress/theme/styles/components/collapse.scss +175 -175
  111. package/vitepress/theme/styles/components/markmap.scss +101 -101
  112. package/vitepress/theme/styles/components/swiper.scss +255 -255
  113. package/vitepress/theme/styles/index.scss +25 -25
  114. package/vitepress/theme/styles/layout.scss +62 -62
  115. package/vitepress/theme/styles/utilities.scss +39 -39
  116. package/vitepress/theme/styles/vitepress-override.scss +25 -25
@@ -1,543 +1,544 @@
1
- <script setup>
1
+ <script setup>
2
+ import { marked } from 'marked'
2
3
  import { computed, ref, onMounted, onUnmounted, nextTick } from 'vue'
3
- import { marked } from 'marked'
4
- import {
5
- EN_WORDS_REPO_BASE_URL,
6
- EN_WORDS_REPO_BASE_RAW_URL,
7
- EN_WORD_LIST_COMP_IS_AUTO_SHOW_CARD,
8
- } from '../constants.ts'
9
- import RightClickMenu from './RightClickMenu.vue'
10
-
11
- const props = defineProps({
12
- words: {
13
- type: Array,
14
- default: () => [],
15
- },
16
- needSort: {
17
- type: Boolean,
18
- default: false,
19
- },
20
- })
21
-
22
- const isMobile = computed(() => {
23
- if (typeof navigator === 'undefined') return false
24
- return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
25
- navigator.userAgent
26
- )
27
- })
28
-
29
- // checkbox ---------------------------------------------------
30
-
31
- const pathname = typeof window !== 'undefined' ? window.location.pathname : ''
32
- const sortedWords = props.needSort
33
- ? computed(() =>
34
- [...new Set(props.words)].sort(
35
- (a, b) =>
36
- a.toLowerCase()[0].charCodeAt() - b.toLowerCase()[0].charCodeAt()
37
- )
38
- )
39
- : computed(() => [...new Set(props.words)])
40
- const checkedStates = ref({})
41
-
42
- const updateCheckedState = (word, isChecked) => {
43
- const key = `${pathname}-${word}`
44
- checkedStates.value[word] = isChecked
45
- localStorage.setItem(key, isChecked)
46
- }
47
-
48
- const checkAll = () => {
49
- Object.keys(checkedStates.value).forEach((word) => {
50
- updateCheckedState(word, true)
51
- })
52
- hideContextMenu()
53
- }
54
-
55
- const reset = () => {
56
- sortedWords.value.forEach((word) => {
57
- const key = `${pathname}-${word}`
58
- localStorage.removeItem(key)
59
- checkedStates.value[word] = false
60
- })
61
- hideContextMenu()
62
- }
63
-
64
- // word card ---------------------------------------------------
65
-
66
- const topZIndex = ref(10000)
67
-
68
- const isAutoShowCard = ref(false)
69
-
70
- // 卡片状态
71
- const showCard = ref(false)
72
- const cardX = ref(0)
73
- const cardY = ref(0)
74
- const cardContent = ref('')
75
- const wordCache = ref({})
76
-
77
- // 加载失败的词汇(也就是词库中不存在的词汇)
78
- const failedWords = ref({})
79
-
80
- // pinnedCards: { id, word, x, y, isDragging }
81
- const pinnedCards = ref([])
82
- let draggingCard = null
83
- let offsetX = 0
84
- let offsetY = 0
85
-
86
- const CARD_DEFAULT_WIDTH = 400
87
- const CARD_DEFAULT_HEIGHT = 500
88
-
89
- let resizingCard = null
90
- let startX = 0
91
- let startY = 0
92
- let startWidth = 0
93
- let startHeight = 0
94
-
95
- // 右键菜单状态
96
- const contextMenuVisible = ref(false)
97
- const contextMenuX = ref(0)
98
- const contextMenuY = ref(0)
99
- let currentWordForContextMenu = null
100
-
101
- // 防抖计时器
102
- let hoverTimer = null
103
-
104
- /**
105
- * 显示单词卡片
106
- */
107
- const showWordCard = async (e, word) => {
108
- cardContent.value = '<em>加载中……</em>'
109
- return new Promise((resolve) => {
110
- clearTimeout(hoverTimer)
111
- hoverTimer = setTimeout(async () => {
112
- const { clientX, clientY } = e
113
- cardX.value = clientX + 10
114
- cardY.value = clientY + 10
115
- showCard.value = true
116
-
117
- if (wordCache.value[word]) {
118
- cardContent.value = wordCache.value[word]
119
- resolve()
120
- return
121
- }
122
-
123
- const url = `${EN_WORDS_REPO_BASE_RAW_URL}${encodeURIComponent(
124
- word.toLowerCase().replaceAll(/\s/g, '_')
125
- )}.md`
126
- try {
127
- const res = await fetch(url)
128
- if (res.ok) {
129
- let text = await res.text()
130
- text = marked.parse(text)
131
- wordCache.value[word] = text
132
- cardContent.value = text
133
- } else {
134
- cardContent.value = `<em>无法加载单词内容</em>`
135
- }
136
- } catch (err) {
137
- console.error(err)
138
- cardContent.value = `<em>加载失败</em>`
139
- }
140
- resolve()
141
- }, 300)
142
- })
143
- }
144
-
145
- // const convertMarkdownToHTML = (text) => {
146
- // const lines = text.trim().split('\n')
147
- // let stack = [{ level: -1, html: [] }]
148
-
149
- // for (let line of lines) {
150
- // const match = line.match(/^(\s*)-\s(.*)/)
151
- // if (!match) continue
152
-
153
- // const indent = match[1].length
154
- // const content = match[2]
155
- // const currentLevel = stack[stack.length - 1]
156
-
157
- // const imageMatch = content.match(/^!\$$(.+?)$$/)
158
- // const processedContent = imageMatch
159
- // ? `<img src="${imageMatch[1]}" alt="" />`
160
- // : content
161
-
162
- // // 1. 深了:如果缩进比上一级更深,开启新子列表
163
- // if (indent > currentLevel.level) {
164
- // stack.push({ level: indent, html: [] })
165
- // }
166
- // // 2. 浅了:如果缩进更浅,关闭之前的列表直到匹配层级
167
- // else if (indent < currentLevel.level) {
168
- // while (stack.length > 1 && stack[stack.length - 2].level >= indent) {
169
- // const closed = stack.pop()
170
- // const innerHTML = closed.html.join('')
171
- // stack[stack.length - 1].html.push(`<ul>${innerHTML}</ul>`)
172
- // }
173
- // }
174
- // // 3. 一致:stack[-1] 是与当前 indent 层级一致的节点,添加当前 li 内容。
175
- // stack[stack.length - 1].html.push(`<li>${processedContent}</li>`)
176
- // }
177
-
178
- // // 清理栈中剩余的 ul
179
- // while (stack.length > 1) {
180
- // const closed = stack.pop()
181
- // const innerHTML = closed.html.join('')
182
- // stack[stack.length - 1].html.push(`<ul>${innerHTML}</ul>`)
183
- // }
184
- // console.log(stack)
185
-
186
- // return stack[0].html.join('')
187
- // }
188
-
189
- const preloadWords = async () => {
190
- const wordsToPreload = sortedWords.value
191
- if (!wordsToPreload.length) return
192
-
193
- for (let i = 0; i < wordsToPreload.length; i++) {
194
- const word = wordsToPreload[i]
195
-
196
- // 如果已经缓存过,跳过
197
- if (wordCache.value[word]) continue
198
-
199
- const url = `${EN_WORDS_REPO_BASE_RAW_URL}${encodeURIComponent(
200
- word.toLowerCase().replaceAll(/\s/g, '_')
201
- )}.md`
202
- try {
203
- const res = await fetch(url)
204
- if (res.ok) {
205
- let text = await res.text()
206
- text = marked.parse(text)
207
- wordCache.value[word] = text
208
- console.log(`✅ 预加载完成: ${word}`)
209
- } else {
210
- wordCache.value[word] = `<em>无法加载单词内容</em>`
211
- failedWords.value[word] = true
212
- }
213
- } catch (err) {
214
- console.error(`❌ 加载失败: ${word}`, err)
215
- wordCache.value[word] = `<em>加载失败</em>`
216
- failedWords.value[word] = true
217
- }
218
-
219
- // 可选:加个延迟避免并发请求过多
220
- await new Promise((resolve) => setTimeout(resolve, 200))
221
- }
222
- }
223
-
224
- const pinCard = (word) => {
225
- // const key = `${pathname}-${word}`
226
- // const storedState = localStorage.getItem(key)
227
- // if (!storedState || storedState !== 'true') {
228
- // updateCheckedState(word, true)
229
- // }
230
-
231
- // 如果已存在该卡片则不再重复添加
232
- if (pinnedCards.value.some((card) => card.word === word)) return
233
-
234
- pinnedCards.value.push({
235
- id: Date.now(),
236
- word,
237
- x: cardX.value,
238
- y: cardY.value,
239
- content: cardContent.value,
240
- width: CARD_DEFAULT_WIDTH,
241
- height: CARD_DEFAULT_HEIGHT,
242
- zIndex: topZIndex.value++,
243
- })
244
- }
245
-
246
- const bringToFront = (card) => {
247
- const index = pinnedCards.value.indexOf(card)
248
- if (index > -1) {
249
- pinnedCards.value = [
250
- ...pinnedCards.value.slice(0, index),
251
- ...pinnedCards.value.slice(index + 1),
252
- { ...card, zIndex: topZIndex.value++ },
253
- ]
254
- }
255
- }
256
-
257
- const removeCard = (id) => {
258
- pinnedCards.value = pinnedCards.value.filter((card) => card.id !== id)
259
- }
260
-
261
- const startDrag = (card, e) => {
262
- draggingCard = card
263
- offsetX = e.clientX - card.x
264
- offsetY = e.clientY - card.y
265
- document.addEventListener('mousemove', onDragging)
266
- document.addEventListener('mouseup', stopDrag)
267
- }
268
-
269
- const onDragging = (e) => {
270
- if (!draggingCard) return
271
- draggingCard.x = e.clientX - offsetX
272
- draggingCard.y = e.clientY - offsetY
273
- }
274
-
275
- const stopDrag = () => {
276
- draggingCard = null
277
- document.removeEventListener('mousemove', onDragging)
278
- document.removeEventListener('mouseup', stopDrag)
279
- }
280
-
281
- const showContextMenu = (e, word) => {
282
- e.preventDefault()
283
- currentWordForContextMenu = word
284
- contextMenuX.value = e.clientX
285
- contextMenuY.value = e.clientY
286
- contextMenuVisible.value = true
287
- }
288
-
289
- const hideContextMenu = () => {
290
- contextMenuVisible.value = false
291
- }
292
-
293
- const handleContextMenuPin = () => {
294
- if (currentWordForContextMenu) {
295
- const word = currentWordForContextMenu
296
- // 提前加载内容
297
- showWordCard(
298
- { clientX: contextMenuX.value, clientY: contextMenuY.value },
299
- word
300
- ).then(() => {
301
- pinCard(word)
302
- showCard.value = false
303
- })
304
- hideContextMenu()
305
- }
306
- }
307
-
308
- const startResize = (card, e) => {
309
- resizingCard = card
310
- startX = e.clientX
311
- startY = e.clientY
312
- startWidth = card.width
313
- startHeight = card.height
314
-
315
- document.addEventListener('mousemove', onResizing)
316
- document.addEventListener('mouseup', stopResize)
317
- }
318
-
319
- const onResizing = (e) => {
320
- if (!resizingCard) return
321
-
322
- const newWidth = startWidth + (e.clientX - startX)
323
- const newHeight = startHeight + (e.clientY - startY)
324
-
325
- // 设置最小尺寸
326
- if (newWidth > 200) resizingCard.width = newWidth
327
- if (newHeight > 100) resizingCard.height = newHeight
328
- }
329
-
330
- const stopResize = () => {
331
- resizingCard = null
332
- document.removeEventListener('mousemove', onResizing)
333
- document.removeEventListener('mouseup', stopResize)
334
- }
335
-
336
- /**
337
- * 处理鼠标离开事件
338
- */
339
- const handleMouseLeave = () => {
340
- setTimeout(() => {
341
- hideWordCard()
342
- }, 100)
343
- }
344
-
345
- /**
346
- * 隐藏单词卡片
347
- */
348
- const hideWordCard = () => {
349
- showCard.value = false
350
- }
351
-
352
- // pronounce ----------------------------------------------------------
353
-
354
- let currentPronounceAllIndex = ref(0)
355
- let isPronouncingAll = ref(false)
356
- let pronounceAllInterval = null
357
-
358
- const handlePronounceAll = (lang) => {
359
- if (isPronouncingAll.value) {
360
- // 如果正在播放,就停止
361
- stopPronounceAll()
362
- return
363
- }
364
-
365
- const wordsToSpeak = sortedWords.value
366
- if (!wordsToSpeak.length) return
367
-
368
- currentPronounceAllIndex.value = 0
369
- isPronouncingAll.value = true
370
-
371
- const speakNext = async () => {
372
- if (
373
- !isPronouncingAll.value ||
374
- currentPronounceAllIndex.value >= wordsToSpeak.length
375
- ) {
376
- stopPronounceAll()
377
- return
378
- }
379
-
380
- const word = wordsToSpeak[currentPronounceAllIndex.value]
381
- const utterance = new SpeechSynthesisUtterance(word)
382
- utterance.lang = lang
383
- speechSynthesis.speak(utterance)
384
-
385
- await nextTick()
386
- currentPronounceAllIndex.value++
387
- }
388
-
389
- speakNext()
390
-
391
- // 每隔 1.5 秒读一个词
392
- pronounceAllInterval = setInterval(speakNext, 1500)
393
-
394
- hideContextMenu()
395
- }
396
-
397
- const stopPronounceAll = () => {
398
- isPronouncingAll.value = false
399
- if (pronounceAllInterval) {
400
- clearInterval(pronounceAllInterval)
401
- pronounceAllInterval = null
402
- }
403
- speechSynthesis.cancel() // 停止所有未完成的语音
404
- }
405
-
406
- const handlePronounce = (word, lang = 'en-GB') => {
407
- if ('speechSynthesis' in window) {
408
- stopPronounceAll()
409
-
410
- const utterance = new SpeechSynthesisUtterance(word)
411
- utterance.lang = lang
412
- speechSynthesis.speak(utterance)
413
- hideContextMenu()
414
- } else {
415
- alert('您的浏览器不支持语音功能,请尝试使用 Chrome 或 Edge 浏览器。')
416
- }
417
- }
418
-
419
- // hooks ----------------------------------------------------------
420
-
421
- onMounted(() => {
422
- sortedWords.value.forEach((word) => {
423
- const key = `${pathname}-${word}`
424
- const storedState = localStorage.getItem(key)
425
- checkedStates.value[word] = storedState === 'true'
426
- })
427
-
428
- if (!isMobile.value) preloadWords()
429
-
430
- // 添加点击事件监听以隐藏右键菜单
431
- if (typeof document !== 'undefined') {
432
- document.body.addEventListener('click', hideContextMenu)
433
- }
434
- })
435
-
436
- /**
437
- * 销毁时清理定时器
438
- */
439
- onUnmounted(() => {
440
- clearTimeout(hoverTimer)
441
- if (typeof document !== 'undefined') {
442
- document.removeEventListener('mousemove', onDragging)
443
- document.removeEventListener('mouseup', stopDrag)
444
- document.body.removeEventListener('click', hideContextMenu)
445
- }
446
- })
447
- </script>
448
-
449
- <template>
450
- <div :class="$style.enWordList">
451
- <ol>
452
- <li
453
- v-for="(word, index) in sortedWords"
454
- :key="word"
455
- :class="{
456
- [$style.pronounced]:
457
- isPronouncingAll && currentPronounceAllIndex === index + 1,
458
- }"
459
- >
460
- <span :class="$style.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
- [$style.lineThrough]: checkedStates[word],
474
- [$style.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="$style.wordCard"
489
- :style="{ left: cardX + 'px', top: cardY + 'px' }"
490
- v-if="showCard"
491
- >
492
- <div :class="$style.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="$style.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="$style.wordCardContentWrapper">
511
- <div :class="$style.wordCardContent" v-html="card.content"></div>
512
- </div>
513
- <button :class="$style.closeBtn" @click.stop="removeCard(card.id)">
514
-
515
- </button>
516
- <div
517
- :class="$style.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 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>