mirror of
https://github.com/RealKai42/qwerty-learner.git
synced 2026-04-05 06:19:08 +08:00
feat: add sound play animation (#125)
* feat: add sound animation * fix: press any key will be start * fix: play multiple word sound * fix: should only add howl listener * fix: can trigger overlapping sounds manually * fix: blocked attempt to create a WebMediaPlayer - [Intervention] Blocked attempt to create a WebMediaPlayer as there are too many WebMediaPlayers already in existence. - See crbug.com/1144736#c27 * revert: revert word sound setting * refactor: give up using "useImperativeHandle" * feat: add word pronunciation hot key tips * feat: modify the hover content Co-authored-by: zhangkaiyi004 <zhangkaiyi004@ke.com>
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
"@types/howler": "^2.2.3",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/lodash": "^4.14.168",
|
||||
"@types/node": "^12.0.0",
|
||||
@@ -26,6 +27,7 @@
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"howler": "^2.2.3",
|
||||
"husky": "^4.3.8",
|
||||
"lint-staged": "^10.5.3",
|
||||
"lodash": "^4.17.20",
|
||||
@@ -42,7 +44,7 @@
|
||||
"source-map-explorer": "^2.5.2",
|
||||
"swr": "^0.4.1",
|
||||
"typescript": "^4.0.3",
|
||||
"use-sound": "^2.0.1",
|
||||
"use-sound": "^4.0.1",
|
||||
"web-vitals": "^0.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
48
src/components/SoundIcon/index.tsx
Normal file
48
src/components/SoundIcon/index.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import VolumeIcon from './volume-icons/VolumeIcon'
|
||||
import VolumeLowIcon from './volume-icons/VolumeLowIcon'
|
||||
import VolumeMediumIcon from './volume-icons/VolumeMediumIcon'
|
||||
import VolumeHighIcon from './volume-icons/VolumeHieghIcon'
|
||||
|
||||
const volumeIcons = [VolumeIcon, VolumeLowIcon, VolumeMediumIcon, VolumeHighIcon]
|
||||
|
||||
export const SoundIcon = React.memo<SoundIconProps>(({ duration = 500, animated = false, ...rest }, ref) => {
|
||||
const [animationFrameIndex, setAnimationFrameIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (animated) {
|
||||
const timer = window.setTimeout(() => {
|
||||
const index = animationFrameIndex < volumeIcons.length - 1 ? animationFrameIndex + 1 : 0
|
||||
setAnimationFrameIndex(index)
|
||||
}, duration)
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [animated, animationFrameIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (!animated) {
|
||||
const timer = setTimeout(() => {
|
||||
setAnimationFrameIndex(0)
|
||||
}, duration)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [animated])
|
||||
|
||||
const Icon = volumeIcons[animationFrameIndex]
|
||||
|
||||
return <Icon {...rest} />
|
||||
})
|
||||
|
||||
export type SoundIconProps = {
|
||||
animated?: boolean
|
||||
duration?: number
|
||||
} & Omit<React.SVGProps<SVGSVGElement>, 'ref'>
|
||||
|
||||
export type SoundIconRef = {
|
||||
playAnimation(): void
|
||||
stopAnimation(): void
|
||||
}
|
||||
12
src/components/SoundIcon/volume-icons/VolumeHieghIcon.tsx
Normal file
12
src/components/SoundIcon/volume-icons/VolumeHieghIcon.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
|
||||
function VolumeHighIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg className="prefix__icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M699.034 761.907a25.6 25.6 0 01-16.18-45.414C736.973 672.512 768 607.283 768 537.65s-31.027-134.81-85.094-178.841a25.549 25.549 0 1132.307-39.68C781.312 372.89 819.2 452.557 819.2 537.702s-37.888 164.762-103.987 218.573a25.6 25.6 0 01-16.128 5.735z" />
|
||||
<path d="M795.904 881.1a25.6 25.6 0 01-16.18-45.414C869.889 762.368 921.6 653.722 921.6 537.651s-51.712-224.717-141.875-298.035a25.549 25.549 0 1132.307-39.68C914.176 283.034 972.8 406.17 972.8 537.702s-58.573 254.67-160.768 337.767a25.6 25.6 0 01-16.128 5.734zm-193.69-238.438a25.6 25.6 0 01-16.179-45.414c18.023-14.694 28.365-36.403 28.365-59.597s-10.342-44.953-28.365-59.597a25.549 25.549 0 1132.307-39.68c30.055 24.423 47.258 60.621 47.258 99.328s-17.254 74.906-47.258 99.328a25.6 25.6 0 01-16.128 5.735zM417.28 164.198c-12.646 0-25.293 5.325-37.683 15.821L169.779 358.35H76.8c-42.342 0-76.8 34.457-76.8 76.8v204.8c0 42.342 34.458 76.8 76.8 76.8h92.98l209.817 178.33c12.339 10.495 25.037 15.82 37.683 15.82a40.755 40.755 0 0034.304-18.534c6.093-9.165 9.216-20.89 9.216-34.816v-640c0-36.864-21.862-53.402-43.52-53.402zM51.2 640V435.2a25.6 25.6 0 0125.6-25.6h76.8v256H76.8A25.6 25.6 0 0151.2 640zm358.4 213.453l-204.8-174.08V395.827l204.8-174.08v631.706z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default VolumeHighIcon
|
||||
11
src/components/SoundIcon/volume-icons/VolumeIcon.tsx
Normal file
11
src/components/SoundIcon/volume-icons/VolumeIcon.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
|
||||
function VolumeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg className="prefix__icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M417.28 164.198c-12.646 0-25.293 5.325-37.683 15.821L169.779 358.35H76.8c-42.342 0-76.8 34.457-76.8 76.8v204.8c0 42.342 34.458 76.8 76.8 76.8h92.98l209.817 178.33c12.339 10.495 25.037 15.82 37.683 15.82a40.755 40.755 0 0034.304-18.534c6.093-9.165 9.216-20.89 9.216-34.816v-640c0-36.864-21.862-53.402-43.52-53.402zM51.2 640V435.2a25.6 25.6 0 0125.6-25.6h76.8v256H76.8A25.6 25.6 0 0151.2 640zm358.4 213.453l-204.8-174.08V395.827l204.8-174.08v631.706z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default VolumeIcon
|
||||
11
src/components/SoundIcon/volume-icons/VolumeLowIcon.tsx
Normal file
11
src/components/SoundIcon/volume-icons/VolumeLowIcon.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
|
||||
function VolumeLowIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg className="prefix__icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M602.214 642.662a25.6 25.6 0 01-16.179-45.414c18.023-14.694 28.365-36.403 28.365-59.597s-10.342-44.953-28.365-59.597a25.549 25.549 0 1132.307-39.68c30.055 24.423 47.258 60.621 47.258 99.328s-17.254 74.906-47.258 99.328a25.6 25.6 0 01-16.128 5.735zM417.28 164.198c-12.646 0-25.293 5.325-37.683 15.821L169.779 358.35H76.8c-42.342 0-76.8 34.457-76.8 76.8v204.8c0 42.342 34.458 76.8 76.8 76.8h92.98l209.817 178.33c12.339 10.495 25.037 15.82 37.683 15.82a40.755 40.755 0 0034.304-18.534c6.093-9.165 9.216-20.89 9.216-34.816v-640c0-36.864-21.862-53.402-43.52-53.402zM51.2 640V435.2a25.6 25.6 0 0125.6-25.6h76.8v256H76.8A25.6 25.6 0 0151.2 640zm358.4 213.453l-204.8-174.08V395.827l204.8-174.08v631.706z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default VolumeLowIcon
|
||||
11
src/components/SoundIcon/volume-icons/VolumeMediumIcon.tsx
Normal file
11
src/components/SoundIcon/volume-icons/VolumeMediumIcon.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
|
||||
function VolumeMediumIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg className="prefix__icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M699.034 761.907a25.6 25.6 0 01-16.18-45.414C736.973 672.512 768 607.283 768 537.65s-31.027-134.81-85.094-178.841a25.549 25.549 0 1132.307-39.68C781.312 372.89 819.2 452.557 819.2 537.702s-37.888 164.762-103.987 218.573a25.6 25.6 0 01-16.128 5.735zm-96.82-119.245a25.6 25.6 0 01-16.179-45.414c18.023-14.694 28.365-36.403 28.365-59.597s-10.342-44.953-28.365-59.597a25.549 25.549 0 1132.307-39.68c30.055 24.423 47.258 60.621 47.258 99.328s-17.254 74.906-47.258 99.328a25.6 25.6 0 01-16.128 5.735zM417.28 164.198c-12.646 0-25.293 5.325-37.683 15.821L169.779 358.35H76.8c-42.342 0-76.8 34.457-76.8 76.8v204.8c0 42.342 34.458 76.8 76.8 76.8h92.98l209.817 178.33c12.339 10.495 25.037 15.82 37.683 15.82a40.755 40.755 0 0034.304-18.534c6.093-9.165 9.216-20.89 9.216-34.816v-640c0-36.864-21.862-53.402-43.52-53.402zM51.2 640V435.2a25.6 25.6 0 0125.6-25.6h76.8v256H76.8A25.6 25.6 0 0151.2 640zm358.4 213.453l-204.8-174.08V395.827l204.8-174.08v631.706z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default VolumeMediumIcon
|
||||
@@ -1,13 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { classNames } from '../../utils/utils'
|
||||
|
||||
const Tooltip: React.FC<
|
||||
React.PropsWithChildren<{
|
||||
/** 显示文本 */
|
||||
content: string
|
||||
/** 位置 */
|
||||
placement?: 'top' | 'bottom'
|
||||
}>
|
||||
> = ({ children, content, placement = 'top' }) => {
|
||||
const Tooltip: React.FC<React.PropsWithChildren<TooltipProps>> = ({ children, content, className, placement = 'top' }) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
const placementClasses = {
|
||||
@@ -16,7 +10,7 @@ const Tooltip: React.FC<
|
||||
}[placement]
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className={classNames('relative', className)}>
|
||||
<div onMouseEnter={() => setVisible(true)} onMouseLeave={() => setVisible(false)}>
|
||||
{children}
|
||||
</div>
|
||||
@@ -33,4 +27,12 @@ const Tooltip: React.FC<
|
||||
)
|
||||
}
|
||||
|
||||
export type TooltipProps = {
|
||||
/** 显示文本 */
|
||||
content: string
|
||||
/** 位置 */
|
||||
placement?: 'top' | 'bottom'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default Tooltip
|
||||
|
||||
@@ -24,3 +24,21 @@
|
||||
transform: translate3d(4px, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.word-sound {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
transform: translateY(calc(-50% - 23px));
|
||||
right: -55px;
|
||||
cursor: pointer;
|
||||
fill: theme('colors.gray.600');
|
||||
}
|
||||
.word-sound .prefix__icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.dark .word-sound {
|
||||
fill: theme('colors.gray.50');
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ import Letter, { LetterState } from './Letter'
|
||||
import { isLegal, isChineseSymbol } from '../../utils/utils'
|
||||
import useSounds from 'hooks/useSounds'
|
||||
import style from './index.module.css'
|
||||
import usePronunciationSound from 'hooks/usePronunciation'
|
||||
import WordSound from 'components/WordSound'
|
||||
import { useAppState } from '../../store/AppState'
|
||||
|
||||
const EXPLICIT_SPACE = '␣'
|
||||
|
||||
const Word: React.FC<WordProps> = ({ word = 'defaultWord', onFinish, isStart, wordVisible = true }) => {
|
||||
// Used in `usePronunciationSound`.
|
||||
const originalWord = word
|
||||
const originWord = word
|
||||
|
||||
word = word.replace(new RegExp(' ', 'g'), EXPLICIT_SPACE)
|
||||
word = word.replace(new RegExp('…', 'g'), '..')
|
||||
@@ -19,7 +19,7 @@ const Word: React.FC<WordProps> = ({ word = 'defaultWord', onFinish, isStart, wo
|
||||
const [isFinish, setIsFinish] = useState(false)
|
||||
const [hasWrong, setHasWrong] = useState(false)
|
||||
const [playKeySound, playBeepSound, playHintSound] = useSounds()
|
||||
const playPronounce = usePronunciationSound(originalWord)
|
||||
const { pronunciation } = useAppState()
|
||||
|
||||
const onKeydown = useCallback(
|
||||
(e) => {
|
||||
@@ -61,21 +61,17 @@ const Word: React.FC<WordProps> = ({ word = 'defaultWord', onFinish, isStart, wo
|
||||
useEffect(() => {
|
||||
if (hasWrong) {
|
||||
playBeepSound()
|
||||
setTimeout(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setInputWord('')
|
||||
setHasWrong(false)
|
||||
}, 300)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [hasWrong, isFinish, playBeepSound])
|
||||
|
||||
useEffect(() => {
|
||||
if (isStart && inputWord.length === 0) {
|
||||
playPronounce()
|
||||
}
|
||||
// SAFETY: Don't depend on `playPronounce`! It will cost audio play again and again.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isStart, word, inputWord])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
let hasWrong = false,
|
||||
wordLength = word.length,
|
||||
@@ -99,19 +95,26 @@ const Word: React.FC<WordProps> = ({ word = 'defaultWord', onFinish, isStart, wo
|
||||
setStatesList(statesList)
|
||||
}, [inputWord, word])
|
||||
|
||||
const playWordSound = pronunciation !== false
|
||||
|
||||
return (
|
||||
<div className={`pt-4 pb-1 flex items-center justify-center ${hasWrong ? style.wrong : ''}`}>
|
||||
{/* {console.log(inputWord, word)} */}
|
||||
{word.split('').map((t, index) => {
|
||||
return (
|
||||
<Letter
|
||||
key={`${index}-${t}`}
|
||||
visible={statesList[index] === 'correct' ? true : wordVisible}
|
||||
letter={t}
|
||||
state={statesList[index]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<div className="flex justify-center pt-4 pb-1">
|
||||
<div className="relative">
|
||||
<div className={`flex items-center justify-center ${hasWrong ? style.wrong : ''}`}>
|
||||
{/* {console.log(inputWord, word)} */}
|
||||
{word.split('').map((t, index) => {
|
||||
return (
|
||||
<Letter
|
||||
key={`${index}-${t}`}
|
||||
visible={statesList[index] === 'correct' ? true : wordVisible}
|
||||
letter={t}
|
||||
state={statesList[index]}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{playWordSound && <WordSound word={originWord} inputWord={inputWord} className={`${style['word-sound']}`} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -123,8 +126,3 @@ export type WordProps = {
|
||||
wordVisible: boolean
|
||||
}
|
||||
export default Word
|
||||
|
||||
/**
|
||||
* Warning: Can't perform a React state update on an unmounted component.
|
||||
* 为 use-sound 未解决的 bug
|
||||
*/
|
||||
|
||||
48
src/components/WordSound/index.tsx
Normal file
48
src/components/WordSound/index.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { SoundIcon, SoundIconProps } from 'components/SoundIcon'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import usePronunciationSound from 'hooks/usePronunciation'
|
||||
import React, { useEffect, useCallback } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
|
||||
const WordSound: React.FC<WordSoundProps> = React.memo(({ word, className, inputWord, ...rest }) => {
|
||||
const { play, stop, isPlaying } = usePronunciationSound(word)
|
||||
|
||||
useHotkeys(
|
||||
'ctrl+j',
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
stop()
|
||||
play()
|
||||
},
|
||||
[play, stop],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (inputWord.length === 0) {
|
||||
stop()
|
||||
play()
|
||||
}
|
||||
}, [play, inputWord, stop])
|
||||
|
||||
useEffect(() => {
|
||||
return stop
|
||||
}, [word, stop])
|
||||
|
||||
const handleClickSoundIcon = useCallback(() => {
|
||||
stop()
|
||||
play()
|
||||
}, [play, stop])
|
||||
|
||||
return (
|
||||
<Tooltip content="朗读发音(Ctrl + J)" className={className}>
|
||||
<SoundIcon animated={isPlaying} {...rest} onClick={handleClickSoundIcon} />
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
|
||||
export type WordSoundProps = {
|
||||
word: string
|
||||
inputWord: string
|
||||
} & SoundIconProps
|
||||
|
||||
export default WordSound
|
||||
@@ -1,31 +1,44 @@
|
||||
import { useAppState } from 'store/AppState'
|
||||
import { noop } from 'lodash'
|
||||
import { Howl } from 'howler'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
declare type PronounceFunction = () => void
|
||||
import { PronunciationType, useAppState } from 'store/AppState'
|
||||
import useSound from 'use-sound'
|
||||
import { HookOptions } from 'use-sound/dist/types'
|
||||
import { addHowlListener } from '../utils/utils'
|
||||
|
||||
const pronunciationApi = 'https://dict.youdao.com/dictvoice?audio='
|
||||
|
||||
export default function usePronunciationSound(word: string): PronounceFunction {
|
||||
const [audio, setAudio] = useState<HTMLAudioElement | null>(null)
|
||||
const { pronunciation } = useAppState()
|
||||
const ukPronounceFunction = () => setAudio(new Audio(pronunciationApi + word + '&type=1'))
|
||||
const usPronounceFunction = () => setAudio(new Audio(pronunciationApi + word + '&type=2'))
|
||||
|
||||
useEffect(() => {
|
||||
audio?.play()
|
||||
return () => {
|
||||
// Pause the audio when unMount
|
||||
audio?.pause()
|
||||
}
|
||||
}, [audio, word])
|
||||
|
||||
function generateWordSoundSrc(word: string, pronunciation: Exclude<PronunciationType, false>) {
|
||||
switch (pronunciation) {
|
||||
case false:
|
||||
return noop
|
||||
case 'uk':
|
||||
return ukPronounceFunction
|
||||
return `${pronunciationApi}${word}&type=1`
|
||||
case 'us':
|
||||
return usPronounceFunction
|
||||
return `${pronunciationApi}${word}&type=2`
|
||||
}
|
||||
}
|
||||
|
||||
export default function usePronunciationSound(word: string) {
|
||||
const { pronunciation } = useAppState()
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
|
||||
const [play, { stop, sound }] = useSound(generateWordSoundSrc(word, pronunciation as Exclude<PronunciationType, false>), {
|
||||
html5: true,
|
||||
format: ['mp3'],
|
||||
} as HookOptions)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sound) return
|
||||
|
||||
const unListens: Array<() => void> = []
|
||||
|
||||
unListens.push(addHowlListener(sound, 'play', () => setIsPlaying(true)))
|
||||
unListens.push(addHowlListener(sound, 'end', () => setIsPlaying(false)))
|
||||
unListens.push(addHowlListener(sound, 'pause', () => setIsPlaying(false)))
|
||||
unListens.push(addHowlListener(sound, 'playerror', () => setIsPlaying(false)))
|
||||
|
||||
return () => {
|
||||
unListens.forEach((unListen) => unListen())
|
||||
;(sound as Howl).unload()
|
||||
}
|
||||
}, [sound])
|
||||
|
||||
return { play, stop, isPlaying }
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ const Switcher: React.FC<SwitcherPropsType> = ({ state, dispatch }) => {
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center space-x-3">
|
||||
<Tooltip content="开关声音(Ctrl + M)">
|
||||
<Tooltip content="开关键盘声音(Ctrl + M)">
|
||||
<button
|
||||
className={`${state.sound ? 'text-indigo-400' : 'text-gray-400'} text-lg focus:outline-none`}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -73,12 +73,9 @@ const App: React.FC = () => {
|
||||
if (isLegal(e.key) && !e.altKey && !e.ctrlKey && !e.metaKey) {
|
||||
if (isStart) {
|
||||
setInputCount((count) => count + 1)
|
||||
} else {
|
||||
if (document.activeElement?.nodeName === 'BODY') {
|
||||
setIsStart(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
setIsStart(true)
|
||||
}
|
||||
const onBlur = () => {
|
||||
if (isStart) {
|
||||
@@ -195,19 +192,22 @@ const App: React.FC = () => {
|
||||
<Main>
|
||||
<div className="container h-full relative flex mx-auto flex-col items-center">
|
||||
<div className="h-1/3"></div>
|
||||
<div>
|
||||
<Word
|
||||
key={`word-${wordList.words[order].name}-${order}`}
|
||||
word={wordList.words[order].name}
|
||||
onFinish={onFinish}
|
||||
isStart={isStart}
|
||||
wordVisible={switcherState.wordVisible}
|
||||
/>
|
||||
{switcherState.phonetic && (wordList.words[order].usphone || wordList.words[order].ukphone) && (
|
||||
<Phonetic usphone={wordList.words[order].usphone} ukphone={wordList.words[order].ukphone} />
|
||||
)}
|
||||
<Translation key={`trans-${wordList.words[order].name}`} trans={wordList.words[order].trans.join(';')} />
|
||||
</div>
|
||||
{!isStart && <h3 className="pb-4 text-xl text-gray-600 dark:text-gray-50 animate-pulse">按任意键开始</h3>}
|
||||
{isStart && (
|
||||
<div>
|
||||
<Word
|
||||
key={`word-${wordList.words[order].name}-${order}`}
|
||||
word={wordList.words[order].name}
|
||||
onFinish={onFinish}
|
||||
isStart={isStart}
|
||||
wordVisible={switcherState.wordVisible}
|
||||
/>
|
||||
{switcherState.phonetic && (wordList.words[order].usphone || wordList.words[order].ukphone) && (
|
||||
<Phonetic usphone={wordList.words[order].usphone} ukphone={wordList.words[order].ukphone} />
|
||||
)}
|
||||
<Translation key={`trans-${wordList.words[order].name}`} trans={wordList.words[order].trans.join(';')} />
|
||||
</div>
|
||||
)}
|
||||
<Speed correctCount={correctCount} inputCount={inputCount} isStart={isStart} />
|
||||
</div>
|
||||
</Main>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Howl } from 'howler'
|
||||
|
||||
export const isLegal = (val: string): boolean => /^[a-z_A-Z_._(_)_0-9'!,'?\-;[\]\\/]$/.test(val)
|
||||
export const isChineseSymbol = (val: string): boolean =>
|
||||
/[\u3002|\uff1f|\uff01|\uff0c|\u3001|\uff1b|\uff1a|\u201c|\u201d|\u2018|\u2019|\uff08|\uff09|\u300a|\u300b|\u3008|\u3009|\u3010|\u3011|\u300e|\u300f|\u300c|\u300d|\ufe43|\ufe44|\u3014|\u3015|\u2026|\u2014|\uff5e|\ufe4f|\uffe5]/.test(
|
||||
@@ -17,3 +19,21 @@ export const IsDesktop = () => {
|
||||
}
|
||||
return flag
|
||||
}
|
||||
|
||||
export function addHowlListener(howl: Howl, ...args: Parameters<Howl['on']>) {
|
||||
howl.on(...args)
|
||||
|
||||
return () => howl.off(...args)
|
||||
}
|
||||
|
||||
export function classNames(...classNames: Array<string | void | null>) {
|
||||
const finallyClassNames: string[] = []
|
||||
|
||||
for (const className of classNames) {
|
||||
if (className) {
|
||||
finallyClassNames.push(className.trim())
|
||||
}
|
||||
}
|
||||
|
||||
return finallyClassNames.join(' ')
|
||||
}
|
||||
|
||||
18
yarn.lock
18
yarn.lock
@@ -1784,6 +1784,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.8.tgz#49348387983075705fe8f4e02fb67f7daaec4934"
|
||||
integrity sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA==
|
||||
|
||||
"@types/howler@^2.2.3":
|
||||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/howler/-/howler-2.2.3.tgz#236ff8ba2da2ed25b0b0ab959ad2f15effc93dff"
|
||||
integrity sha512-rzIE56NmGh7t8LdZT/zuBMa9iZvwqVmB65gNhwNLtrGEIHdWkEDp1fVmoM/zctR4UJZFe0uP01AmPuPzOwhecA==
|
||||
|
||||
"@types/html-minifier-terser@^5.0.0":
|
||||
version "5.1.1"
|
||||
resolved "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz#3c9ee980f1a10d6021ae6632ca3e79ca2ec4fb50"
|
||||
@@ -5770,6 +5775,11 @@ howler@^2.1.3:
|
||||
resolved "https://registry.yarnpkg.com/howler/-/howler-2.2.1.tgz#a521a9b495841e8bb9aa12e651bebba0affc179e"
|
||||
integrity sha512-0iIXvuBO/81CcrQ/HSSweYmbT50fT2mIc9XMFb+kxIfk2pW/iKzDbX1n3fZmDXMEIpYvyyfrB+gXwPYSDqUxIQ==
|
||||
|
||||
howler@^2.2.3:
|
||||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/howler/-/howler-2.2.3.tgz#a2eff9b08b586798e7a2ee17a602a90df28715da"
|
||||
integrity sha512-QM0FFkw0LRX1PR8pNzJVAY25JhIWvbKMBFM4gqk+QdV+kPXOhleWGCB6AiAF/goGjIHK2e/nIElplvjQwhr0jg==
|
||||
|
||||
hpack.js@^2.1.6:
|
||||
version "2.1.6"
|
||||
resolved "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2"
|
||||
@@ -11640,10 +11650,10 @@ url@^0.11.0:
|
||||
punycode "1.3.2"
|
||||
querystring "0.2.0"
|
||||
|
||||
use-sound@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/use-sound/-/use-sound-2.0.1.tgz#72e70d4bfdda71a6959e5adab0c7e10ff4080a0a"
|
||||
integrity sha512-wNwnyIOe8QPDQgWRZd3PqVEJ9BpLO+VJX+EpQSb6EvvjblHhnYMD7WIOTQ4hgloC2jC7Az6FhclHilC441De7A==
|
||||
use-sound@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/use-sound/-/use-sound-4.0.1.tgz#8c8de4badd16902db7762fd9418a4283b9bf7b3e"
|
||||
integrity sha512-hykJ86kNcu6y/FzlSHcQxhjSGMslZx2WlfLpZNoPbvueakv4OF3xPxEtGV2YmculrIaH0tPp9LtG4jgy17xMWg==
|
||||
dependencies:
|
||||
howler "^2.1.3"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user