mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2025-12-25 02:14:11 -05:00
feat(client): add pinyin-to-hanzi input to fill in the blank challenge (#63986)
This commit is contained in:
@@ -963,7 +963,8 @@
|
||||
"editor-a11y-on-non-macos": "{{editorName}} editor content. Accessibility mode set to 'on'. Press Ctrl+E to disable or press Alt+F1 for more options.",
|
||||
"terminal-output": "Terminal output",
|
||||
"not-available": "Not available",
|
||||
"interactive-editor-desc": "Turn static code examples into interactive editors. This allows you to edit and run the code directly on the page."
|
||||
"interactive-editor-desc": "Turn static code examples into interactive editors. This allows you to edit and run the code directly on the page.",
|
||||
"pinyin-to-hanzi-input-desc": "This task uses Pinyin-to-Hanzi inputs. Type pinyin with tone numbers (1 to 5). When you enter a correct syllable, it will turn into a Chinese character. If you press backspace after a Chinese character, it will change back to pinyin and remove the last thing you typed: if it's a tone number, the tone is removed; if it's a letter, the letter is removed."
|
||||
},
|
||||
"flash": {
|
||||
"no-email-in-userinfo": "We could not retrieve an email from your chosen provider. Please try another provider or use the 'Continue with Email' option.",
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"nanoid": "3.3.7",
|
||||
"normalize-url": "6.1.0",
|
||||
"path-browserify": "1.0.1",
|
||||
"pinyin-tone": "2.4.0",
|
||||
"postcss": "8.4.35",
|
||||
"prismjs": "1.29.0",
|
||||
"process": "0.11.10",
|
||||
@@ -143,6 +144,7 @@
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@total-typescript/ts-reset": "^0.5.0",
|
||||
"@types/canvas-confetti": "^1.6.0",
|
||||
"@types/gatsbyjs__reach-router": "1.3.0",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { HandlerProps } from 'react-reflex';
|
||||
import { SuperBlocks } from '../../../shared-dist/config/curriculum';
|
||||
import {
|
||||
ChallengeLang,
|
||||
SuperBlocks
|
||||
} from '../../../shared-dist/config/curriculum';
|
||||
import type { Chapter } from '../../../shared-dist/config/chapters';
|
||||
import { BlockLayouts, BlockLabel } from '../../../shared-dist/config/blocks';
|
||||
import type { ChallengeFile, Ext } from '../../../shared-dist/utils/polyvinyl';
|
||||
@@ -222,6 +225,7 @@ export type ChallengeNode = {
|
||||
helpCategory: string;
|
||||
hooks?: Hooks;
|
||||
id: string;
|
||||
lang?: ChallengeLang;
|
||||
instructions: string;
|
||||
internal?: {
|
||||
content: string;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { parseBlanks, parseAnswer } from '../fill-in-the-blank/parse-blanks';
|
||||
import PrismFormatted from '../components/prism-formatted';
|
||||
import { FillInTheBlank } from '../../../redux/prop-types';
|
||||
import ChallengeHeading from './challenge-heading';
|
||||
import PinyinToHanziInput from './pinyin-to-hanzi-input';
|
||||
|
||||
type FillInTheBlankProps = {
|
||||
fillInTheBlank: FillInTheBlank;
|
||||
@@ -33,8 +34,63 @@ const AnswerText = ({ answer }: { answer: string }) => {
|
||||
);
|
||||
};
|
||||
|
||||
type BlankInputProps = {
|
||||
blankIndex: number;
|
||||
answer: string;
|
||||
isCorrect: boolean | null;
|
||||
className: string;
|
||||
onChange: (index: number, value: string) => void;
|
||||
ariaLabel: string;
|
||||
inputType?: 'pinyin-to-hanzi' | 'pinyin-tone';
|
||||
};
|
||||
|
||||
const BlankInput = ({
|
||||
blankIndex,
|
||||
answer,
|
||||
isCorrect,
|
||||
className,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
inputType
|
||||
}: BlankInputProps) => {
|
||||
const parsedAnswer = parseAnswer(answer);
|
||||
const answerLength =
|
||||
typeof parsedAnswer === 'string'
|
||||
? parsedAnswer.length
|
||||
: parsedAnswer.pinyin.length;
|
||||
|
||||
if (inputType === 'pinyin-to-hanzi' && typeof parsedAnswer === 'object') {
|
||||
return (
|
||||
<PinyinToHanziInput
|
||||
index={blankIndex}
|
||||
expectedAnswer={parsedAnswer}
|
||||
isCorrect={isCorrect}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
maxLength={answerLength + 3}
|
||||
size={answerLength}
|
||||
ariaLabel={ariaLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default text input
|
||||
return (
|
||||
<input
|
||||
type='text'
|
||||
maxLength={answerLength + 3}
|
||||
className={className}
|
||||
onChange={e => onChange(blankIndex, e.target.value)}
|
||||
size={answerLength}
|
||||
autoComplete='off'
|
||||
aria-label={ariaLabel}
|
||||
{...(isCorrect === false ? { 'aria-invalid': 'true' } : {})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function FillInTheBlanks({
|
||||
fillInTheBlank: { sentence, blanks },
|
||||
fillInTheBlank: { sentence, blanks, inputType },
|
||||
answersCorrect,
|
||||
showFeedback,
|
||||
feedback,
|
||||
@@ -53,76 +109,61 @@ function FillInTheBlanks({
|
||||
return cls;
|
||||
};
|
||||
|
||||
const getAnswerLength = (answer: string): number => {
|
||||
const parsedAnswer = parseAnswer(answer);
|
||||
|
||||
if (typeof parsedAnswer === 'string') {
|
||||
return parsedAnswer.length;
|
||||
}
|
||||
|
||||
// TODO: This is a simplification. Revisit later to account for tones and spaces.
|
||||
return parsedAnswer.pinyin.length;
|
||||
};
|
||||
|
||||
const paragraphs = parseBlanks(sentence);
|
||||
const blankAnswers = blanks.map(b => b.answer);
|
||||
|
||||
const ariaInputDescription =
|
||||
inputType === 'pinyin-to-hanzi' ? t('aria.pinyin-to-hanzi-input-desc') : '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChallengeHeading heading={t('learn.fill-in-the-blank.heading')} />
|
||||
<Spacer size='xs' />
|
||||
<p className='sr-only'>{t(ariaInputDescription)}</p>
|
||||
<div className='fill-in-the-blank-wrap'>
|
||||
{paragraphs.map((p, i) => {
|
||||
return (
|
||||
// both keys, i and j, are stable between renders, since
|
||||
// the paragraphs are static.
|
||||
<p key={i}>
|
||||
{p.map((node, j) => {
|
||||
const { type, value } = node;
|
||||
if (type === 'text') {
|
||||
return value;
|
||||
}
|
||||
{paragraphs.map((p, i) => (
|
||||
// both keys, i and j, are stable between renders, since
|
||||
// the paragraphs are static.
|
||||
<p key={i}>
|
||||
{p.map((node, j) => {
|
||||
const { type, value } = node;
|
||||
|
||||
if (type === 'hanzi-pinyin') {
|
||||
const { hanzi, pinyin } = value;
|
||||
return (
|
||||
<ruby key={j}>
|
||||
{hanzi}
|
||||
<rp>(</rp>
|
||||
<rt>{pinyin}</rt>
|
||||
<rp>)</rp>
|
||||
</ruby>
|
||||
);
|
||||
}
|
||||
|
||||
// If a blank is answered correctly, render the answer as part of the sentence.
|
||||
if (type === 'blank' && answersCorrect[value] === true) {
|
||||
return <AnswerText key={j} answer={blankAnswers[value]} />;
|
||||
}
|
||||
|
||||
const answerLength = getAnswerLength(blankAnswers[value]);
|
||||
if (type === 'text') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (type === 'hanzi-pinyin') {
|
||||
const { hanzi, pinyin } = value;
|
||||
return (
|
||||
<input
|
||||
key={j}
|
||||
type='text'
|
||||
maxLength={answerLength + 3}
|
||||
className={getInputClass(value)}
|
||||
onChange={e =>
|
||||
handleInputChange(node.value, e.target.value)
|
||||
}
|
||||
size={answerLength}
|
||||
autoComplete='off'
|
||||
aria-label={t('learn.fill-in-the-blank.blank')}
|
||||
{...(answersCorrect[value] === false
|
||||
? { 'aria-invalid': 'true' }
|
||||
: {})}
|
||||
/>
|
||||
<ruby key={j}>
|
||||
{hanzi}
|
||||
<rp>(</rp>
|
||||
<rt>{pinyin}</rt>
|
||||
<rp>)</rp>
|
||||
</ruby>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
}
|
||||
|
||||
// If a blank is answered correctly, render the answer as part of the sentence.
|
||||
if (answersCorrect[value] === true) {
|
||||
return <AnswerText key={j} answer={blankAnswers[value]} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BlankInput
|
||||
key={j}
|
||||
blankIndex={value}
|
||||
answer={blankAnswers[value]}
|
||||
isCorrect={answersCorrect[value]}
|
||||
className={getInputClass(value)}
|
||||
onChange={handleInputChange}
|
||||
ariaLabel={t('learn.fill-in-the-blank.blank')}
|
||||
inputType={inputType}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<Spacer size='m' />
|
||||
<div aria-live='polite'>
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import React from 'react';
|
||||
import { describe, test, expect, vi } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import PinyinToHanziInput, { convertToHanzi } from './pinyin-to-hanzi-input';
|
||||
|
||||
describe('convertToHanzi', () => {
|
||||
test('should convert when tone number appears after final letter', () => {
|
||||
// Only the correct tone gets converted to hanzi
|
||||
expect(convertToHanzi('shen2', { hanzi: '什', pinyin: 'shén' })).toBe('什');
|
||||
|
||||
// Incorrect tones stay as pinyin
|
||||
expect(convertToHanzi('shen1', { hanzi: '什', pinyin: 'shén' })).toBe(
|
||||
'shēn'
|
||||
);
|
||||
expect(convertToHanzi('shen3', { hanzi: '什', pinyin: 'shén' })).toBe(
|
||||
'shěn'
|
||||
);
|
||||
expect(convertToHanzi('shen4', { hanzi: '什', pinyin: 'shén' })).toBe(
|
||||
'shèn'
|
||||
);
|
||||
expect(convertToHanzi('shen5', { hanzi: '什', pinyin: 'shén' })).toBe(
|
||||
'shen'
|
||||
);
|
||||
});
|
||||
|
||||
test('should convert when tone number appears before final letter', () => {
|
||||
// Only the correct tone gets converted to hanzi
|
||||
expect(convertToHanzi('she2n', { hanzi: '什', pinyin: 'shén' })).toBe('什');
|
||||
|
||||
// Incorrect tones stay as pinyin
|
||||
expect(convertToHanzi('she1n', { hanzi: '什', pinyin: 'shén' })).toBe(
|
||||
'shēn'
|
||||
);
|
||||
expect(convertToHanzi('she3n', { hanzi: '什', pinyin: 'shén' })).toBe(
|
||||
'shěn'
|
||||
);
|
||||
expect(convertToHanzi('she4n', { hanzi: '什', pinyin: 'shén' })).toBe(
|
||||
'shèn'
|
||||
);
|
||||
expect(convertToHanzi('she5n', { hanzi: '什', pinyin: 'shén' })).toBe(
|
||||
'shen'
|
||||
);
|
||||
});
|
||||
|
||||
test('should convert both correct syllables to hanzi', () => {
|
||||
expect(convertToHanzi('ni3hao3', { hanzi: '你好', pinyin: 'nǐ hǎo' })).toBe(
|
||||
'你好'
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle multiple syllables with space', () => {
|
||||
expect(
|
||||
convertToHanzi('ni3 hao3', { hanzi: '你好', pinyin: 'nǐ hǎo' })
|
||||
).toBe('你 好');
|
||||
});
|
||||
|
||||
test('should allow extra syllables and render them as pinyin', () => {
|
||||
expect(
|
||||
convertToHanzi('ni3hao3ma3', { hanzi: '你好', pinyin: 'nǐ hǎo' })
|
||||
).toBe('你好mǎ');
|
||||
});
|
||||
|
||||
test('should show toned pinyin for wrong syllable and convert correct one', () => {
|
||||
expect(convertToHanzi('ni4hao3', { hanzi: '你好', pinyin: 'nǐ hǎo' })).toBe(
|
||||
'nì好'
|
||||
);
|
||||
|
||||
expect(convertToHanzi('ni3hao4', { hanzi: '你好', pinyin: 'nǐ hǎo' })).toBe(
|
||||
'你hào'
|
||||
);
|
||||
});
|
||||
|
||||
test('should only convert when input has tone 5', () => {
|
||||
expect(
|
||||
convertToHanzi('shen2me', { hanzi: '什么', pinyin: 'shén me' })
|
||||
).toBe('什me');
|
||||
|
||||
expect(
|
||||
convertToHanzi('shen2me5', { hanzi: '什么', pinyin: 'shén me' })
|
||||
).toBe('什么');
|
||||
});
|
||||
|
||||
test('should convert long phrase properly', () => {
|
||||
const longPhrase = {
|
||||
hanzi: '请问你叫什么名字',
|
||||
pinyin: 'qǐng wèn nǐ jiào shén me míng zi'
|
||||
};
|
||||
expect(
|
||||
convertToHanzi('qing3 wen4 ni3 jiao4 shen2 me5 ming2 zi5', longPhrase)
|
||||
).toBe('请 问 你 叫 什 么 名 字');
|
||||
});
|
||||
|
||||
test('should handle uppercase input case-insensitively', () => {
|
||||
expect(convertToHanzi('NI3HAO3', { hanzi: '你好', pinyin: 'nǐ hǎo' })).toBe(
|
||||
'你好'
|
||||
);
|
||||
expect(convertToHanzi('Ni3hAO3', { hanzi: '你好', pinyin: 'nǐ hǎo' })).toBe(
|
||||
'你好'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PinyinToHanziInput component', () => {
|
||||
test.each([
|
||||
[null, false],
|
||||
[true, false],
|
||||
[false, true]
|
||||
])(
|
||||
'should have aria-invalid="%s" when isCorrect is %s',
|
||||
(isCorrect, expectedAriaInvalid) => {
|
||||
const expectedAnswer = { hanzi: '你好', pinyin: 'nǐ hǎo' };
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
render(
|
||||
<PinyinToHanziInput
|
||||
index={0}
|
||||
expectedAnswer={expectedAnswer}
|
||||
isCorrect={isCorrect}
|
||||
onChange={mockOnChange}
|
||||
maxLength={100}
|
||||
size={20}
|
||||
ariaLabel='blank'
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText<HTMLInputElement>('blank');
|
||||
|
||||
if (expectedAriaInvalid) {
|
||||
expect(input).toHaveAttribute('aria-invalid', 'true');
|
||||
} else {
|
||||
expect(input).not.toHaveAttribute('aria-invalid');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test('should convert when tone number appears before final letter (she2n me5)', async () => {
|
||||
const expectedAnswer = { hanzi: '什么', pinyin: 'shén me' };
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
render(
|
||||
<PinyinToHanziInput
|
||||
index={0}
|
||||
expectedAnswer={expectedAnswer}
|
||||
isCorrect={null}
|
||||
onChange={mockOnChange}
|
||||
maxLength={100}
|
||||
size={20}
|
||||
ariaLabel='blank'
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText<HTMLInputElement>('blank');
|
||||
|
||||
await userEvent.type(input, 'she2nme');
|
||||
expect(input.value).toBe('什me');
|
||||
|
||||
// Type the final tone digit to complete the pinyin
|
||||
await userEvent.type(input, '5');
|
||||
expect(input.value).toBe('什么');
|
||||
});
|
||||
|
||||
test('should convert when tone number appears after final letter (shen2 me)', async () => {
|
||||
const expectedAnswer = { hanzi: '什么', pinyin: 'shén me' };
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
render(
|
||||
<PinyinToHanziInput
|
||||
index={0}
|
||||
expectedAnswer={expectedAnswer}
|
||||
isCorrect={null}
|
||||
onChange={mockOnChange}
|
||||
maxLength={100}
|
||||
size={20}
|
||||
ariaLabel='blank'
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText<HTMLInputElement>('blank');
|
||||
|
||||
await userEvent.type(input, 'shen2me');
|
||||
expect(input.value).toBe('什me');
|
||||
|
||||
// Type the final tone digit to complete the pinyin
|
||||
await userEvent.type(input, '5');
|
||||
expect(input.value).toBe('什么');
|
||||
});
|
||||
|
||||
test('should revert hanzi back to toned pinyin and remove tone when backspacing', async () => {
|
||||
const expectedAnswer = { hanzi: '什么', pinyin: 'shén me' };
|
||||
const mockOnChange = vi.fn();
|
||||
const expectedMap: Record<string, string> = {
|
||||
she2nme: '什me',
|
||||
she2nm: '什m',
|
||||
she2n: '什',
|
||||
she2: 'shé',
|
||||
she: 'she'
|
||||
};
|
||||
|
||||
render(
|
||||
<PinyinToHanziInput
|
||||
index={0}
|
||||
expectedAnswer={expectedAnswer}
|
||||
isCorrect={null}
|
||||
onChange={mockOnChange}
|
||||
maxLength={100}
|
||||
size={20}
|
||||
ariaLabel='blank'
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText<HTMLInputElement>('blank');
|
||||
|
||||
await userEvent.type(input, 'she2nme5');
|
||||
expect(input.value).toBe('什么');
|
||||
|
||||
const rawSteps = ['she2nme', 'she2nm', 'she2n', 'she2', 'she'];
|
||||
for (const step of rawSteps) {
|
||||
await userEvent.type(input, '{Backspace}');
|
||||
expect(input.value).toBe(expectedMap[step]);
|
||||
}
|
||||
});
|
||||
|
||||
test('should clear the input when selecting all and pressing backspace', async () => {
|
||||
const expectedAnswer = { hanzi: '你好', pinyin: 'nǐ hǎo' };
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
render(
|
||||
<PinyinToHanziInput
|
||||
index={0}
|
||||
expectedAnswer={expectedAnswer}
|
||||
isCorrect={null}
|
||||
onChange={mockOnChange}
|
||||
maxLength={100}
|
||||
size={20}
|
||||
ariaLabel='blank'
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText<HTMLInputElement>('blank');
|
||||
|
||||
await userEvent.type(input, 'ni3hao3');
|
||||
expect(input.value).toBe('你好');
|
||||
|
||||
await userEvent.clear(input);
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
test('should revert a single hanzi character to partial pinyin when backspacing', async () => {
|
||||
const expectedAnswer = { hanzi: '你', pinyin: 'nǐ' };
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
render(
|
||||
<PinyinToHanziInput
|
||||
index={0}
|
||||
expectedAnswer={expectedAnswer}
|
||||
isCorrect={null}
|
||||
onChange={mockOnChange}
|
||||
maxLength={100}
|
||||
size={20}
|
||||
ariaLabel='blank'
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText<HTMLInputElement>('blank');
|
||||
|
||||
await userEvent.type(input, 'ni3');
|
||||
expect(input.value).toBe('你');
|
||||
|
||||
// Backspace to revert the character to partial pinyin
|
||||
await userEvent.type(input, '{Backspace}');
|
||||
expect(input.value).toBe('ni');
|
||||
});
|
||||
|
||||
test('should allow changing the tone digit for a syllable (shen3 -> shěn -> shèn)', async () => {
|
||||
const expectedAnswer = { hanzi: '什么', pinyin: 'shén me' };
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
render(
|
||||
<PinyinToHanziInput
|
||||
index={0}
|
||||
expectedAnswer={expectedAnswer}
|
||||
isCorrect={null}
|
||||
onChange={mockOnChange}
|
||||
maxLength={100}
|
||||
size={20}
|
||||
ariaLabel='blank'
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText<HTMLInputElement>('blank');
|
||||
|
||||
await userEvent.type(input, 'shen3');
|
||||
expect(input.value).toBe('shěn');
|
||||
|
||||
// Replace tone 3 with 4
|
||||
await userEvent.type(input, '4');
|
||||
expect(input.value).toBe('shèn');
|
||||
});
|
||||
|
||||
test('should allow extra syllables beyond expected answer', async () => {
|
||||
const expectedAnswer = { hanzi: '你好', pinyin: 'nǐ hǎo' };
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
render(
|
||||
<PinyinToHanziInput
|
||||
index={0}
|
||||
expectedAnswer={expectedAnswer}
|
||||
isCorrect={null}
|
||||
onChange={mockOnChange}
|
||||
maxLength={100}
|
||||
size={20}
|
||||
ariaLabel='blank'
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText<HTMLInputElement>('blank');
|
||||
|
||||
await userEvent.type(input, 'ni3hao3ma3');
|
||||
expect(input.value).toBe('你好mǎ');
|
||||
});
|
||||
|
||||
test('should allow inserting mid-string and preserve converted hanzi', async () => {
|
||||
const expectedAnswer = { hanzi: '你好', pinyin: 'nǐ hǎo' };
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
render(
|
||||
<PinyinToHanziInput
|
||||
index={0}
|
||||
expectedAnswer={expectedAnswer}
|
||||
isCorrect={null}
|
||||
onChange={mockOnChange}
|
||||
maxLength={100}
|
||||
size={20}
|
||||
ariaLabel='blank'
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText<HTMLInputElement>('blank');
|
||||
|
||||
await userEvent.type(input, 'ni3hao3');
|
||||
expect(input.value).toBe('你好');
|
||||
|
||||
// Simulate mid-string edit: insert 'x' between the characters
|
||||
await userEvent.type(input, 'x', {
|
||||
initialSelectionStart: 1,
|
||||
initialSelectionEnd: 1
|
||||
});
|
||||
|
||||
expect(input.value).toBe('你x好');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import React, { useState } from 'react';
|
||||
import { convertUnspacedPinyin } from 'pinyin-tone/v2';
|
||||
|
||||
// Removing tone marks from pinyin for base letter comparison.
|
||||
// Uses Unicode NFD to decompose accented characters, then removes combining marks
|
||||
const normalize = (s: string) =>
|
||||
s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
|
||||
|
||||
/**
|
||||
* Converts raw pinyin input (with tone numbers) to hanzi characters when matching expected answer.
|
||||
*
|
||||
* Key behaviors:
|
||||
* 1. When a complete syllable with tone matches expected pinyin -> convert to hanzi
|
||||
* 2. When base letters match but tone differs -> show toned pinyin (incorrect)
|
||||
* 3. When syllable is a prefix of expected -> wait for more letters (e.g., 'shé' is prefix of 'shén')
|
||||
* 4. Spaces are preserved in the output
|
||||
*/
|
||||
export function convertToHanzi(
|
||||
raw: string,
|
||||
expectedAnswer: { hanzi: string; pinyin: string }
|
||||
): string {
|
||||
if (!raw.trim()) return raw;
|
||||
|
||||
const correctPinyins = expectedAnswer.pinyin.toLowerCase().split(/\s+/);
|
||||
const correctHanzi = [...expectedAnswer.hanzi];
|
||||
|
||||
// The final string shown to the user.
|
||||
// Example: '你好' for correct input, 'nǐhǎo' for incorrect
|
||||
let displayOutput = '';
|
||||
|
||||
// Accumulates characters for the current pinyin syllable.
|
||||
// Example: 'ni' while typing 'nǐ'
|
||||
let currentPinyin = '';
|
||||
|
||||
// Index of the next expected pinyin syllable.
|
||||
// Example: 0 for first syllable, 1 for second
|
||||
let currentCorrectPinyinIndex = 0;
|
||||
|
||||
// Pinyin syllable waiting for more input to complete.
|
||||
// Example: 'shé' when expecting 'shén' and waiting for 'n'
|
||||
let pendingPinyin = '';
|
||||
|
||||
// Process each character in the raw input
|
||||
for (const character of raw) {
|
||||
// Handle spaces: flush current syllable to output and reset state
|
||||
if (character === ' ') {
|
||||
displayOutput += currentPinyin + ' ';
|
||||
currentPinyin = '';
|
||||
pendingPinyin = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add character to current syllable
|
||||
currentPinyin += character;
|
||||
|
||||
// When a tone digit is encountered, process the completed syllable
|
||||
if (/[1-5]/.test(character)) {
|
||||
// Normalize to lowercase for case-insensitive handling
|
||||
currentPinyin = currentPinyin.toLowerCase();
|
||||
|
||||
const diacriticPinyin = convertUnspacedPinyin(currentPinyin); // Add tone mark
|
||||
|
||||
// If all expected syllables have been processed and the user has typed more
|
||||
// syllables than the expected answer contains, append the additional pinyin
|
||||
// syllables as-is without attempting to convert them to hanzi.
|
||||
if (currentCorrectPinyinIndex >= correctPinyins.length) {
|
||||
displayOutput += diacriticPinyin;
|
||||
currentPinyin = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
const correctSyllable = correctPinyins[currentCorrectPinyinIndex];
|
||||
|
||||
// Check if the input matches the expected syllable exactly.
|
||||
// If so, convert to hanzi.
|
||||
if (diacriticPinyin.toLowerCase() === correctSyllable.toLowerCase()) {
|
||||
displayOutput += correctHanzi[currentCorrectPinyinIndex]; // Convert to hanzi
|
||||
currentCorrectPinyinIndex++;
|
||||
currentPinyin = '';
|
||||
pendingPinyin = '';
|
||||
}
|
||||
// Check if base letters match but tone differs.
|
||||
// If so, show incorrect toned pinyin.
|
||||
else if (normalize(diacriticPinyin) === normalize(correctSyllable)) {
|
||||
displayOutput += diacriticPinyin;
|
||||
currentCorrectPinyinIndex++;
|
||||
currentPinyin = '';
|
||||
pendingPinyin = '';
|
||||
}
|
||||
// Check if input is a prefix of expected (e.g., 'shé' for 'shén').
|
||||
// If so, show pinyin and wait for more input.
|
||||
else if (
|
||||
normalize(correctSyllable).startsWith(normalize(diacriticPinyin))
|
||||
) {
|
||||
displayOutput += diacriticPinyin;
|
||||
pendingPinyin = diacriticPinyin;
|
||||
currentPinyin = '';
|
||||
}
|
||||
// No match: show pinyin and move to next expected syllable
|
||||
else {
|
||||
displayOutput += diacriticPinyin;
|
||||
currentCorrectPinyinIndex++;
|
||||
currentPinyin = '';
|
||||
pendingPinyin = '';
|
||||
}
|
||||
}
|
||||
// Handle non-tone characters when there's pending pinyin.
|
||||
// Pending pinyin occurs when the user's input is a prefix of the expected syllable
|
||||
// (e.g., 'shé' for 'shén'). In this case, combine the pending pinyin with the new
|
||||
// non-tone characters and check if it now matches the expected syllable. If it does,
|
||||
// replace the pending pinyin in the output with the correct hanzi character.
|
||||
else if (
|
||||
pendingPinyin &&
|
||||
currentCorrectPinyinIndex < correctPinyins.length
|
||||
) {
|
||||
const combinedPinyin = pendingPinyin + currentPinyin;
|
||||
const correctPinyin = correctPinyins[currentCorrectPinyinIndex];
|
||||
|
||||
// Check if combined input now matches the expected syllable exactly.
|
||||
if (combinedPinyin.toLowerCase() === correctPinyin.toLowerCase()) {
|
||||
// Replace the pending pinyin at the end of displayOutput with the correct hanzi character
|
||||
const endIndex = displayOutput.length - pendingPinyin.length;
|
||||
displayOutput =
|
||||
displayOutput.slice(0, endIndex) +
|
||||
correctHanzi[currentCorrectPinyinIndex];
|
||||
currentCorrectPinyinIndex++;
|
||||
currentPinyin = '';
|
||||
pendingPinyin = '';
|
||||
}
|
||||
// Check if combined input matches the base letters but tone differs
|
||||
else if (normalize(combinedPinyin) === normalize(correctPinyin)) {
|
||||
// Replace the pending pinyin at the end of displayOutput with the combined pinyin
|
||||
const endIndex = displayOutput.length - pendingPinyin.length;
|
||||
displayOutput = displayOutput.slice(0, endIndex) + combinedPinyin;
|
||||
currentCorrectPinyinIndex++;
|
||||
currentPinyin = '';
|
||||
pendingPinyin = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append any unfinished syllable at the end
|
||||
return displayOutput + currentPinyin;
|
||||
}
|
||||
|
||||
interface PinyinToHanziInputProps {
|
||||
index: number;
|
||||
expectedAnswer: { hanzi: string; pinyin: string };
|
||||
isCorrect: boolean | null;
|
||||
onChange: (index: number, value: string) => void;
|
||||
className?: string;
|
||||
maxLength: number;
|
||||
size: number;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
function PinyinToHanziInput({
|
||||
index,
|
||||
expectedAnswer,
|
||||
isCorrect,
|
||||
onChange,
|
||||
className,
|
||||
maxLength,
|
||||
size,
|
||||
ariaLabel
|
||||
}: PinyinToHanziInputProps): JSX.Element {
|
||||
const [rawInput, setRawInput] = useState('');
|
||||
const [displayValue, setDisplayValue] = useState('');
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value;
|
||||
const prevLength = displayValue.length;
|
||||
const inputLength = inputValue.length;
|
||||
|
||||
const isAppendingAtEnd =
|
||||
inputLength > prevLength && inputValue.startsWith(displayValue);
|
||||
const isDeletingFromEnd =
|
||||
inputLength < prevLength && displayValue.startsWith(inputValue);
|
||||
|
||||
let newRawInput: string;
|
||||
|
||||
if (isAppendingAtEnd) {
|
||||
const added = inputValue.substring(prevLength);
|
||||
|
||||
// Handle tone digit replacement
|
||||
if (
|
||||
added.length === 1 &&
|
||||
/[1-5]/.test(added) &&
|
||||
/[1-5]$/.test(rawInput)
|
||||
) {
|
||||
newRawInput = rawInput.slice(0, -1) + added;
|
||||
} else {
|
||||
newRawInput = rawInput + added;
|
||||
}
|
||||
} else if (isDeletingFromEnd) {
|
||||
if (inputLength === 0) {
|
||||
// When clearing the entire input:
|
||||
// - If the previous display was a single character,
|
||||
// assume the user wants to remove the last character from raw input
|
||||
// (e.g., undo the tone digit that converted it, like 'ni3' -> 'ni').
|
||||
// - Otherwise, fully clear raw input to an empty string.
|
||||
newRawInput = prevLength === 1 ? rawInput.slice(0, -1) : '';
|
||||
} else {
|
||||
// Remove characters from raw input
|
||||
const charsToRemove = prevLength - inputLength;
|
||||
newRawInput = rawInput.slice(0, -charsToRemove);
|
||||
}
|
||||
} else {
|
||||
// Mid-string edit - update new raw input directly
|
||||
newRawInput = inputValue;
|
||||
}
|
||||
|
||||
setRawInput(newRawInput);
|
||||
const newDisplayValue = convertToHanzi(newRawInput, expectedAnswer);
|
||||
setDisplayValue(newDisplayValue);
|
||||
onChange(index, newDisplayValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
type='text'
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
className={className}
|
||||
maxLength={maxLength}
|
||||
size={size}
|
||||
autoComplete='off'
|
||||
aria-label={ariaLabel}
|
||||
aria-describedby={`pinyin-description-${index}`}
|
||||
{...(isCorrect === false ? { 'aria-invalid': 'true' } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
PinyinToHanziInput.displayName = 'PinyinToHanziInput';
|
||||
|
||||
export default PinyinToHanziInput;
|
||||
@@ -38,6 +38,7 @@ import { replaceAppleQuotes } from '../../../utils/replace-apple-quotes';
|
||||
import { parseHanziPinyinPairs } from './parse-blanks';
|
||||
|
||||
import './show.css';
|
||||
import { ChallengeLang } from '../../../../../shared-dist/config/curriculum';
|
||||
|
||||
// Redux Setup
|
||||
const mapStateToProps = createSelector(
|
||||
@@ -91,7 +92,8 @@ const ShowFillInTheBlank = ({
|
||||
fillInTheBlank,
|
||||
helpCategory,
|
||||
scene,
|
||||
tests
|
||||
tests,
|
||||
lang
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -133,7 +135,7 @@ const ShowFillInTheBlank = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmitNonChinese = () => {
|
||||
const blankAnswers = fillInTheBlank.blanks.map(b => b.answer);
|
||||
|
||||
const newAnswersCorrect = userAnswers.map((userAnswer, i) => {
|
||||
@@ -144,14 +146,47 @@ const ShowFillInTheBlank = ({
|
||||
userAnswer.trim()
|
||||
).toLowerCase();
|
||||
|
||||
const pairs = parseHanziPinyinPairs(answer);
|
||||
const hanziPinyin = pairs.length === 1 ? pairs[0] : null;
|
||||
return normalizedUserAnswer === answer.toLowerCase();
|
||||
});
|
||||
|
||||
if (hanziPinyin) {
|
||||
const { hanzi } = hanziPinyin;
|
||||
// TODO: Implement full hanzi-pinyin validation logic
|
||||
// https://github.com/freeCodeCamp/language-curricula/issues/18
|
||||
return normalizedUserAnswer === hanzi;
|
||||
setAnswersCorrect(newAnswersCorrect);
|
||||
const hasWrongAnswer = newAnswersCorrect.some(a => a === false);
|
||||
if (!hasWrongAnswer) {
|
||||
setShowFeedback(false);
|
||||
setFeedback(null);
|
||||
openCompletionModal();
|
||||
} else {
|
||||
const firstWrongIndex = newAnswersCorrect.findIndex(a => a === false);
|
||||
const feedback =
|
||||
firstWrongIndex >= 0
|
||||
? fillInTheBlank.blanks[firstWrongIndex].feedback
|
||||
: null;
|
||||
|
||||
setFeedback(feedback);
|
||||
setShowWrong(true);
|
||||
setShowFeedback(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitChinese = () => {
|
||||
const blankAnswers = fillInTheBlank.blanks.map(b => b.answer);
|
||||
|
||||
const newAnswersCorrect = userAnswers.map((userAnswer, i) => {
|
||||
if (!userAnswer) return false;
|
||||
|
||||
const answer = blankAnswers[i];
|
||||
const normalizedUserAnswer = userAnswer.trim().toLowerCase();
|
||||
|
||||
if (fillInTheBlank.inputType === 'pinyin-to-hanzi') {
|
||||
const pairs = parseHanziPinyinPairs(answer);
|
||||
if (pairs.length === 1) {
|
||||
const hanziPinyin = pairs[0];
|
||||
const { hanzi } = hanziPinyin;
|
||||
return (
|
||||
normalizedUserAnswer.replace(/\s+/g, '') ===
|
||||
hanzi.replace(/\s+/g, '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedUserAnswer === answer.toLowerCase();
|
||||
@@ -176,6 +211,14 @@ const ShowFillInTheBlank = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (lang === ChallengeLang.Chinese) {
|
||||
handleSubmitChinese();
|
||||
} else {
|
||||
handleSubmitNonChinese();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (inputIndex: number, value: string): void => {
|
||||
const newUserAnswers = [...userAnswers];
|
||||
newUserAnswers[inputIndex] = value;
|
||||
@@ -301,6 +344,7 @@ export const query = graphql`
|
||||
helpCategory
|
||||
superBlock
|
||||
block
|
||||
lang
|
||||
fields {
|
||||
slug
|
||||
}
|
||||
|
||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@@ -411,6 +411,9 @@ importers:
|
||||
path-browserify:
|
||||
specifier: 1.0.1
|
||||
version: 1.0.1
|
||||
pinyin-tone:
|
||||
specifier: 2.4.0
|
||||
version: 2.4.0
|
||||
postcss:
|
||||
specifier: 8.4.35
|
||||
version: 8.4.35
|
||||
@@ -553,6 +556,9 @@ importers:
|
||||
'@testing-library/react-hooks':
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1(@types/react@17.0.83)(react-dom@17.0.2(react@17.0.2))(react-test-renderer@17.0.2(react@17.0.2))(react@17.0.2)
|
||||
'@testing-library/user-event':
|
||||
specifier: 14.6.1
|
||||
version: 14.6.1(@testing-library/dom@10.4.0)
|
||||
'@total-typescript/ts-reset':
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.1
|
||||
@@ -4525,6 +4531,12 @@ packages:
|
||||
react: <18.0.0
|
||||
react-dom: <18.0.0
|
||||
|
||||
'@testing-library/user-event@14.6.1':
|
||||
resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
peerDependencies:
|
||||
'@testing-library/dom': '>=7.21.4'
|
||||
|
||||
'@tokenizer/token@0.3.0':
|
||||
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
|
||||
|
||||
@@ -10816,6 +10828,9 @@ packages:
|
||||
resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==}
|
||||
hasBin: true
|
||||
|
||||
pinyin-tone@2.4.0:
|
||||
resolution: {integrity: sha512-ATSA0WW81iOxTTePpY3FN2hXwh8OcDuO/xP5YwdkZLBGvZnkvhF1Nhbl03fS8CRtyvwvt1cBmzRnmHRR3p/7aw==}
|
||||
|
||||
pirates@4.0.6:
|
||||
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -12625,6 +12640,7 @@ packages:
|
||||
supertest@6.3.3:
|
||||
resolution: {integrity: sha512-EMCG6G8gDu5qEqRQ3JjjPs6+FYT1a7Hv5ApHvtSghmOFJYtsU5S+pSb6Y2EUeCEY3CmEL3mmQ8YWlPOzQomabA==}
|
||||
engines: {node: '>=6.4.0'}
|
||||
deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net
|
||||
|
||||
supports-color@5.5.0:
|
||||
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
|
||||
@@ -18952,6 +18968,10 @@ snapshots:
|
||||
react: 17.0.2
|
||||
react-dom: 17.0.2(react@17.0.2)
|
||||
|
||||
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.0)':
|
||||
dependencies:
|
||||
'@testing-library/dom': 10.4.0
|
||||
|
||||
'@tokenizer/token@0.3.0': {}
|
||||
|
||||
'@tootallnate/once@1.1.2': {}
|
||||
@@ -27138,6 +27158,8 @@ snapshots:
|
||||
sonic-boom: 4.2.0
|
||||
thread-stream: 3.1.0
|
||||
|
||||
pinyin-tone@2.4.0: {}
|
||||
|
||||
pirates@4.0.6: {}
|
||||
|
||||
pkg-dir@3.0.0:
|
||||
|
||||
Reference in New Issue
Block a user