Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,6 @@ module.exports = {
rules: {
'sort-imports': ['error', { ignoreDeclarationSort: true }],
'@typescript-eslint/consistent-type-imports': 1,
'react/prop-types': 'off',
'react/prop-types': [2, { ignore: ['className', 'position'] }],
},
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@radix-ui/react-progress": "^1.0.2",
"@radix-ui/react-radio-group": "^1.1.2",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slider": "^1.1.1",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
Expand Down Expand Up @@ -48,6 +49,7 @@
"react-router-dom": "^6.8.2",
"react-timer-hook": "^3.0.5",
"react-tooltip": "^5.18.0",
"shadcn": "^2.7.0",
"source-map-explorer": "^2.5.2",
"swr": "^2.0.4",
"tailwind-merge": "^2.1.0",
Expand Down
5 changes: 5 additions & 0 deletions src/@types/game.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type TypingGameConfig = {
level: '😁' | '🤨' | '😤' | '🤬'
life: '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
lifeLeft: '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
}
133 changes: 133 additions & 0 deletions src/components/ui/select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { cn } from '@/utils/ui'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import * as React from 'react'

const Select = SelectPrimitive.Root

const SelectGroup = SelectPrimitive.Group

const SelectValue = SelectPrimitive.Value

const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:ring-offset-slate-950 dark:focus:ring-slate-300 dark:data-[placeholder]:text-slate-400 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName

const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton ref={ref} className={cn('flex cursor-default items-center justify-center py-1', className)} {...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName

const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton ref={ref} className={cn('flex cursor-default items-center justify-center py-1', className)} {...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName

const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] origin-[--radix-select-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border border-slate-200 bg-white text-slate-950 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' && 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName

const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)} {...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName

const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-slate-100 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-slate-800 dark:focus:text-slate-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>

<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName

const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-slate-100 dark:bg-slate-800', className)} {...props} />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName

export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
4 changes: 4 additions & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ export const defaultFontSizeConfig = {
foreignFont: 48,
translateFont: 18,
}

// constants for Typing Game
export const LIFERANGE = new Array(10).fill(0).map((_, index) => index.toString())
export const LEVELS = ['😁', '🤨', '😤', '🤬']
2 changes: 2 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ErrorBook } from './pages/ErrorBook'
import { FriendLinks } from './pages/FriendLinks'
import MobilePage from './pages/Mobile'
import TypingPage from './pages/Typing'
import TypingGame from './pages/TypingGame'
import { isOpenDarkModeAtom } from '@/store'
import { Analytics } from '@vercel/analytics/react'
import 'animate.css'
Expand Down Expand Up @@ -61,6 +62,7 @@ function Root() {
<Route path="/analysis" element={<AnalysisPage />} />
<Route path="/error-book" element={<ErrorBook />} />
<Route path="/friend-links" element={<FriendLinks />} />
<Route path="/typing-game" element={<TypingGame />} />
<Route path="/*" element={<Navigate to="/" />} />
</>
)}
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Gallery-N/DictDetail/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export default function DictDetail({ dictionary: dict }: { dictionary: Dictionar
setCurrentDictId(dict.id)
setCurrentChapter(index)
setReviewModeInfo((old) => ({ ...old, isReviewMode: false }))
navigate('/')
navigate(-1)
},
[dict.id, navigate, setCurrentChapter, setCurrentDictId, setReviewModeInfo],
)
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Gallery-N/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export default function GalleryPage() {
}, [galleryState.currentLanguageTab])

const onBack = useCallback(() => {
navigate('/')
navigate(-1)
}, [navigate])

useHotkeys('enter,esc', onBack, { preventDefault: true })
Expand Down
4 changes: 4 additions & 0 deletions src/pages/Typing/components/Switcher/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import HandPositionIllustration from '../HandPositionIllustration'
import LoopWordSwitcher from '../LoopWordSwitcher'
import Setting from '../Setting'
import SoundSwitcher from '../SoundSwitcher'
import TypingGameButton from '../TypingGameButton'
import WordDictationSwitcher from '../WordDictationSwitcher'
import Tooltip from '@/components/Tooltip'
import { isOpenDarkModeAtom } from '@/store'
Expand Down Expand Up @@ -91,6 +92,9 @@ export default function Switcher() {
<Tooltip className="h-7 w-7" content="指法图示">
<HandPositionIllustration></HandPositionIllustration>
</Tooltip>
<Tooltip className="h-7 w-7" content="打字游戏">
<TypingGameButton />
</Tooltip>
<Tooltip content="设置">
<Setting />
</Tooltip>
Expand Down
26 changes: 26 additions & 0 deletions src/pages/Typing/components/TypingGameButton/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { recordAnalysisAction } from '@/utils'
import { useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import GameIcon from '~icons/material-symbols/stadia-controller'

const TypingGameButton = () => {
const navigate = useNavigate()

const toTypingGame = useCallback(() => {
navigate('/typing-game')
recordAnalysisAction('open')
}, [navigate])

return (
<button
type="button"
onClick={toTypingGame}
className={`flex items-center justify-center rounded p-[2px] text-lg text-indigo-500 outline-none transition-colors duration-300 ease-in-out hover:bg-indigo-400 hover:text-white`}
title="开始打字游戏"
>
<GameIcon className="icon" />
</button>
)
}

export default TypingGameButton
88 changes: 88 additions & 0 deletions src/pages/TypingGame/components/GamePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import useGameWordList from '../hooks/useGameWordList'
import { TypingGameContext } from '../index'
import type { TypingGameLevel } from '../utils/typingGame'
import TypingGame from '../utils/typingGame'
import { LEVELS } from '@/constants'
import { isOpenDarkModeAtom, typingGameConfigAtom } from '@/store'
import { useAtom, useAtomValue } from 'jotai'
import { useContext, useEffect, useRef } from 'react'
import type React from 'react'

function getRandomWords(gameWords: string[]) {
const maxStart = Math.max(0, gameWords.length - 50)
const index = Math.floor(Math.random() * (maxStart + 1))
return gameWords.slice(index, index + 50)
}

const GamePanel: React.FC = () => {
const wordPanel = useRef(null)
const { gameWords } = useGameWordList()
const [{ life, level }, setGameConfig] = useAtom(typingGameConfigAtom)
const isOpenDarkMode = useAtomValue(isOpenDarkModeAtom)
const typingGameRef = useRef<TypingGame | null>(null)
const { state, setState, targetWord } = useContext(TypingGameContext)

function getFontStyle(isOpenDarkMode: boolean) {
return isOpenDarkMode
? {
font: '48px Menlo',
fillStyle: '#f9fafb',
}
: {
font: '48px Menlo',
fillStyle: '#4b5563',
}
}

useEffect(() => {
switch (state) {
case 'running':
typingGameRef.current?.resume()
break
case 'paused':
typingGameRef.current?.pause()
break
case 'init':
typingGameRef.current?.init()
break
}
}, [state, setState])

useEffect(() => {
if (!typingGameRef.current) {
typingGameRef.current = new TypingGame(
wordPanel.current,
(LEVELS.indexOf(level) + 1) as TypingGameLevel,
parseInt(life),
getRandomWords(gameWords),
setState,
setGameConfig,
getFontStyle(isOpenDarkMode),
)
}
}, [level, life, gameWords, setGameConfig, isOpenDarkMode, setState])

useEffect(() => {
typingGameRef.current?.changeGameSetting({
life: parseInt(life),
level: (LEVELS.indexOf(level) + 1) as TypingGameLevel,
words: getRandomWords(gameWords),
})
}, [life, level, gameWords])

useEffect(() => {
typingGameRef.current?.changeFontSetting(getFontStyle(isOpenDarkMode))
}, [isOpenDarkMode])

useEffect(() => {
typingGameRef.current?.wipeOut(targetWord)
}, [targetWord])

return (
<div className="flex h-full w-full flex-col items-start">
<canvas id="wordPanel" ref={wordPanel} className="h-full w-full"></canvas>
</div>
)
}

export default GamePanel
Loading