refactor: 重構ClickableTextV2使用React Portal避免CSS繼承問題

🎯 主要改進:
- 使用React Portal將詞彙彈窗渲染到document.body
- 完全脫離父級CSS繼承,解決字體大小和對齊問題
- 確保彈窗樣式與vocab-designs頁面的詞卡風格100%一致

🏗️ 技術架構:
- 導入createPortal和useEffect來管理Portal渲染
- 添加mounted state確保只在客戶端渲染Portal
- 統一getCEFRColor函數,支援完整的6個CEFR等級
- 保持原有API和功能完全不變

 解決的問題:
- 詞彙標題現在正確靠左對齊
- 按鈕文字大小恢復正常(不再受text-lg影響)
- 彈窗樣式與展示頁面完全一致
- 移除了不必要的樣式重置類別

📝 代碼清理:
- 移除舊的ClickableText.tsx組件
- 優化VocabPopup組件結構
- 更新組件頂部文檔說明Portal架構

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-09-21 01:00:01 +08:00
parent db952f94be
commit 421edd0589
2 changed files with 162 additions and 347 deletions

View File

@ -1,187 +0,0 @@
'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>
)
}

View File

@ -1,6 +1,33 @@
'use client'
import { useState } from 'react'
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 {
@ -43,8 +70,6 @@ interface ClickableTextProps {
export function ClickableTextV2({
text,
analysis,
highValueWords = [],
phrasesDetected = [],
onWordClick,
onWordCostConfirm,
onNewWordAnalysis,
@ -59,6 +84,25 @@ export function ClickableTextV2({
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'
}
}
// 輔助函數:兼容大小寫屬性名稱
const getWordProperty = (wordData: any, propName: string) => {
@ -102,7 +146,6 @@ export function ClickableTextV2({
// 計算垂直位置
const spaceAbove = rect.top
const spaceBelow = viewportHeight - rect.bottom
const showBelow = spaceAbove < popupHeight
const position = {
@ -154,12 +197,6 @@ export function ClickableTextV2({
if (response.ok) {
const result = await response.json()
if (result.success) {
// 更新分析資料
const newAnalysis = {
...analysis,
[showCostConfirm.word]: result.data.analysis
}
setPopupPosition({...showCostConfirm.position, showBelow: false})
setSelectedWord(showCostConfirm.word)
onWordClick?.(showCostConfirm.word, result.data.analysis)
@ -268,17 +305,121 @@ export function ClickableTextV2({
}
}
// 詞彙彈窗組件 - 使用 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>
{/* 同義詞區塊 - 紫色 */}
{getWordProperty(analysis[selectedWord], '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">
{/* 點擊區域遮罩 */}
{selectedWord && (
<div
className="fixed inset-0 z-10"
onClick={closePopup}
/>
)}
{/* 文字內容 */}
<div className="text-lg leading-relaxed">
{words.map((word, index) => {
@ -307,149 +448,10 @@ export function ClickableTextV2({
})}
</div>
{/* 詞卡風格詞彙彈窗 */}
{selectedWord && analysis?.[selectedWord] && (
<div
className="fixed z-50 bg-white rounded-xl shadow-lg border-0"
style={{
left: `${popupPosition.x}px`,
top: `${popupPosition.y}px`,
transform: popupPosition.showBelow
? 'translate(-50%, 8px)'
: 'translate(-50%, calc(-100% - 8px))',
width: 'min(384px, calc(100vw - 32px))', // 響應式寬度,稍微放大
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>
{/* 使用 Portal 渲染的詞彙彈窗 */}
<VocabPopup />
{/* 詞彙標題 */}
<div className="mb-3">
<h3 className="text-2xl font-bold text-gray-900 text-left">
{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 ${
(() => {
const difficulty = getWordProperty(analysis[selectedWord], 'difficultyLevel')
return difficulty === 'A1' || difficulty === 'A2' ? 'bg-green-100 text-green-700 border-green-200' :
difficulty === 'B1' || difficulty === 'B2' ? 'bg-yellow-100 text-yellow-700 border-yellow-200' :
difficulty === 'C1' ? 'bg-red-100 text-red-700 border-red-200' :
difficulty === 'C2' ? 'bg-purple-100 text-purple-700 border-purple-200' :
'bg-gray-100 text-gray-700 border-gray-200'
})()
}`}>
{getWordProperty(analysis[selectedWord], 'difficultyLevel')}
</span>
</div>
</div>
{/* 內容區 - 彩色區塊設計 */}
<div className="p-4 space-y-4">
{/* 重點學習標記 */}
{getWordProperty(analysis[selectedWord], 'isHighValue') && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<div className="flex items-center gap-2">
<div className="text-green-600">🎯</div>
<div className="text-sm font-medium text-green-800"></div>
<div className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full">
</div>
</div>
</div>
)}
{/* 翻譯區塊 - 綠色 */}
<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>
{/* 同義詞區塊 - 紫色 */}
{getWordProperty(analysis[selectedWord], '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"
>
{isSavingWord ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
<span>...</span>
</>
) : (
<>
<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"></span>
</>
)}
</button>
</div>
)}
</div>
)}
{/* 收費確認對話框 */}
{/* 收費確認對話框 - 保留原有功能 */}
{showCostConfirm && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowCostConfirm(null)} />