diff --git a/src/components/conversation/LinkedPhrase.tsx b/src/components/conversation/LinkedPhrase.tsx new file mode 100644 index 0000000..b3abb14 --- /dev/null +++ b/src/components/conversation/LinkedPhrase.tsx @@ -0,0 +1,267 @@ +import { useState, useRef, useEffect, useMemo } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { X, Plus, Check } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { toast } from '@/lib/toast' +import { hiraganaToRomaji } from '@/lib/hiraganaToRomaji' +import { useAppStore } from '@/store' +import { matchPhraseWithWords } from '@/lib/phraseWordMatcher' +import type { ConversationPhrase } from '@/types' +import { WORDS } from '@/data/words' + +interface LinkedPhraseProps { + phrase: ConversationPhrase + categoryId: string +} + +/** + * 회화 표현에서 사전 단어를 링크로 표시하는 컴포넌트 + * + * 기능: + * - 사전에 있는 단어는 클릭 가능한 칩으로 표시 + * - 클릭 시 바텀시트에서 단어 상세 정보 표시 + * - "단어장에 담기" 버튼으로 개인 단어장에 추가 + * - 이미 추가된 단어는 "담아졌어요" 표시 + */ +export function LinkedPhrase({ phrase, categoryId }: LinkedPhraseProps) { + const [selectedWordId, setSelectedWordId] = useState(null) + const previousFocusRef = useRef(null) + const bottomSheetRef = useRef(null) + + // 사전 매칭을 메모이즈해서 렌더마다 인덱스를 재생성하지 않음 + const matches = useMemo( + () => matchPhraseWithWords(phrase.japanese, WORDS), + [phrase.japanese] + ) + + // Zustand store에서 회화 메모 조회 및 미션 진행도 업데이트 + const { conversationMemo, addConversationMemo, removeConversationMemo, bumpMissionProgress } = useAppStore() + + // 바텀시트 열 때: 포커스 저장, 닫을 때: 복귀 + useEffect(() => { + if (selectedWordId) { + previousFocusRef.current = document.activeElement as HTMLElement + // 바텀시트에 포커스 이동 (닫기 버튼으로) + setTimeout(() => { + const closeButton = bottomSheetRef.current?.querySelector('button[aria-label="닫기"]') as HTMLButtonElement + closeButton?.focus() + }, 100) + } else if (previousFocusRef.current) { + previousFocusRef.current.focus() + previousFocusRef.current = null + } + }, [selectedWordId]) + + // Esc 키로 바텀시트 닫기 + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape' && selectedWordId) { + setSelectedWordId(null) + } + } + document.addEventListener('keydown', handleEscape) + return () => document.removeEventListener('keydown', handleEscape) + }, [selectedWordId]) + + // 선택된 단어 정보 + const selectedMatch = matches.find((m) => m.word.id === selectedWordId) + const selectedWord = selectedMatch?.word + + // 선택된 단어가 입자인지 확인 (phrase.words에서 입자 정보 가져오기) + const selectedWordFromPhrase = selectedMatch + ? phrase.words.find( + (w) => + w.text === selectedMatch.matchedText || + w.reading === selectedMatch.word.hiragana || + w.text === selectedMatch.word.kanji + ) + : null + const isParticle = selectedWordFromPhrase?.isParticle === true + + // 이 단어가 이미 메모에 있는지 확인 + const isMemoized = + selectedWord && + conversationMemo.some((memo) => memo.text === selectedWord.kanji && memo.sourcePhrase === phrase.japanese) + + const handleAddToMemo = () => { + if (!selectedWord) return + + // 입자는 메모 추가 불가 + if (isParticle) { + toast.info({ message: '조사는 단어장에 담을 수 없어요.' }) + return + } + + if (isMemoized) { + removeConversationMemo(selectedWord.kanji) + toast.info({ message: '단어장에서 제거했어요.' }) + } else { + addConversationMemo({ + text: selectedWord.kanji, + reading: selectedWord.hiragana, + meaning: selectedWord.meaning, + sourcePhrase: phrase.japanese, + category: categoryId, + savedAt: Date.now(), + }) + toast.success({ message: '단어장에 담았어요.' }) + bumpMissionProgress('conversation', 1) + } + } + + return ( +
+ {/* 일본어 표현 — 링크된 단어 표시 */} +
+ {matches.length === 0 ? ( + // 매칭된 단어가 없으면 그냥 텍스트 표시 + {phrase.japanese} + ) : ( + // 매칭 결과에 따라 링크와 일반 텍스트 분절 + <> + {/* 첫 매칭까지의 텍스트 */} + {matches[0].startIndex > 0 && ( + {phrase.japanese.substring(0, matches[0].startIndex)} + )} + + {/* 매칭된 단어들 렌더링 */} + {matches.map((match, index) => ( +
+ {/* 링크된 단어 칩 */} + setSelectedWordId(match.word.id)} + className={`inline-flex flex-col items-center px-2 py-1 rounded-lg transition-colors ${ + selectedWordId === match.word.id + ? 'bg-primary text-primary-foreground' + : 'bg-primary/10 text-primary hover:bg-primary/20' + }`} + type="button" + aria-label={`${match.matchedText}. 뜻: ${match.word.meaning}`} + > + {match.matchedText} + {hiraganaToRomaji(match.word.hiragana)} + + + {/* 다음 매칭까지의 텍스트 */} + {index < matches.length - 1 && ( + + {phrase.japanese.substring(match.endIndex, matches[index + 1].startIndex)} + + )} +
+ ))} + + {/* 마지막 매칭 이후의 텍스트 */} + {matches[matches.length - 1].endIndex < phrase.japanese.length && ( + + {phrase.japanese.substring(matches[matches.length - 1].endIndex)} + + )} + + )} +
+ + {/* 한국어 번역 */} +

{phrase.korean}

+ + {/* 단어 상세 바텀시트 */} + + {selectedWord && ( + setSelectedWordId(null)} + className="fixed inset-0 bg-black/50 z-40" + /> + )} + + + {/* 바텀시트 콘텐츠 */} + + {selectedWord && ( + + {/* 닫기 버튼 */} + + + {/* 단어 정보 */} +
+ {/* 한자 + 히라가나 + 로마자 */} +
+
{selectedWord.kanji}
+
{selectedWord.hiragana}
+
{hiraganaToRomaji(selectedWord.hiragana)}
+
+ + {/* 뜻 */} +
+

뜻

+

{selectedWord.meaning}

+
+ + {/* 레벨 */} +
+

레벨

+
+ N{selectedWord.level} +
+
+ + {/* 예문 */} + {selectedWord.example && ( +
+

예문

+
+

{selectedWord.example.japanese}

+

{selectedWord.example.korean}

+
+
+ )} + + {/* 단어장 추가 버튼 */} + +
+
+ )} +
+
+ ) +} diff --git a/src/lib/phraseWordMatcher.test.ts b/src/lib/phraseWordMatcher.test.ts new file mode 100644 index 0000000..88ba75d --- /dev/null +++ b/src/lib/phraseWordMatcher.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from 'vitest' +import { buildWordIndex, matchPhraseWords, segmentPhrasedText, matchPhraseWithWords } from './phraseWordMatcher' +import type { Word } from '@/types' + +describe('phraseWordMatcher', () => { + // 테스트용 샘플 단어 + const sampleWords: Word[] = [ + { id: 'w1', kanji: 'レストラン', hiragana: 'れすとらん', meaning: '레스토랑', level: 5 }, + { id: 'w2', kanji: '探す', hiragana: 'さがす', meaning: '찾다', level: 5 }, + { id: 'w3', kanji: '水', hiragana: 'みず', meaning: '물', level: 5 }, + { id: 'w4', kanji: '飲む', hiragana: 'のむ', meaning: '마시다', level: 5 }, + { id: 'w5', kanji: '家', hiragana: 'いえ', meaning: '집', level: 5 }, + { id: 'w6', kanji: '本', hiragana: 'ほん', meaning: '책', level: 5 }, + ] + + describe('buildWordIndex', () => { + it('should create index with kanji keys', () => { + const index = buildWordIndex(sampleWords) + expect(index.has('レストラン')).toBe(true) + expect(index.has('探す')).toBe(true) + expect(index.get('レストラン')?.[0].meaning).toBe('레스토랑') + }) + + it('should create index with hiragana keys for fallback', () => { + const index = buildWordIndex(sampleWords) + expect(index.has('れすとらん')).toBe(true) + expect(index.has('さがす')).toBe(true) + }) + + it('should handle words without kanji', () => { + const wordsWithoutKanji: Word[] = [ + { id: 'w1', kanji: '', hiragana: 'あ', meaning: '문자', level: 5 }, + ] + const index = buildWordIndex(wordsWithoutKanji) + expect(index.has('あ')).toBe(true) + }) + }) + + describe('matchPhraseWords', () => { + it('should match simple phrase', () => { + const text = 'レストラン探す' + const index = buildWordIndex(sampleWords) + const matches = matchPhraseWords(text, index) + + // レストラン과 探す 매칭되어야 함 + expect(matches.length).toBeGreaterThanOrEqual(2) + expect(matches.some((m) => m.word.id === 'w1')).toBe(true) + expect(matches.some((m) => m.word.id === 'w2')).toBe(true) + }) + + it('should match longest substring first', () => { + // 長い (4글자)와 長 (1글자)가 있어도 긴 것 우선 + const wordsWithDifferentLengths: Word[] = [ + { id: 'w1', kanji: '長', hiragana: 'なが', meaning: '길다', level: 5 }, + { id: 'w2', kanji: '長い', hiragana: 'ながい', meaning: '길다', level: 5 }, + ] + const text = '長い川' + const index = buildWordIndex(wordsWithDifferentLengths) + const matches = matchPhraseWords(text, index) + + // 長い가 먼저 매칭 + expect(matches[0].word.id).toBe('w2') + expect(matches[0].matchedText).toBe('長い') + }) + + it('should skip non-matching characters', () => { + const text = '水飲む' + const index = buildWordIndex(sampleWords) + const matches = matchPhraseWords(text, index) + + // 水와 飲む 매칭 (사전에 있는 단어만) + expect(matches.some((m) => m.word.id === 'w3')).toBe(true) + expect(matches.some((m) => m.word.id === 'w4')).toBe(true) + }) + + it('should return correct positions', () => { + const text = 'レストラン' + const index = buildWordIndex(sampleWords) + const matches = matchPhraseWords(text, index) + + expect(matches[0].startIndex).toBe(0) + expect(matches[0].endIndex).toBe('レストラン'.length) + }) + + it('should handle empty results', () => { + const text = 'ああああ' + const index = buildWordIndex(sampleWords) + const matches = matchPhraseWords(text, index) + + expect(matches.length).toBe(0) + }) + + it('should handle consecutive matches', () => { + const text = '本を読む本' + const index = buildWordIndex(sampleWords) + const matches = matchPhraseWords(text, index) + + // 本이 두 번 매칭되어야 함 + const bookMatches = matches.filter((m) => m.word.id === 'w6') + expect(bookMatches.length).toBe(2) + expect(bookMatches[0].startIndex).toBe(0) + expect(bookMatches[1].startIndex).toBe('本を読む'.length) + }) + + it('should not match partial overlaps', () => { + const wordsWithPartialOverlap: Word[] = [ + { id: 'w1', kanji: '家', hiragana: 'いえ', meaning: '집', level: 5 }, + { id: 'w2', kanji: '家族', hiragana: 'かぞく', meaning: '가족', level: 5 }, + ] + const text = '家族' + const index = buildWordIndex(wordsWithPartialOverlap) + const matches = matchPhraseWords(text, index) + + // 家族이 먼저 매칭 (최장일치) + expect(matches[0].word.id).toBe('w2') + expect(matches.length).toBe(1) + }) + }) + + describe('segmentPhrasedText', () => { + it('should segment text with matches', () => { + const text = 'レストランを探す' + const index = buildWordIndex(sampleWords) + const matches = matchPhraseWords(text, index) + const segments = segmentPhrasedText(text, matches) + + // link, text, link 형태로 분절 + expect(segments.length).toBeGreaterThan(0) + expect(segments.some((s) => s.type === 'link')).toBe(true) + expect(segments.some((s) => s.type === 'text')).toBe(true) + }) + + it('should preserve text order', () => { + const text = 'レストラン探す' + const index = buildWordIndex(sampleWords) + const matches = matchPhraseWords(text, index) + const segments = segmentPhrasedText(text, matches) + + // 분절을 다시 조합하면 원본과 같아야 함 + const reconstructed = segments + .map((s) => { + if (s.type === 'link') { + return s.content.matchedText + } + return s.content + }) + .join('') + expect(reconstructed).toBe(text) + }) + }) + + describe('matchPhraseWithWords', () => { + it('should work without manual index', () => { + const text = 'レストランを探す' + const matches = matchPhraseWithWords(text, sampleWords) + + expect(matches.some((m) => m.word.id === 'w1')).toBe(true) + expect(matches.some((m) => m.word.id === 'w2')).toBe(true) + }) + }) +}) diff --git a/src/lib/phraseWordMatcher.ts b/src/lib/phraseWordMatcher.ts new file mode 100644 index 0000000..57feee0 --- /dev/null +++ b/src/lib/phraseWordMatcher.ts @@ -0,0 +1,157 @@ +import type { Word } from '@/types' + +/** + * 회화 표현 일본어 문장에서 사전 단어를 매칭하는 로직. + * 일본어는 띄어쓰기가 없으므로 최장일치 매칭을 사용. + * + * @param text 일본어 문장 (예: "レストランを探しています") + * @param words 사전 단어 배열 + * @returns 매칭된 단어들의 정보 + 위치 + */ + +export interface MatchedWord { + word: Word + startIndex: number + endIndex: number + matchedText: string // 일본어 텍스트에서 실제 매칭된 부분 +} + +/** + * 단어 사전으로부터 매칭용 인덱스 생성 (Map) + * 표기(kanji) 우선, 없으면 히라가나로 매칭 + * + * @param words 사전 단어 배열 + * @returns Map<표기, Word[]> — 한 글자열이 여러 단어를 가질 수 있음 + */ +export function buildWordIndex(words: Word[]): Map { + const index = new Map() + + for (const word of words) { + // kanji가 있으면 우선 인덱싱 + if (word.kanji) { + const key = word.kanji + if (!index.has(key)) { + index.set(key, []) + } + index.get(key)!.push(word) + } + // 히라가나도 인덱싱 (kanji 매칭 실패 시 대안용) + const hiraganaKey = word.hiragana + if (hiraganaKey !== word.kanji) { + if (!index.has(hiraganaKey)) { + index.set(hiraganaKey, []) + } + index.get(hiraganaKey)!.push(word) + } + } + + return index +} + +/** + * 최장일치 매칭으로 일본어 문장에서 단어 추출 + * + * 알고리즘: + * 1. 현재 위치에서 가능한 가장 긴 부분문자열 찾기 + * 2. 없으면 다음 글자로 진행 + * 3. 매칭된 단어는 위치 기록 후 건너뜀 + * + * @param text 일본어 문장 + * @param wordIndex 단어 매칭용 인덱스 (buildWordIndex로 생성) + * @returns 매칭된 단어들 (위치순) + */ +export function matchPhraseWords(text: string, wordIndex: Map): MatchedWord[] { + const matches: MatchedWord[] = [] + let i = 0 + + while (i < text.length) { + // 현재 위치에서 가능한 가장 긴 단어 찾기 + let bestMatch: MatchedWord | null = null + let maxLen = 0 + + // 최대 5글자까지 시도 (일본어 단어 길이 제한) + for (let len = Math.min(5, text.length - i); len > 0; len--) { + const substring = text.substring(i, i + len) + const wordsForSubstring = wordIndex.get(substring) + + if (wordsForSubstring && wordsForSubstring.length > 0) { + // 첫 번째 매칭 사용 (중복 처리) + const word = wordsForSubstring[0] + if (len > maxLen) { + bestMatch = { + word, + startIndex: i, + endIndex: i + len, + matchedText: substring, + } + maxLen = len + } + break // 최장일치 찾음 + } + } + + if (bestMatch) { + matches.push(bestMatch) + i = bestMatch.endIndex + } else { + // 매칭 실패 시 다음 글자로 진행 + i++ + } + } + + return matches +} + +export type Segment = { type: 'link'; content: MatchedWord } | { type: 'text'; content: string } + +/** + * 매칭 결과에 따라 회화 표현을 분절 배열로 변환 + * UI 렌더링용으로 링크된 단어와 일반 텍스트를 구분하기 위함 + * + * @param text 일본어 문장 + * @param matches 매칭 결과 + * @returns Segment[] + */ +export function segmentPhrasedText(text: string, matches: MatchedWord[]): Segment[] { + const segments: Segment[] = [] + let lastIndex = 0 + + for (const match of matches) { + // 매칭 전 일반 텍스트 추가 + if (lastIndex < match.startIndex) { + segments.push({ + type: 'text', + content: text.substring(lastIndex, match.startIndex), + }) + } + // 매칭된 단어 추가 + segments.push({ + type: 'link', + content: match, + }) + lastIndex = match.endIndex + } + + // 남은 텍스트 추가 + if (lastIndex < text.length) { + segments.push({ + type: 'text', + content: text.substring(lastIndex), + }) + } + + return segments +} + +/** + * 간편한 매칭 함수 (인덱스 자동 생성) + * 작은 규모나 테스트용. 대규모는 인덱스를 재사용하는 게 더 효율적. + * + * @param phraseJapanese 회화 표현 일본어 + * @param words 사전 단어 배열 + * @returns 매칭 결과 + */ +export function matchPhraseWithWords(phraseJapanese: string, words: Word[]) { + const index = buildWordIndex(words) + return matchPhraseWords(phraseJapanese, index) +} diff --git a/src/pages/ConversationDetailPage.tsx b/src/pages/ConversationDetailPage.tsx index f6926d8..507e908 100644 --- a/src/pages/ConversationDetailPage.tsx +++ b/src/pages/ConversationDetailPage.tsx @@ -8,13 +8,12 @@ import { Bookmark, Gamepad2, Star } from 'lucide-react' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { TTSButton } from '@/components/TTSButton' -import { ClickablePhrase } from '@/components/conversation/ClickablePhrase' +import { LinkedPhrase } from '@/components/conversation/LinkedPhrase' import { PrefetchAudioButton } from '@/components/conversation/PrefetchAudioButton' import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import { CONVERSATION_CATEGORIES } from '@/data/conversations' import { getIconByName } from '@/lib/icon-map' -import type { ConversationWord } from '@/types' export function ConversationDetailPage() { const { categoryId } = useParams<{ categoryId: string }>() @@ -61,19 +60,6 @@ export function ConversationDetailPage() { const Icon = getIconByName(category.icon) - const handleWordClick = (word: ConversationWord, isSaved: boolean, isParticle: boolean) => { - if (isParticle) { - toast.warning({ message: '조사는 메모할 수 없습니다', id: 'particle-warn' }) - return - } - - if (isSaved) { - toast.info({ message: `"${word.text}" 메모에서 삭제됐어요`, id: `memo-${word.text}` }) - } else { - toast.success({ message: `"${word.text}" 메모에 저장됐어요`, id: `memo-${word.text}` }) - } - } - return (
- -

- {phrase.korean} -

+ {/* 신기능: 사전 연결 단어 매칭 */} +