dramaling-vocab-learning/frontend/components/ClickableText.tsx

187 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState } from 'react'
// 模擬分析後的詞彙資料
interface WordAnalysis {
word: string
translation: string
definition: string
partOfSpeech: string
pronunciation: string
synonyms: string[]
isPhrase: boolean
phraseInfo?: {
phrase: string
meaning: string
warning: string
}
}
interface ClickableTextProps {
text: string
analysis?: Record<string, WordAnalysis>
onWordClick?: (word: string, analysis: WordAnalysis) => void
}
export function ClickableText({ text, analysis, onWordClick }: ClickableTextProps) {
const [selectedWord, setSelectedWord] = useState<string | null>(null)
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 })
// 將文字分割成單字
const words = text.split(/(\s+|[.,!?;:])/g).filter(word => word.trim())
const handleWordClick = (word: string, event: React.MouseEvent) => {
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
const wordAnalysis = analysis?.[cleanWord]
if (wordAnalysis) {
const rect = event.currentTarget.getBoundingClientRect()
setPopupPosition({
x: rect.left + rect.width / 2,
y: rect.top - 10
})
setSelectedWord(cleanWord)
onWordClick?.(cleanWord, wordAnalysis)
}
}
const closePopup = () => {
setSelectedWord(null)
}
const getWordClass = (word: string) => {
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
const wordAnalysis = analysis?.[cleanWord]
if (!wordAnalysis) return "cursor-default"
const baseClass = "cursor-pointer transition-all duration-200 hover:bg-blue-100 rounded px-1"
if (wordAnalysis.isPhrase) {
return `${baseClass} bg-yellow-100 border-b-2 border-yellow-400 hover:bg-yellow-200`
}
return `${baseClass} hover:bg-blue-200 border-b border-blue-300`
}
return (
<div className="relative">
{/* 點擊區域遮罩 */}
{selectedWord && (
<div
className="fixed inset-0 z-10"
onClick={closePopup}
/>
)}
{/* 文字內容 */}
<div className="text-lg leading-relaxed">
{words.map((word, index) => {
if (word.trim() === '') return <span key={index}>{word}</span>
return (
<span
key={index}
className={getWordClass(word)}
onClick={(e) => handleWordClick(word, e)}
>
{word}
</span>
)
})}
</div>
{/* 彈出視窗 */}
{selectedWord && analysis?.[selectedWord] && (
<div
className="fixed z-20 bg-white border border-gray-300 rounded-lg shadow-lg p-4 w-80 max-w-sm"
style={{
left: `${popupPosition.x}px`,
top: `${popupPosition.y}px`,
transform: 'translate(-50%, -100%)',
}}
>
<div className="space-y-3">
{/* 標題 */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-gray-900">
{analysis[selectedWord].word}
</h3>
<button
onClick={closePopup}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
{/* 片語警告 */}
{analysis[selectedWord].isPhrase && analysis[selectedWord].phraseInfo && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<div className="text-yellow-600 text-lg"></div>
<div>
<div className="text-sm font-medium text-yellow-800">
</div>
<div className="text-sm text-yellow-700 mt-1">
<strong></strong>{analysis[selectedWord].phraseInfo.phrase}
</div>
<div className="text-sm text-yellow-700">
<strong></strong>{analysis[selectedWord].phraseInfo.meaning}
</div>
</div>
</div>
</div>
)}
{/* 詞性和發音 */}
<div className="flex items-center gap-4">
<span className="text-sm bg-gray-100 px-2 py-1 rounded">
{analysis[selectedWord].partOfSpeech}
</span>
<span className="text-sm text-gray-600">
{analysis[selectedWord].pronunciation}
</span>
<button className="text-blue-600 hover:text-blue-800">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
</svg>
</button>
</div>
{/* 翻譯 */}
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="text-base text-gray-900">{analysis[selectedWord].translation}</div>
</div>
{/* 定義 */}
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="text-sm text-gray-600">{analysis[selectedWord].definition}</div>
</div>
{/* 同義詞 */}
{analysis[selectedWord].synonyms.length > 0 && (
<div>
<div className="text-sm font-medium text-gray-700"></div>
<div className="flex flex-wrap gap-1 mt-1">
{analysis[selectedWord].synonyms.map((synonym, idx) => (
<span
key={idx}
className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full"
>
{synonym}
</span>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}