feat: support practice input modes
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user