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