diff --git a/app/learn/spanish/03/page.mdx b/app/learn/spanish/03/page.mdx new file mode 100644 index 0000000..6c31ae1 --- /dev/null +++ b/app/learn/spanish/03/page.mdx @@ -0,0 +1,67 @@ +import SpanishTablePracticeLoader from "@/components/spanish/SpanishTablePracticeLoader"; + +export const title = "人称代名詞・冠詞・指示詞の表完成演習"; + +# 人称代名詞・冠詞・指示詞の表完成演習 + + + +{/* 出題データ定義テーブル (display:none) */} + + diff --git a/components/spanish/SpanishTablePractice.tsx b/components/spanish/SpanishTablePractice.tsx new file mode 100644 index 0000000..6cdb3d7 --- /dev/null +++ b/components/spanish/SpanishTablePractice.tsx @@ -0,0 +1,534 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +// 文法表データの型定義 +export type GrammarTableRow = { + label: string; + cells: GrammarTableCell[]; +}; + +export type GrammarTableCell = { + value: string; + colSpan: number; +}; + +export type GrammarTableData = { + categoryKey: string; + categoryTitle: string; + headers: string[]; + rows: GrammarTableRow[]; +}; + +// アルファベット基本文字とアクセント付き特殊文字の対応マップ +const ACCENT_MAP: Record = { + a: ["a", "á"], + e: ["e", "é"], + i: ["i", "í"], + o: ["o", "ó"], + u: ["u", "ú", "ü"], + n: ["n", "ñ"], + A: ["A", "Á"], + E: ["E", "É"], + I: ["I", "Í"], + O: ["O", "Ó"], + U: ["U", "Ú", "Ü"], + N: ["N", "Ñ"], +}; + +// 逆引きルックアップ用マップ +const ACCENT_GROUP_KEY: Record = {}; +for (const [base, list] of Object.entries(ACCENT_MAP)) { + for (const char of list) { + ACCENT_GROUP_KEY[char] = base; + } +} + +/** + * 矢印キー操作によりアクセント記号を順次切り替える関数 + */ +function cycleChar(char: string, direction: "up" | "down"): string { + const groupKey = ACCENT_GROUP_KEY[char]; + if (!groupKey) return char; + const list = ACCENT_MAP[groupKey]; + if (!list) return char; + const currentIndex = list.indexOf(char); + if (currentIndex === -1) return char; + + const delta = direction === "up" ? 1 : -1; + const nextIndex = (currentIndex + delta + list.length) % list.length; + return list[nextIndex]; +} + +const SPECIAL_KEYS = ["á", "é", "í", "ó", "ú", "ñ", "ü", "¿", "¡"]; + +// 空欄割合の選択肢 (パーセント) +export type BlankRatioOption = "25" | "50" | "75" | "100"; + +export default function SpanishTablePractice({ tables }: { tables: GrammarTableData[] }) { + // カテゴリ選択状態("all" または 各カテゴリのタイトル) + const [selectedCategory, setSelectedCategory] = useState("all"); + // 空欄の指定割合(25%, 50%, 75%, 100%) + const [blankRatio, setBlankRatio] = useState("50"); + + // 空欄対象セルのマスクマップ: key = `${tableIndex}-${rowIndex}-${colIndex}` -> boolean + const [blankMask, setBlankMask] = useState>({}); + + // 入力グリッドの回答データ保持: key = `${tableIndex}-${rowIndex}-${colIndex}` -> value + const [gridAnswers, setGridAnswers] = useState>({}); + const [isChecked, setIsChecked] = useState(false); + + // 現在フォーカス中の入力欄のキー + const [activeInputKey, setActiveInputKey] = useState(null); + + // 各入力欄への参照を保持するMap + const inputRefs = useRef>(new Map()); + + // 初回マウント参照 + const isMountedRef = useRef(false); + + // 選択フィルタリング後のテーブル一覧 + const filteredTables = tables.filter( + (t) => selectedCategory === "all" || t.categoryTitle === selectedCategory, + ); + + /** + * 選択された割合に応じて、空欄にするセルをランダムに選出するマスク作成関数 + */ + const generateBlankMask = useCallback( + (targetTables: GrammarTableData[], ratioStr: BlankRatioOption) => { + const ratio = parseInt(ratioStr, 10) / 100; + const mask: Record = {}; + + const allKeys: string[] = []; + targetTables.forEach((table, tIdx) => { + table.rows.forEach((row, rIdx) => { + row.cells.forEach((_, cIdx) => { + allKeys.push(`${tIdx}-${rIdx}-${cIdx}`); + }); + }); + }); + + if (ratioStr === "100") { + allKeys.forEach((k) => { + mask[k] = true; + }); + } else { + // 対象のキーをランダムにシャッフルし、指定割合分を true(空欄)に設定 + const keysCopy = [...allKeys]; + for (let i = keysCopy.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [keysCopy[i], keysCopy[j]] = [keysCopy[j], keysCopy[i]]; + } + const blankCount = Math.max(1, Math.round(allKeys.length * ratio)); + keysCopy.slice(0, blankCount).forEach((k) => { + mask[k] = true; + }); + } + + return mask; + }, + [], + ); + + /** + * 演習のリセット処理 + */ + const resetPractice = useCallback( + (cat: string, ratio: BlankRatioOption = blankRatio) => { + const tList = tables.filter((t) => cat === "all" || t.categoryTitle === cat); + const newMask = generateBlankMask(tList, ratio); + setBlankMask(newMask); + setGridAnswers({}); + setIsChecked(false); + }, + [tables, blankRatio, generateBlankMask], + ); + + // マウント時にランダム空欄マスクを生成 + useEffect(() => { + if (!isMountedRef.current && tables.length > 0) { + isMountedRef.current = true; + resetPractice("all", "50"); + } + }, [tables, resetPractice]); + + /** + * セルに入力があったときの更新処理 + */ + const handleCellChange = (cellKey: string, value: string) => { + setGridAnswers((prev) => ({ + ...prev, + [cellKey]: value, + })); + }; + + /** + * 答え合わせ実行処理 + */ + const handleCheckAnswers = () => { + setIsChecked(true); + }; + + /** + * カテゴリ切替処理 + */ + const handleCategoryChange = (cat: string) => { + setSelectedCategory(cat); + resetPractice(cat, blankRatio); + }; + + /** + * 空欄割合変更処理 + */ + const handleBlankRatioChange = (ratio: BlankRatioOption) => { + setBlankRatio(ratio); + resetPractice(selectedCategory, ratio); + }; + + /** + * 入力キーボードイベント(↑ / ↓ 矢印キーでアクセント変換、Enterキーで答え合わせ送信または再挑戦) + */ + const handleKeyDown = ( + e: React.KeyboardEvent, + cellKey: string, + currentValue: string, + ) => { + if (e.nativeEvent.isComposing) return; + + if (e.key === "ArrowUp" || e.key === "ArrowDown") { + e.preventDefault(); + const input = inputRefs.current.get(cellKey); + if (!input || !currentValue) return; + + const selStart = input.selectionStart ?? currentValue.length; + + let targetIdx = -1; + for (let i = Math.min(selStart - 1, currentValue.length - 1); i >= 0; i--) { + if (ACCENT_GROUP_KEY[currentValue[i]]) { + targetIdx = i; + break; + } + } + + if (targetIdx === -1) { + for (let i = selStart; i < currentValue.length; i++) { + if (ACCENT_GROUP_KEY[currentValue[i]]) { + targetIdx = i; + break; + } + } + } + + if (targetIdx !== -1) { + const charToCycle = currentValue[targetIdx]; + const newChar = cycleChar(charToCycle, e.key === "ArrowUp" ? "up" : "down"); + const newVal = + currentValue.slice(0, targetIdx) + newChar + currentValue.slice(targetIdx + 1); + + handleCellChange(cellKey, newVal); + + requestAnimationFrame(() => { + const updatedInput = inputRefs.current.get(cellKey); + if (updatedInput) { + updatedInput.setSelectionRange(selStart, selStart); + } + }); + } + } else if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + if (!isChecked) { + handleCheckAnswers(); + } else { + resetPractice(selectedCategory, blankRatio); + } + } + }; + + // 答え合わせ状態でのEnterキーサポート + useEffect(() => { + if (!isChecked) return; + + const handleGlobalKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + if (e.isComposing) return; + e.preventDefault(); + resetPractice(selectedCategory, blankRatio); + } + }; + window.addEventListener("keydown", handleGlobalKeyDown); + return () => window.removeEventListener("keydown", handleGlobalKeyDown); + }, [isChecked, resetPractice, selectedCategory, blankRatio]); + + /** + * 特殊文字ボタンクリックでアクティブな入力欄へ文字挿入 + */ + const handleInsertSpecialChar = (char: string) => { + if (!activeInputKey) return; + const input = inputRefs.current.get(activeInputKey); + const val = gridAnswers[activeInputKey] || ""; + const selStart = input?.selectionStart ?? val.length; + const selEnd = input?.selectionEnd ?? val.length; + + const newVal = val.slice(0, selStart) + char + val.slice(selEnd); + handleCellChange(activeInputKey, newVal); + + requestAnimationFrame(() => { + if (input) { + input.focus(); + const newPos = selStart + char.length; + input.setSelectionRange(newPos, newPos); + } + }); + }; + + if (!tables || tables.length === 0) { + return ( +
+

+ 文法表データが見つかりませんでした。 +

+
+ ); + } + + // 正解数の計算 (空欄対象に指定されたセルのみを計算) + let totalBlankCells = 0; + let correctCount = 0; + filteredTables.forEach((table, tIdx) => { + table.rows.forEach((row, rIdx) => { + row.cells.forEach((cell, cIdx) => { + const key = `${tIdx}-${rIdx}-${cIdx}`; + if (blankMask[key]) { + totalBlankCells += 1; + const userVal = (gridAnswers[key] || "").trim().toLowerCase(); + if (userVal === cell.value.trim().toLowerCase()) { + correctCount += 1; + } + } + }); + }); + }); + + return ( +
+ {/* 設定・カテゴリ選択パネル */} +
+
+ {/* カテゴリ選択 */} +
+ + カテゴリ: + +
+ + {tables.map((t) => ( + + ))} +
+
+ + {/* 空欄割合選択 */} +
+ + 空欄の割合: + +
+ {[ + { label: "25%", value: "25" }, + { label: "50%", value: "50" }, + { label: "75%", value: "75" }, + { label: "100%", value: "100" }, + ].map((opt) => ( + + ))} +
+
+
+
+ + {/* 特殊文字ボタンパレット */} +
+ + 特殊文字入力パレット (選択中セルへ挿入) + +
+ {SPECIAL_KEYS.map((char) => ( + + ))} +
+

+ 💡 入力セルで ↑ / ↓ 矢印キー を押してもアクセント記号(á, é, í, ó, ú, ñ + など)へ切り替えられます。 +

+
+ + {/* 文法表完成グリッド */} +
+ {filteredTables.map((table, tIdx) => ( +
+

+ 【{table.categoryTitle}】 +

+ + + + {table.headers.map((h, hIdx) => ( + + ))} + + + + {table.rows.map((row, rIdx) => ( + + + {row.cells.map((cell, cIdx) => { + const key = `${tIdx}-${rIdx}-${cIdx}`; + const isBlankTarget = blankMask[key]; + const val = gridAnswers[key] || ""; + const isCellCorrect = + isChecked && val.trim().toLowerCase() === cell.value.trim().toLowerCase(); + const isCellWrong = isChecked && isBlankTarget && !isCellCorrect; + + return ( + + ); + })} + + ))} + +
+ {h} +
+ {row.label} + + {isBlankTarget ? ( +
+ { + if (el) inputRefs.current.set(key, el); + else inputRefs.current.delete(key); + }} + type="text" + value={val} + disabled={isChecked} + onFocus={() => setActiveInputKey(key)} + onChange={(e) => handleCellChange(key, e.target.value)} + onKeyDown={(e) => handleKeyDown(e, key, val)} + placeholder="..." + className={`w-full rounded-xl border-2 px-3 py-2 text-base font-semibold outline-none transition-colors dark:bg-zinc-900 dark:text-zinc-50 ${ + isChecked + ? isCellCorrect + ? "border-emerald-500 bg-emerald-50 text-emerald-900 dark:bg-emerald-950/60 dark:text-emerald-300" + : "border-rose-500 bg-rose-50 text-rose-900 dark:bg-rose-950/60 dark:text-rose-300" + : "border-zinc-300 bg-white focus:border-teal-500 focus:ring-4 focus:ring-teal-500/20 dark:border-zinc-700 dark:focus:border-teal-400" + }`} + /> + {isCellWrong && ( + + 正解: {cell.value} + + )} +
+ ) : ( + + {cell.value} + + )} +
+
+ ))} +
+ + {/* スコア・結果表示バッジ & 操作ボタンエリア */} +
+ {isChecked && ( +
+

+ 答え合わせ結果:{" "} + + {correctCount} + {" "} + / {totalBlankCells} 空欄正解 ( + {totalBlankCells > 0 ? Math.round((correctCount / totalBlankCells) * 100) : 0}%) +

+
+ )} + +
+ {!isChecked ? ( + + ) : ( + + )} + +
+
+
+ ); +} diff --git a/components/spanish/SpanishTablePracticeLoader.tsx b/components/spanish/SpanishTablePracticeLoader.tsx new file mode 100644 index 0000000..46ce81a --- /dev/null +++ b/components/spanish/SpanishTablePracticeLoader.tsx @@ -0,0 +1,104 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import SpanishTablePractice, { + type GrammarTableCell, + type GrammarTableData, + type GrammarTableRow, +} from "./SpanishTablePractice"; + +function mergeAdjacentCells(cells: string[]): GrammarTableCell[] { + const mergedCells: GrammarTableCell[] = []; + + for (const value of cells) { + const previousCell = mergedCells.at(-1); + if (previousCell?.value === value) { + previousCell.colSpan += 1; + } else { + mergedCells.push({ value, colSpan: 1 }); + } + } + + return mergedCells; +} + +/** + * MDX教材ファイル(app/learn/spanish/03/page.mdx)から + * 人称代名詞・冠詞・指示詞の各文法表データを動的に抽出する関数。 + * データのベタ打ちを排除し、教材ファイルを唯一のデータソースとして使用する。 + */ +async function getGrammarTablesFromMdx(): Promise { + const filePath = path.join(process.cwd(), "app", "learn", "spanish", "03", "page.mdx"); + const tables: GrammarTableData[] = []; + + try { + const content = await fs.readFile(filePath, "utf-8"); + const lines = content.split("\n"); + + let currentCategory = "文法表"; + let currentHeaders: string[] = []; + let currentRows: GrammarTableRow[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + + // 見出し3(###)でカテゴリ切り替え + if (trimmed.startsWith("###")) { + if (currentHeaders.length > 0 && currentRows.length > 0) { + tables.push({ + categoryKey: currentCategory.toLowerCase(), + categoryTitle: currentCategory, + headers: currentHeaders, + rows: currentRows, + }); + } + currentCategory = trimmed.replace(/^###\s*/, "").trim(); + currentHeaders = []; + currentRows = []; + } else if (trimmed.startsWith("|") && trimmed.endsWith("|")) { + const cells = trimmed + .split("|") + .slice(1, -1) + .map((c) => c.trim()); + + if (cells.length >= 2) { + // 区切り行(| --- | --- |)は無視 + if (cells.every((c) => /^[-:\s]+$/.test(c))) { + continue; + } + + // 最初のデータ行をヘッダーとして取得 + if (currentHeaders.length === 0) { + currentHeaders = cells; + } else { + const label = cells[0]; + const rowCells = mergeAdjacentCells(cells.slice(1)); + currentRows.push({ label, cells: rowCells }); + } + } + } + } + + // 最後のテーブルを追加 + if (currentHeaders.length > 0 && currentRows.length > 0) { + tables.push({ + categoryKey: currentCategory.toLowerCase(), + categoryTitle: currentCategory, + headers: currentHeaders, + rows: currentRows, + }); + } + } catch { + // 読込エラー時は空配列を返す + } + + return tables; +} + +/** + * サーバー側でMDXから文法表データを読み込み、 + * 表完成演習コンポーネント(SpanishTablePractice)へ受け渡すServer Component。 + */ +export default async function SpanishTablePracticeLoader() { + const tables = await getGrammarTablesFromMdx(); + return ; +}