feat: 實現慣用語彈窗智能定位 + 簡化 WordPopup 組件
## 慣用語彈窗智能定位系統 - ✅ 創建智能定位工具 (popupPositioning.ts) - ✅ 自動檢測可用空間,防止彈窗被底部遮蔽 - ✅ 智能選擇彈出方向 (上方/下方/居中) - ✅ 響應式適配:桌面智能定位 + 手機底部modal - ✅ 修正底部慣用語點擊體驗問題 ## WordPopup 組件簡化 - 🔧 移除未使用的 useState import - 🔧 簡化過度的響應式設計 (移除多處 sm: 斷點) - 🔧 替換 ContentBlock 為簡單 div 結構 - 🔧 簡化條件渲染邏輯 (IIFE → 簡單 &&) - ✅ 統一字體大小,與慣用語彈窗保持一致 ## 技術改進 - 📱 設備檢測:自動適配移動/桌面體驗 - 🎯 智能定位:邊界檢測 + 動態位置計算 - 🧹 代碼簡化:減少複雜度,提升維護性 - 🎨 設計統一:兩種彈窗風格對齊 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b45d119d78
commit
262312b02a
|
|
@ -9,6 +9,7 @@ import { flashcardsService } from '@/lib/services/flashcards'
|
|||
import { compareCEFRLevels, getLevelIndex, getTargetLearningRange } from '@/lib/utils/cefrUtils'
|
||||
import { BluePlayButton } from '@/components/shared/BluePlayButton'
|
||||
import { API_CONFIG } from '@/lib/config/api'
|
||||
import { calculateSmartPopupPosition, isMobileDevice, getElementPosition } from '@/lib/utils/popupPositioning'
|
||||
import Link from 'next/link'
|
||||
|
||||
// 常數定義
|
||||
|
|
@ -34,6 +35,7 @@ interface IdiomPopup {
|
|||
idiom: string;
|
||||
analysis: any;
|
||||
position: { x: number; y: number };
|
||||
placement?: 'top' | 'bottom' | 'center';
|
||||
}
|
||||
|
||||
function GenerateContent() {
|
||||
|
|
@ -442,15 +444,36 @@ function GenerateContent() {
|
|||
key={index}
|
||||
className="cursor-pointer transition-all duration-200 rounded-lg relative mx-0.5 px-1 py-0.5 inline-flex items-center gap-1 bg-blue-50 border border-blue-200 hover:bg-blue-100 hover:shadow-lg transform hover:-translate-y-0.5 text-blue-700 font-medium"
|
||||
onClick={(e) => {
|
||||
// 使用新的API格式,直接使用idiom物件
|
||||
setIdiomPopup({
|
||||
idiom: idiom.idiom,
|
||||
analysis: idiom,
|
||||
position: {
|
||||
x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2,
|
||||
y: e.currentTarget.getBoundingClientRect().bottom + 10
|
||||
}
|
||||
})
|
||||
// 檢測移動設備
|
||||
const isMobile = isMobileDevice()
|
||||
|
||||
if (isMobile) {
|
||||
// 移動設備:使用底部居中 modal
|
||||
setIdiomPopup({
|
||||
idiom: idiom.idiom,
|
||||
analysis: idiom,
|
||||
position: { x: 0, y: 0 }, // 移動版不使用計算位置
|
||||
placement: 'center'
|
||||
})
|
||||
} else {
|
||||
// 桌面設備:使用智能定位系統
|
||||
const elementPosition = getElementPosition(e.currentTarget)
|
||||
const smartPosition = calculateSmartPopupPosition(
|
||||
elementPosition,
|
||||
384, // w-96 = 384px
|
||||
400 // 預估高度
|
||||
)
|
||||
|
||||
setIdiomPopup({
|
||||
idiom: idiom.idiom,
|
||||
analysis: idiom,
|
||||
position: {
|
||||
x: smartPosition.x,
|
||||
y: smartPosition.y
|
||||
},
|
||||
placement: smartPosition.placement
|
||||
})
|
||||
}
|
||||
}}
|
||||
title={`${idiom.idiom}: ${idiom.translation}`}
|
||||
>
|
||||
|
|
@ -494,7 +517,7 @@ function GenerateContent() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 片語彈窗 */}
|
||||
{/* 片語彈窗 - 智能定位系統 */}
|
||||
{idiomPopup && (
|
||||
<>
|
||||
<div
|
||||
|
|
@ -502,14 +525,27 @@ function GenerateContent() {
|
|||
onClick={() => setIdiomPopup(null)}
|
||||
/>
|
||||
<div
|
||||
className="fixed z-50 bg-white rounded-xl shadow-lg w-96 max-w-md overflow-hidden"
|
||||
style={{
|
||||
left: `${idiomPopup.position.x}px`,
|
||||
top: `${idiomPopup.position.y}px`,
|
||||
transform: 'translate(-50%, 8px)',
|
||||
maxHeight: '85vh',
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
className={`
|
||||
fixed z-50 bg-white rounded-xl shadow-lg overflow-hidden
|
||||
${idiomPopup.placement === 'center' || isMobileDevice()
|
||||
? 'w-full max-w-md mx-4 top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2'
|
||||
: 'w-96 max-w-md'
|
||||
}
|
||||
`}
|
||||
style={
|
||||
idiomPopup.placement !== 'center' && !isMobileDevice()
|
||||
? {
|
||||
left: `${idiomPopup.position.x}px`,
|
||||
top: `${idiomPopup.position.y}px`,
|
||||
transform: 'translate(-50%, 0)',
|
||||
maxHeight: '85vh',
|
||||
overflowY: 'auto'
|
||||
}
|
||||
: {
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React, { useState } from 'react'
|
||||
import React from 'react'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { ContentBlock } from '@/components/shared/ContentBlock'
|
||||
import { getCEFRColor } from '@/lib/utils/flashcardUtils'
|
||||
import { BluePlayButton } from '@/components/shared/BluePlayButton'
|
||||
import { useWordAnalysis } from '@/hooks/word/useWordAnalysis'
|
||||
|
|
@ -39,20 +38,18 @@ export const WordPopup: React.FC<WordPopupProps> = ({
|
|||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="md">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-4 sm:p-5 border-b border-blue-200">
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-5 border-b border-blue-200">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-xl sm:text-2xl font-bold text-gray-900 break-words">
|
||||
{getWordProperty(wordAnalysis, 'word')}
|
||||
</h3>
|
||||
<h3 className="text-2xl font-bold text-gray-900">{getWordProperty(wordAnalysis, 'word')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3">
|
||||
<span className="text-xs sm:text-sm bg-gray-100 text-gray-700 px-2 sm:px-3 py-1 rounded-full w-fit">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm bg-gray-100 text-gray-700 px-3 py-1 rounded-full">
|
||||
{getWordProperty(wordAnalysis, 'partOfSpeech')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm sm:text-base text-gray-600 break-all">
|
||||
<span className="text-base text-gray-600">
|
||||
{getWordProperty(wordAnalysis, 'pronunciation')}
|
||||
</span>
|
||||
<BluePlayButton
|
||||
|
|
@ -71,44 +68,42 @@ export const WordPopup: React.FC<WordPopupProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-3 sm:p-4 space-y-3 sm:space-y-4 max-h-96 overflow-y-auto">
|
||||
<div className="p-4 space-y-4 max-h-96 overflow-y-auto">
|
||||
{/* Translation */}
|
||||
<ContentBlock title="中文翻譯" variant="green">
|
||||
<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(wordAnalysis, 'translation')}
|
||||
</p>
|
||||
</ContentBlock>
|
||||
</div>
|
||||
|
||||
{/* Definition */}
|
||||
<ContentBlock title="英文定義" variant="gray">
|
||||
<p className="text-gray-700 text-left text-sm leading-relaxed">
|
||||
<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 leading-relaxed">
|
||||
{getWordProperty(wordAnalysis, 'definition')}
|
||||
</p>
|
||||
</ContentBlock>
|
||||
</div>
|
||||
|
||||
{/* Example */}
|
||||
{(() => {
|
||||
const example = getWordProperty(wordAnalysis, 'example')
|
||||
return example && example !== 'null' && example !== 'undefined'
|
||||
})() && (
|
||||
<ContentBlock title="例句" variant="blue">
|
||||
{getWordProperty(wordAnalysis, 'example') && (
|
||||
<div className="bg-blue-50 rounded-lg p-3 border border-blue-200">
|
||||
<h4 className="font-semibold text-blue-900 mb-2 text-left text-sm">例句</h4>
|
||||
<div className="space-y-2">
|
||||
<p className="text-blue-800 text-left text-sm italic">
|
||||
<p className="text-blue-800 text-left italic">
|
||||
"{getWordProperty(wordAnalysis, 'example')}"
|
||||
</p>
|
||||
<p className="text-blue-700 text-left text-sm">
|
||||
<p className="text-blue-700 text-left">
|
||||
{getWordProperty(wordAnalysis, 'exampleTranslation')}
|
||||
</p>
|
||||
</div>
|
||||
</ContentBlock>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Synonyms */}
|
||||
{(() => {
|
||||
const synonyms = getWordProperty(wordAnalysis, 'synonyms')
|
||||
return synonyms && Array.isArray(synonyms) && synonyms.length > 0
|
||||
})() && (
|
||||
<ContentBlock title="同義詞" variant="purple">
|
||||
{getWordProperty(wordAnalysis, '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-2">
|
||||
{getWordProperty(wordAnalysis, 'synonyms')?.map((synonym: string, index: number) => (
|
||||
<span
|
||||
|
|
@ -119,13 +114,13 @@ export const WordPopup: React.FC<WordPopupProps> = ({
|
|||
</span>
|
||||
))}
|
||||
</div>
|
||||
</ContentBlock>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
{onSaveWord && (
|
||||
<div className="p-3 sm:p-4 pt-2">
|
||||
<div className="p-4 pt-2">
|
||||
<button
|
||||
onClick={handleSaveWord}
|
||||
disabled={isSaving}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* 智能 Popup 定位工具
|
||||
* 自動檢測可用空間,確保 popup 始終完全可見
|
||||
*/
|
||||
|
||||
export interface PopupPosition {
|
||||
x: number
|
||||
y: number
|
||||
placement: 'top' | 'bottom' | 'center'
|
||||
}
|
||||
|
||||
export interface ClickPosition {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
const POPUP_MARGIN = 16 // 與視窗邊緣的最小距離
|
||||
const POPUP_ARROW_OFFSET = 10 // 箭頭偏移量
|
||||
|
||||
/**
|
||||
* 計算智能 popup 位置
|
||||
* @param clickRect 點擊元素的位置信息
|
||||
* @param popupWidth popup 寬度 (預設 384px = w-96)
|
||||
* @param popupHeight popup 高度 (預計值)
|
||||
* @returns 計算後的最佳位置
|
||||
*/
|
||||
export function calculateSmartPopupPosition(
|
||||
clickRect: ClickPosition,
|
||||
popupWidth: number = 384,
|
||||
popupHeight: number = 400
|
||||
): PopupPosition {
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
const scrollY = window.scrollY
|
||||
|
||||
// 計算點擊元素的中心點
|
||||
const clickCenterX = clickRect.x + clickRect.width / 2
|
||||
const clickCenterY = clickRect.y + clickRect.height / 2 + scrollY
|
||||
|
||||
// 檢測各方向可用空間
|
||||
const spaceAbove = clickRect.y - POPUP_MARGIN
|
||||
const spaceBelow = viewportHeight - (clickRect.y + clickRect.height) - POPUP_MARGIN
|
||||
const spaceLeft = clickRect.x - POPUP_MARGIN
|
||||
const spaceRight = viewportWidth - (clickRect.x + clickRect.width) - POPUP_MARGIN
|
||||
|
||||
// 判斷最佳垂直位置
|
||||
let placement: 'top' | 'bottom' | 'center' = 'bottom'
|
||||
let y: number
|
||||
|
||||
if (spaceBelow >= popupHeight) {
|
||||
// 底部有足夠空間
|
||||
placement = 'bottom'
|
||||
y = clickRect.y + clickRect.height + POPUP_ARROW_OFFSET + scrollY
|
||||
} else if (spaceAbove >= popupHeight) {
|
||||
// 頂部有足夠空間
|
||||
placement = 'top'
|
||||
y = clickRect.y - popupHeight - POPUP_ARROW_OFFSET + scrollY
|
||||
} else {
|
||||
// 兩邊都沒有足夠空間,使用居中模式
|
||||
placement = 'center'
|
||||
y = Math.max(
|
||||
POPUP_MARGIN + scrollY,
|
||||
Math.min(
|
||||
viewportHeight - popupHeight - POPUP_MARGIN + scrollY,
|
||||
clickCenterY - popupHeight / 2
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 計算水平位置 (始終嘗試居中對齊點擊元素)
|
||||
let x = clickCenterX - popupWidth / 2
|
||||
|
||||
// 確保不超出視窗邊界
|
||||
x = Math.max(POPUP_MARGIN, Math.min(x, viewportWidth - popupWidth - POPUP_MARGIN))
|
||||
|
||||
return { x, y, placement }
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢測是否為移動設備
|
||||
* 移動設備使用底部 modal,桌面使用智能定位
|
||||
*/
|
||||
export function isMobileDevice(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
return window.innerWidth <= 768
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取元素的位置信息
|
||||
* @param element DOM 元素
|
||||
* @returns 位置信息
|
||||
*/
|
||||
export function getElementPosition(element: HTMLElement): ClickPosition {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 創建平滑滾動效果,確保 popup 可見
|
||||
* @param targetY popup 的 Y 位置
|
||||
* @param popupHeight popup 高度
|
||||
*/
|
||||
export function ensurePopupVisible(targetY: number, popupHeight: number): void {
|
||||
const viewportHeight = window.innerHeight
|
||||
const scrollY = window.scrollY
|
||||
|
||||
// 計算 popup 的頂部和底部位置(相對於視窗)
|
||||
const popupTop = targetY - scrollY
|
||||
const popupBottom = popupTop + popupHeight
|
||||
|
||||
let needsScroll = false
|
||||
let scrollTarget = scrollY
|
||||
|
||||
// 如果 popup 頂部被遮蔽
|
||||
if (popupTop < POPUP_MARGIN) {
|
||||
scrollTarget = targetY - POPUP_MARGIN
|
||||
needsScroll = true
|
||||
}
|
||||
// 如果 popup 底部被遮蔽
|
||||
else if (popupBottom > viewportHeight - POPUP_MARGIN) {
|
||||
scrollTarget = targetY + popupHeight - viewportHeight + POPUP_MARGIN
|
||||
needsScroll = true
|
||||
}
|
||||
|
||||
// 執行平滑滾動
|
||||
if (needsScroll) {
|
||||
window.scrollTo({
|
||||
top: scrollTarget,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue