feat: support practice input modes

This commit is contained in:
ittoview
2026-05-10 15:36:24 +01:00
parent ac55300e69
commit a1b8f3064d

View File

@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { processPurposePracticeItems } from '@/data/process-purpose-practice'
import { InputArea } from '@/components/practice/InputArea'
import { ProficientInputArea } from '@/components/practice/ProficientInputArea'
import { useAppStore } from '@/stores/useAppStore'
import { normalizeAnswer, announceToScreenReader } from '@/utils/practice'
type CharStatus = 'pending' | 'correct' | 'error'
@@ -85,9 +87,12 @@ export default function ProcessPurposePracticePage() {
)
const [showAnswer, setShowAnswer] = useState(false)
const [correctFeedback, setCorrectFeedback] = useState(false)
const [proficientInput, setProficientInput] = useState('')
const [proficientHasError, setProficientHasError] = useState(false)
const isComposingRef = useRef(false)
const latestInputRef = useRef<string[]>([])
const answerTimerRef = useRef<number | null>(null)
const { practiceMode } = useAppStore()
const itemMap = useMemo(
() => new Map(processPurposePracticeItems.map((item) => [item.id, item])),
@@ -124,13 +129,17 @@ export default function ProcessPurposePracticePage() {
if (answerTimerRef.current) {
window.clearTimeout(answerTimerRef.current)
}
if (practiceMode === 'proficient') {
setProficientInput('')
setProficientHasError(false)
}
setShowAnswer(true)
setInputLocked(true)
announceToScreenReader('答案已显示')
answerTimerRef.current = window.setTimeout(() => {
hideAnswer()
}, ANSWER_VISIBLE_DURATION)
}, [currentItem, hideAnswer, isFinished])
}, [currentItem, hideAnswer, isFinished, practiceMode])
useEffect(() => {
if (!currentItem) return
@@ -146,6 +155,8 @@ export default function ProcessPurposePracticePage() {
setShowAnswer(false)
setCorrectFeedback(false)
setInputLocked(false)
setProficientInput('')
setProficientHasError(false)
}, [currentItem?.id])
useEffect(() => {
@@ -172,8 +183,13 @@ export default function ProcessPurposePracticePage() {
e.preventDefault()
if (!showAnswer) showCurrentAnswer()
} else if (e.key === 'Escape' && !inputLocked) {
setUserInput(new Array(userInput.length).fill(''))
setCharStatuses(new Array(userInput.length).fill('pending'))
if (practiceMode === 'proficient') {
setProficientInput('')
setProficientHasError(false)
} else {
setUserInput(new Array(userInput.length).fill(''))
setCharStatuses(new Array(userInput.length).fill('pending'))
}
}
}
@@ -189,7 +205,7 @@ export default function ProcessPurposePracticePage() {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [hideAnswer, inputLocked, showAnswer, showCurrentAnswer, userInput.length])
}, [hideAnswer, inputLocked, practiceMode, showAnswer, showCurrentAnswer, userInput.length])
useEffect(() => {
return () => {
@@ -266,15 +282,64 @@ export default function ProcessPurposePracticePage() {
[validateInput]
)
const handleCompositionStart = useCallback((_index: number) => {
const handleCompositionStart = useCallback((_index?: number) => {
isComposingRef.current = true
setIsComposing(true)
}, [])
const submitProficientAnswer = useCallback(
(rawValue: string) => {
if (!currentItem || inputLocked || isFinished) return
const normalizedValue = normalizeAnswer(rawValue)
if (!normalizedValue) return
if (normalizedValue !== normalizeAnswer(currentItem.name)) {
setProficientHasError(true)
return
}
setCorrectFeedback(true)
setInputLocked(true)
setProficientInput('')
setProficientHasError(false)
setProgress((prev) => ({
...prev,
completedIds: prev.completedIds.includes(currentItem.id)
? prev.completedIds
: [...prev.completedIds, currentItem.id],
currentInput: [],
}))
announceToScreenReader('回答正确')
window.setTimeout(() => {
const isLastQuestion = progress.currentIndex >= progress.order.length - 1
if (isLastQuestion) {
setInputLocked(false)
setCorrectFeedback(false)
} else {
moveToNextQuestion()
}
}, NEXT_QUESTION_DELAY)
},
[currentItem, inputLocked, isFinished, moveToNextQuestion, progress.currentIndex, progress.order.length]
)
const handleCompositionEnd = useCallback(
(index: number, value: string) => {
(indexOrValue: number | string, maybeValue?: string) => {
isComposingRef.current = false
setIsComposing(false)
if (practiceMode === 'proficient') {
const nextValue =
typeof indexOrValue === 'string' ? indexOrValue : maybeValue ?? ''
setProficientInput(nextValue)
setProficientHasError(false)
return
}
const index = indexOrValue as number
const value = maybeValue ?? ''
requestAnimationFrame(() => {
const newInput = [...latestInputRef.current]
const chars = value.split('')
@@ -288,7 +353,7 @@ export default function ProcessPurposePracticePage() {
handleInputChange(newInput)
})
},
[handleInputChange]
[handleInputChange, practiceMode]
)
const handlePaste = useCallback(
@@ -308,6 +373,31 @@ export default function ProcessPurposePracticePage() {
[currentItem, handleInputChange, inputLocked]
)
useEffect(() => {
if (practiceMode !== 'proficient' || isComposing || inputLocked || isFinished) return
if (!normalizeAnswer(proficientInput)) return
const timer = window.setTimeout(() => {
submitProficientAnswer(proficientInput)
}, 800)
return () => window.clearTimeout(timer)
}, [
isComposing,
inputLocked,
isFinished,
practiceMode,
proficientInput,
submitProficientAnswer,
])
useEffect(() => {
setProficientInput('')
setProficientHasError(false)
setShowAnswer(false)
setInputLocked(false)
}, [practiceMode])
const handleRestart = useCallback(() => {
const fresh = createFreshProgress()
localStorage.setItem(STORAGE_KEY, JSON.stringify(fresh))
@@ -315,6 +405,8 @@ export default function ProcessPurposePracticePage() {
setShowAnswer(false)
setCorrectFeedback(false)
setInputLocked(false)
setProficientInput('')
setProficientHasError(false)
focusFirstEmptyInput()
}, [focusFirstEmptyInput])
@@ -396,17 +488,32 @@ export default function ProcessPurposePracticePage() {
<div className="mx-auto max-w-5xl px-6">
<div className="border-b border-gray-200/50 py-3 dark:border-gray-700/50">
<div className="flex items-center justify-center gap-3">
<InputArea
userInput={userInput}
charStatuses={charStatuses}
isComposing={isComposing}
inputLocked={inputLocked}
lastErrorTimestamp={lastErrorTimestamp}
onInputChange={handleInputChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onPaste={handlePaste}
/>
{practiceMode === 'proficient' ? (
<ProficientInputArea
value={proficientInput}
isComposing={isComposing}
inputLocked={inputLocked}
hasError={proficientHasError}
onChange={(value) => {
setProficientInput(value)
setProficientHasError(false)
}}
onCompositionStart={() => handleCompositionStart()}
onCompositionEnd={(value) => handleCompositionEnd(value)}
/>
) : (
<InputArea
userInput={userInput}
charStatuses={charStatuses}
isComposing={isComposing}
inputLocked={inputLocked}
lastErrorTimestamp={lastErrorTimestamp}
onInputChange={handleInputChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={(index, value) => handleCompositionEnd(index, value)}
onPaste={handlePaste}
/>
)}
<button
onClick={showCurrentAnswer}