'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 highValueWords?: string[] // 高價值詞彙列表 phrasesDetected?: Array<{ phrase: string words: string[] colorCode: string }> onWordClick?: (word: string, analysis: WordAnalysis) => void onWordCostConfirm?: (word: string, cost: number) => Promise // 收費確認 onSaveWord?: (word: string, analysis: WordAnalysis) => Promise // 保存詞彙回調 remainingUsage?: number // 剩餘使用次數 } // Popup 尺寸常數 const POPUP_CONFIG = { WIDTH: 320, // w-96 = 384px, 但實際使用320px HEIGHT: 400, // 估計彈窗高度 PADDING: 16, // 最小邊距 MOBILE_BREAKPOINT: 640 // sm斷點 } as const export function ClickableTextV2({ text, analysis, onWordClick, onWordCostConfirm, onSaveWord, remainingUsage = 5 }: ClickableTextProps) { const [selectedWord, setSelectedWord] = useState(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) }, []) // Debug: 檢查接收到的analysis prop useEffect(() => { if (analysis) { console.log('🔍 ClickableTextV2接收到analysis:', analysis); console.log('🔍 analysis的keys:', Object.keys(analysis)); } }, [analysis]) // 獲取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; // 不提供預設例句,只使用AI/後端資料 } // 特殊處理例句翻譯 if (propName === 'exampleTranslation') { return result; // 不提供預設翻譯,只使用AI/後端資料 } return result; } // 統一的詞彙查找函數 - 處理大小寫不匹配問題 const findWordAnalysis = (word: string) => { const cleanWord = word.toLowerCase().replace(/[.,!?;:]/g, '') // 嘗試多種格式匹配API回傳的keys return analysis?.[cleanWord] || // 小寫 analysis?.[word] || // 原始 analysis?.[word.toLowerCase()] || // 確保小寫 analysis?.[word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()] || // 首字母大寫 analysis?.[word.toUpperCase()] || // 全大寫 null } // 補充同義詞的本地函數 const getSynonymsForWord = (word: string): string[] => { const synonymsMap: Record = { // 你的例句詞彙 '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 = findWordAnalysis(word) const rect = event.currentTarget.getBoundingClientRect() const viewportWidth = window.innerWidth // 智能水平定位,適應不同屏幕尺寸 let x = rect.left + rect.width / 2 const actualPopupWidth = Math.min(POPUP_CONFIG.WIDTH, viewportWidth - 32) // 實際popup寬度 const halfPopupWidth = actualPopupWidth / 2 // 手機端特殊處理 if (viewportWidth <= POPUP_CONFIG.MOBILE_BREAKPOINT) { // sm斷點 // 小屏幕時居中顯示,避免邊緣問題 x = viewportWidth / 2 } else { // 大屏幕時智能調整位置 if (x + halfPopupWidth + POPUP_CONFIG.PADDING > viewportWidth) { x = viewportWidth - halfPopupWidth - POPUP_CONFIG.PADDING } if (x - halfPopupWidth < POPUP_CONFIG.PADDING) { x = halfPopupWidth + POPUP_CONFIG.PADDING } } // 計算垂直位置 const spaceAbove = rect.top const showBelow = spaceAbove < POPUP_CONFIG.HEIGHT 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:詞彙不在analysis中,直接顯示空彈窗或提示 // 因為analyze-sentence應該已經包含所有詞彙,這種情況很少發生 setPopupPosition(position) setSelectedWord(cleanWord) onWordClick?.(cleanWord, { word: cleanWord, translation: '查詢中...', definition: '正在載入定義...', partOfSpeech: 'unknown', pronunciation: `/${cleanWord}/`, synonyms: [], isPhrase: false, isHighValue: false, learningPriority: 'low', difficultyLevel: 'A1' }) } } const handleCostConfirm = async () => { if (!showCostConfirm) return const confirmed = await onWordCostConfirm?.(showCostConfirm.word, showCostConfirm.cost) if (confirmed) { // 由於analyze-sentence已提供完整資料,不再需要額外API調用 // 使用智能查找尋找詞彙資料 const wordAnalysis = findWordAnalysis(showCostConfirm.word) if (wordAnalysis) { setPopupPosition({...showCostConfirm.position, showBelow: false}) setSelectedWord(showCostConfirm.word) onWordClick?.(showCostConfirm.word, wordAnalysis) } else { // 極少數情況:詞彙真的不在analysis中 setPopupPosition({...showCostConfirm.position, showBelow: false}) setSelectedWord(showCostConfirm.word) onWordClick?.(showCostConfirm.word, { word: showCostConfirm.word, translation: '此詞彙未在分析中', definition: '請重新分析句子以獲取完整資訊', partOfSpeech: 'unknown', pronunciation: `/${showCostConfirm.word}/`, synonyms: [], isPhrase: false, isHighValue: false, learningPriority: 'low', difficultyLevel: 'A1' }) } } 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 getWordClass = (word: string) => { const wordAnalysis = findWordAnalysis(word) 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( <> {/* 背景遮罩 */}
{/* 彈窗內容 - 完全脫離父級樣式 */}
{/* 標題區 - 漸層背景 */}
{/* 關閉按鈕 - 獨立一行 */}
{/* 詞彙標題 */}

{getWordProperty(analysis[selectedWord], 'word')}

{/* 詞性、發音、播放按鈕、CEFR */}
{getWordProperty(analysis[selectedWord], 'partOfSpeech')} {getWordProperty(analysis[selectedWord], 'pronunciation')}
{/* CEFR標籤 - 在播放按鈕那一行的最右邊 */} {getWordProperty(analysis[selectedWord], 'difficultyLevel')}
{/* 內容區 - 彩色區塊設計 */}
{/* 翻譯區塊 - 綠色 */}

中文翻譯

{getWordProperty(analysis[selectedWord], 'translation')}

{/* 定義區塊 - 灰色 */}

英文定義

{getWordProperty(analysis[selectedWord], 'definition')}

{/* 同義詞區塊 - 紫色 */} {(() => { const synonyms = getWordProperty(analysis[selectedWord], 'synonyms'); return synonyms && Array.isArray(synonyms) && synonyms.length > 0; })() && (

同義詞

{getWordProperty(analysis[selectedWord], 'synonyms')?.slice(0, 4).map((synonym: string, idx: number) => ( {synonym} ))}
)} {/* 例句區塊 - 藍色 */} {(() => { const example = getWordProperty(analysis[selectedWord], 'example'); return example && example !== 'null' && example !== 'undefined'; })() && (

例句

"{getWordProperty(analysis[selectedWord], 'example')}"

{getWordProperty(analysis[selectedWord], 'exampleTranslation')}

)}
{/* 保存按鈕 - 底部平均延展 */} {onSaveWord && (
)}
, document.body ) } return (
{/* 文字內容 */}
{words.map((word, index) => { // 如果是空格或標點,直接顯示 if (word.trim() === '' || /^[.,!?;:\s]+$/.test(word)) { return {word} } const className = getWordClass(word) const wordAnalysis = findWordAnalysis(word) const isHighValue = getWordProperty(wordAnalysis, 'isHighValue') return ( handleWordClick(word, e)} > {word} {isHighValue && ( )} ) })}
{/* 使用 Portal 渲染的詞彙彈窗 */} {/* 收費確認對話框 - 保留原有功能 */} {showCostConfirm && ( <>
setShowCostConfirm(null)} />

{showCostConfirm.word}

💰
低價值詞彙(需消耗額度)
此查詢將消耗 {showCostConfirm.cost} 次 使用額度
剩餘額度:{remainingUsage}
)}
) }