From 5d6eacb6156eb1a8e3904d683fcf957b4e48719f Mon Sep 17 00:00:00 2001
From: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com>
Date: Mon, 1 Dec 2025 10:35:06 -0800
Subject: [PATCH] feat(client): add pinyin-to-hanzi input to fill in the blank
challenge (#63986)
---
client/i18n/locales/english/translations.json | 3 +-
client/package.json | 2 +
client/src/redux/prop-types.ts | 6 +-
.../components/fill-in-the-blanks.tsx | 159 +++++---
.../components/pinyin-to-hanzi-input.test.tsx | 353 ++++++++++++++++++
.../components/pinyin-to-hanzi-input.tsx | 237 ++++++++++++
.../Challenges/fill-in-the-blank/show.tsx | 62 ++-
pnpm-lock.yaml | 22 ++
8 files changed, 774 insertions(+), 70 deletions(-)
create mode 100644 client/src/templates/Challenges/components/pinyin-to-hanzi-input.test.tsx
create mode 100644 client/src/templates/Challenges/components/pinyin-to-hanzi-input.tsx
diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json
index 8965e8b6527..3051be48c2b 100644
--- a/client/i18n/locales/english/translations.json
+++ b/client/i18n/locales/english/translations.json
@@ -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.",
diff --git a/client/package.json b/client/package.json
index 6131bcab33a..e21795978e9 100644
--- a/client/package.json
+++ b/client/package.json
@@ -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",
diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts
index 4a953cce415..f2681450719 100644
--- a/client/src/redux/prop-types.ts
+++ b/client/src/redux/prop-types.ts
@@ -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;
diff --git a/client/src/templates/Challenges/components/fill-in-the-blanks.tsx b/client/src/templates/Challenges/components/fill-in-the-blanks.tsx
index 41a840e6c7d..23295877cf3 100644
--- a/client/src/templates/Challenges/components/fill-in-the-blanks.tsx
+++ b/client/src/templates/Challenges/components/fill-in-the-blanks.tsx
@@ -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 (
+
+ );
+ }
+
+ // Default text input
+ return (
+ 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 (
<>
+
{t(ariaInputDescription)}
- {paragraphs.map((p, i) => {
- return (
- // both keys, i and j, are stable between renders, since
- // the paragraphs are static.
-
- {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.map((node, j) => {
+ const { type, value } = node;
- if (type === 'hanzi-pinyin') {
- const { hanzi, pinyin } = value;
- return (
-
- {hanzi}
- (
- {pinyin}
- )
-
- );
- }
-
- // If a blank is answered correctly, render the answer as part of the sentence.
- if (type === 'blank' && answersCorrect[value] === true) {
- return ;
- }
-
- const answerLength = getAnswerLength(blankAnswers[value]);
+ if (type === 'text') {
+ return value;
+ }
+ if (type === 'hanzi-pinyin') {
+ const { hanzi, pinyin } = value;
return (
-
- 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' }
- : {})}
- />
+
+ {hanzi}
+ (
+ {pinyin}
+ )
+
);
- })}
-
- );
- })}
+ }
+
+ // If a blank is answered correctly, render the answer as part of the sentence.
+ if (answersCorrect[value] === true) {
+ return
;
+ }
+
+ return (
+
+ );
+ })}
+
+ ))}
diff --git a/client/src/templates/Challenges/components/pinyin-to-hanzi-input.test.tsx b/client/src/templates/Challenges/components/pinyin-to-hanzi-input.test.tsx
new file mode 100644
index 00000000000..da71228028a
--- /dev/null
+++ b/client/src/templates/Challenges/components/pinyin-to-hanzi-input.test.tsx
@@ -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(
+
+ );
+
+ const input = screen.getByLabelText
('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(
+
+ );
+
+ const input = screen.getByLabelText('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(
+
+ );
+
+ const input = screen.getByLabelText('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 = {
+ she2nme: '什me',
+ she2nm: '什m',
+ she2n: '什',
+ she2: 'shé',
+ she: 'she'
+ };
+
+ render(
+
+ );
+
+ const input = screen.getByLabelText('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(
+
+ );
+
+ const input = screen.getByLabelText('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(
+
+ );
+
+ const input = screen.getByLabelText('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(
+
+ );
+
+ const input = screen.getByLabelText('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(
+
+ );
+
+ const input = screen.getByLabelText('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(
+
+ );
+
+ const input = screen.getByLabelText('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好');
+ });
+});
diff --git a/client/src/templates/Challenges/components/pinyin-to-hanzi-input.tsx b/client/src/templates/Challenges/components/pinyin-to-hanzi-input.tsx
new file mode 100644
index 00000000000..ec8d08d3cbc
--- /dev/null
+++ b/client/src/templates/Challenges/components/pinyin-to-hanzi-input.tsx
@@ -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) => {
+ 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 (
+
+ );
+}
+
+PinyinToHanziInput.displayName = 'PinyinToHanziInput';
+
+export default PinyinToHanziInput;
diff --git a/client/src/templates/Challenges/fill-in-the-blank/show.tsx b/client/src/templates/Challenges/fill-in-the-blank/show.tsx
index 4c95ab32630..5b1cc32ff76 100644
--- a/client/src/templates/Challenges/fill-in-the-blank/show.tsx
+++ b/client/src/templates/Challenges/fill-in-the-blank/show.tsx
@@ -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
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index da2968f9daf..7ccf96f48b6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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: