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

578 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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, useEffect } from 'react'
import { createPortal } from 'react-dom'
/**
* ClickableTextV2 組件架構說明
*
* 🎯 **Portal 設計解決方案**
* - 使用 React Portal 將彈窗渲染到 document.body
* - 完全脫離父級 CSS 繼承,避免樣式污染
* - 確保彈窗樣式與 vocab-designs 頁面的詞卡風格完全一致
*
* 🏗️ **組件架構**
* ```
* ClickableTextV2
* ├─ 文字容器 (text-lg) - 可點擊文字
* └─ VocabPopup (Portal) - 彈窗渲染到 body不受父級影響
* ```
*
* ✅ **解決的問題**
* - CSS 繼承問題Portal 完全脫離父級樣式
* - 字體大小問題:彈窗使用標準字體大小
* - 對齊問題:彈窗內容正確左對齊
*
* 🔧 **技術要點**
* - createPortal() 渲染到 document.body
* - mounted state 確保只在客戶端渲染
* - 保持原有 API 和功能不變
*/
// 更新的詞彙分析介面
interface WordAnalysis {
word: string
translation: string
definition: string
partOfSpeech: string
pronunciation: string
synonyms: string[]
antonyms?: string[]
isPhrase: boolean
isHighValue: boolean // 高學習價值標記
learningPriority: 'high' | 'medium' | 'low' // 學習優先級
phraseInfo?: {
phrase: string
meaning: string
warning: string
colorCode: string // 片語顏色代碼
}
difficultyLevel: string
costIncurred?: number // 點擊此詞彙的成本
}
interface ClickableTextProps {
text: string
analysis?: Record<string, WordAnalysis>
highValueWords?: string[] // 高價值詞彙列表
phrasesDetected?: Array<{
phrase: string
words: string[]
colorCode: string
}>
onWordClick?: (word: string, analysis: WordAnalysis) => void
onWordCostConfirm?: (word: string, cost: number) => Promise<boolean> // 收費確認
onNewWordAnalysis?: (word: string, analysis: WordAnalysis) => void // 新詞彙分析資料回調
onSaveWord?: (word: string, analysis: WordAnalysis) => Promise<void> // 保存詞彙回調
remainingUsage?: number // 剩餘使用次數
}
export function ClickableTextV2({
text,
analysis,
onWordClick,
onWordCostConfirm,
onNewWordAnalysis,
onSaveWord,
remainingUsage = 5
}: ClickableTextProps) {
const [selectedWord, setSelectedWord] = useState<string | null>(null)
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0, showBelow: false })
const [showCostConfirm, setShowCostConfirm] = useState<{
word: string
cost: number
position: { x: number, y: number }
} | null>(null)
const [isSavingWord, setIsSavingWord] = useState(false)
const [mounted, setMounted] = useState(false)
// 確保只在客戶端渲染 Portal
useEffect(() => {
setMounted(true)
}, [])
// 獲取CEFR等級顏色 - 與詞卡風格完全一致
const getCEFRColor = (level: string) => {
switch (level) {
case 'A1': return 'bg-green-100 text-green-700 border-green-200'
case 'A2': return 'bg-blue-100 text-blue-700 border-blue-200'
case 'B1': return 'bg-yellow-100 text-yellow-700 border-yellow-200'
case 'B2': return 'bg-orange-100 text-orange-700 border-orange-200'
case 'C1': return 'bg-red-100 text-red-700 border-red-200'
case 'C2': return 'bg-purple-100 text-purple-700 border-purple-200'
default: return 'bg-gray-100 text-gray-700 border-gray-200'
}
}
// 輔助函數兼容大小寫屬性名稱和處理AI資料格式
const getWordProperty = (wordData: any, propName: string) => {
if (!wordData) return undefined;
// 嘗試多種屬性名稱格式
const variations = [
propName, // 原始名稱
propName.toLowerCase(), // 小寫
propName.charAt(0).toUpperCase() + propName.slice(1), // 首字母大寫
propName.charAt(0).toLowerCase() + propName.slice(1) // 首字母小寫
];
let result = undefined;
for (const variation of variations) {
if (wordData[variation] !== undefined) {
result = wordData[variation];
break;
}
}
// 特殊處理同義詞 - 如果AI沒有提供使用預設同義詞
if (propName === 'synonyms') {
const synonyms = result || getSynonymsForWord(wordData?.word || '');
return Array.isArray(synonyms) ? synonyms : [];
}
// 特殊處理例句 - 如果AI沒有提供生成預設例句
if (propName === 'example') {
return result || `This is an example sentence using ${wordData?.word || 'the word'}.`;
}
// 特殊處理例句翻譯
if (propName === 'exampleTranslation') {
return result || `這是使用 ${wordData?.word || '該詞'} 的例句翻譯。`;
}
return result;
}
// 補充同義詞的本地函數
const getSynonymsForWord = (word: string): string[] => {
const synonymsMap: Record<string, string[]> = {
// 你的例句詞彙
'company': ['business', 'corporation', 'firm'],
'offered': ['provided', 'gave', 'presented'],
'bonus': ['reward', 'incentive', 'extra pay'],
'employees': ['workers', 'staff', 'personnel'],
'wanted': ['desired', 'wished for', 'sought'],
'benefits': ['advantages', 'perks', 'rewards'],
// 常見詞彙
'the': [],
'a': [],
'an': [],
'and': [],
'but': [],
'or': [],
'even': [],
'more': [],
// 其他詞彙可以繼續添加
};
return synonymsMap[word.toLowerCase()] || [];
};
// 將文字分割成單字,保留空格
const words = text.split(/(\s+|[.,!?;:])/g)
const handleWordClick = async (word: string, event: React.MouseEvent) => {
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
const wordAnalysis = analysis?.[cleanWord]
const rect = event.currentTarget.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const popupWidth = 320 // popup寬度 w-80 = 320px
const popupHeight = 400 // 估計popup高度
// 智能水平定位,適應不同屏幕尺寸
let x = rect.left + rect.width / 2
const actualPopupWidth = Math.min(popupWidth, viewportWidth - 32) // 實際popup寬度
const halfPopupWidth = actualPopupWidth / 2
const padding = 16 // 最小邊距
// 手機端特殊處理
if (viewportWidth <= 640) { // sm斷點
// 小屏幕時居中顯示,避免邊緣問題
x = viewportWidth / 2
} else {
// 大屏幕時智能調整位置
if (x + halfPopupWidth + padding > viewportWidth) {
x = viewportWidth - halfPopupWidth - padding
}
if (x - halfPopupWidth < padding) {
x = halfPopupWidth + padding
}
}
// 計算垂直位置
const spaceAbove = rect.top
const showBelow = spaceAbove < popupHeight
const position = {
x: x,
y: showBelow ? rect.bottom + 10 : rect.top - 10,
showBelow: showBelow
}
if (wordAnalysis) {
// 場景A有預存資料的詞彙
const isHighValue = getWordProperty(wordAnalysis, 'isHighValue')
if (isHighValue) {
// 高價值詞彙 → 直接免費顯示
setPopupPosition(position)
setSelectedWord(cleanWord)
onWordClick?.(cleanWord, wordAnalysis)
} else {
// 低價值詞彙 → 直接顯示(移除付費限制)
setPopupPosition(position)
setSelectedWord(cleanWord)
onWordClick?.(cleanWord, wordAnalysis)
}
} else {
// 場景B無預存資料的詞彙 → 即時調用 AI 查詢
await queryWordWithAI(cleanWord, position)
}
}
const handleCostConfirm = async () => {
if (!showCostConfirm) return
const confirmed = await onWordCostConfirm?.(showCostConfirm.word, showCostConfirm.cost)
if (confirmed) {
// 調用真實的單字查詢API
try {
const response = await fetch('http://localhost:5000/api/ai/query-word', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
word: showCostConfirm.word,
sentence: text,
analysisId: null // 可以傳入分析ID
})
})
if (response.ok) {
const result = await response.json()
if (result.success) {
setPopupPosition({...showCostConfirm.position, showBelow: false})
setSelectedWord(showCostConfirm.word)
onWordClick?.(showCostConfirm.word, result.data.analysis)
}
}
} catch (error) {
console.error('Query word API error:', error)
// 回退到現有資料
const wordAnalysis = analysis?.[showCostConfirm.word]
if (wordAnalysis) {
setPopupPosition({...showCostConfirm.position, showBelow: false})
setSelectedWord(showCostConfirm.word)
onWordClick?.(showCostConfirm.word, wordAnalysis)
}
}
}
setShowCostConfirm(null)
}
const closePopup = () => {
setSelectedWord(null)
}
const handleSaveWord = async () => {
if (!selectedWord || !analysis?.[selectedWord] || !onSaveWord) return
setIsSavingWord(true)
try {
await onSaveWord(selectedWord, analysis[selectedWord])
setSelectedWord(null) // 保存成功後關閉popup
} catch (error) {
console.error('Save word error:', error)
alert(`保存詞彙失敗: ${error instanceof Error ? error.message : '未知錯誤'}`)
} finally {
setIsSavingWord(false)
}
}
const queryWordWithAI = async (word: string, position: { x: number, y: number, showBelow: boolean }) => {
try {
console.log(`🤖 查詢單字: ${word}`)
const response = await fetch('http://localhost:5000/api/ai/query-word', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
word: word,
sentence: text,
analysisId: null
})
})
if (response.ok) {
const result = await response.json()
console.log('AI 查詢結果:', result)
if (result.success && result.data?.analysis) {
// 將新的分析資料通知父組件
onNewWordAnalysis?.(word, result.data.analysis)
// 顯示分析結果
setPopupPosition(position)
setSelectedWord(word)
onWordClick?.(word, result.data.analysis)
} else {
alert(`❌ 查詢 "${word}" 失敗,請稍後再試`)
}
} else {
throw new Error(`API 錯誤: ${response.status}`)
}
} catch (error) {
console.error('AI 查詢錯誤:', error)
alert(`❌ 查詢 "${word}" 時發生錯誤,請稍後再試`)
}
}
const getWordClass = (word: string) => {
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
const wordAnalysis = analysis?.[cleanWord]
const baseClass = "cursor-pointer transition-all duration-200 rounded relative mx-0.5 px-1 py-0.5"
if (wordAnalysis) {
// 有預存資料的詞彙
const isHighValue = getWordProperty(wordAnalysis, 'isHighValue')
const isPhrase = getWordProperty(wordAnalysis, 'isPhrase')
// 高價值片語(黃色系)
if (isHighValue && isPhrase) {
return `${baseClass} bg-yellow-100 border-2 border-yellow-400 hover:bg-yellow-200 hover:shadow-sm transform hover:-translate-y-0.5`
}
// 高價值單字(綠色系)
if (isHighValue && !isPhrase) {
return `${baseClass} bg-green-100 border-2 border-green-400 hover:bg-green-200 hover:shadow-sm transform hover:-translate-y-0.5`
}
// 普通單字(藍色系)
return `${baseClass} bg-blue-100 border-2 border-blue-300 hover:bg-blue-200 hover:shadow-sm`
} else {
// 無預存資料的詞彙(灰色虛線,表示需要即時查詢)
return `${baseClass} border-2 border-dashed border-gray-300 hover:border-gray-400 bg-gray-50 hover:bg-gray-100`
}
}
// 詞彙彈窗組件 - 使用 Portal 渲染
const VocabPopup = () => {
if (!selectedWord || !analysis?.[selectedWord] || !mounted) return null
return createPortal(
<>
{/* 背景遮罩 */}
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40"
onClick={closePopup}
/>
{/* 彈窗內容 - 完全脫離父級樣式 */}
<div
className="fixed z-50 bg-white rounded-xl shadow-lg w-96 max-w-md overflow-hidden"
style={{
left: `${popupPosition.x}px`,
top: `${popupPosition.y}px`,
transform: popupPosition.showBelow
? 'translate(-50%, 8px)'
: 'translate(-50%, calc(-100% - 8px))',
maxHeight: '85vh',
overflowY: 'auto'
}}
>
{/* 標題區 - 漸層背景 */}
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-5 border-b border-blue-200">
{/* 關閉按鈕 - 獨立一行 */}
<div className="flex justify-end mb-3">
<button
onClick={closePopup}
className="text-gray-400 hover:text-gray-600 w-6 h-6 rounded-full bg-white bg-opacity-80 hover:bg-opacity-100 transition-all flex items-center justify-center"
>
</button>
</div>
{/* 詞彙標題 */}
<div className="mb-3">
<h3 className="text-2xl font-bold text-gray-900">{getWordProperty(analysis[selectedWord], 'word')}</h3>
</div>
{/* 詞性、發音、播放按鈕、CEFR */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm bg-gray-100 text-gray-700 px-3 py-1 rounded-full">
{getWordProperty(analysis[selectedWord], 'partOfSpeech')}
</span>
<span className="text-base text-gray-600">{getWordProperty(analysis[selectedWord], 'pronunciation')}</span>
<button className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white hover:bg-blue-700 transition-colors">
<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>
{/* CEFR標籤 - 在播放按鈕那一行的最右邊 */}
<span className={`px-3 py-1 rounded-full text-sm font-medium border ${getCEFRColor(getWordProperty(analysis[selectedWord], 'difficultyLevel'))}`}>
{getWordProperty(analysis[selectedWord], 'difficultyLevel')}
</span>
</div>
</div>
{/* 內容區 - 彩色區塊設計 */}
<div className="p-4 space-y-4">
{/* 翻譯區塊 - 綠色 */}
<div className="bg-green-50 rounded-lg p-3 border border-green-200">
<h4 className="font-semibold text-green-900 mb-2 text-left text-sm"></h4>
<p className="text-green-800 font-medium text-left">{getWordProperty(analysis[selectedWord], 'translation')}</p>
</div>
{/* 定義區塊 - 灰色 */}
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
<h4 className="font-semibold text-gray-900 mb-2 text-left text-sm"></h4>
<p className="text-gray-700 text-left text-sm leading-relaxed">{getWordProperty(analysis[selectedWord], 'definition')}</p>
</div>
{/* 同義詞區塊 - 紫色 */}
{(() => {
const synonyms = getWordProperty(analysis[selectedWord], 'synonyms');
return synonyms && Array.isArray(synonyms) && synonyms.length > 0;
})() && (
<div className="bg-purple-50 rounded-lg p-3 border border-purple-200">
<h4 className="font-semibold text-purple-900 mb-2 text-left text-sm"></h4>
<div className="flex flex-wrap gap-1">
{getWordProperty(analysis[selectedWord], 'synonyms')?.slice(0, 4).map((synonym: string, idx: number) => (
<span key={idx} className="bg-white text-purple-700 px-2 py-1 rounded-full text-xs border border-purple-200 font-medium">
{synonym}
</span>
))}
</div>
</div>
)}
</div>
{/* 保存按鈕 - 底部平均延展 */}
{onSaveWord && (
<div className="p-4 pt-2">
<button
onClick={handleSaveWord}
disabled={isSavingWord}
className="w-full bg-primary text-white py-3 rounded-lg font-medium hover:bg-primary-hover transition-colors flex items-center justify-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
<span className="font-medium">{isSavingWord ? '保存中...' : '保存到詞卡'}</span>
</button>
</div>
)}
</div>
</>,
document.body
)
}
return (
<div className="relative">
{/* 文字內容 */}
<div className="text-lg leading-relaxed">
{words.map((word, index) => {
// 如果是空格或標點,直接顯示
if (word.trim() === '' || /^[.,!?;:\s]+$/.test(word)) {
return <span key={index}>{word}</span>
}
const className = getWordClass(word)
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '')
const wordAnalysis = analysis?.[cleanWord]
const isHighValue = wordAnalysis?.isHighValue
return (
<span
key={index}
className={`${className} ${isHighValue ? 'relative' : ''}`}
onClick={(e) => handleWordClick(word, e)}
>
{word}
{isHighValue && (
<span className="absolute -top-1 -right-1 text-xs"></span>
)}
</span>
)
})}
</div>
{/* 使用 Portal 渲染的詞彙彈窗 */}
<VocabPopup />
{/* 收費確認對話框 - 保留原有功能 */}
{showCostConfirm && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowCostConfirm(null)} />
<div
className="fixed z-20 bg-white border border-gray-300 rounded-lg shadow-lg p-4 w-72"
style={{
left: `${showCostConfirm.position.x}px`,
top: `${showCostConfirm.position.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">
{showCostConfirm.word}
</h3>
<button
onClick={() => setShowCostConfirm(null)}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<div className="text-orange-600 text-lg">💰</div>
<div>
<div className="text-sm font-medium text-orange-800">
</div>
<div className="text-sm text-orange-700 mt-1">
<strong>{showCostConfirm.cost} </strong> 使
</div>
<div className="text-sm text-orange-600 mt-1">
<strong>{remainingUsage}</strong>
</div>
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleCostConfirm}
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
>
</button>
<button
onClick={() => setShowCostConfirm(null)}
className="flex-1 bg-gray-200 text-gray-700 py-2 px-4 rounded-lg text-sm font-medium hover:bg-gray-300 transition-colors"
>
</button>
</div>
</div>
</div>
</>
)}
</div>
)
}