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:
鄭沛軒 2025-10-06 00:42:05 +08:00
parent b45d119d78
commit 262312b02a
3 changed files with 218 additions and 48 deletions

View File

@ -9,6 +9,7 @@ import { flashcardsService } from '@/lib/services/flashcards'
import { compareCEFRLevels, getLevelIndex, getTargetLearningRange } from '@/lib/utils/cefrUtils' import { compareCEFRLevels, getLevelIndex, getTargetLearningRange } from '@/lib/utils/cefrUtils'
import { BluePlayButton } from '@/components/shared/BluePlayButton' import { BluePlayButton } from '@/components/shared/BluePlayButton'
import { API_CONFIG } from '@/lib/config/api' import { API_CONFIG } from '@/lib/config/api'
import { calculateSmartPopupPosition, isMobileDevice, getElementPosition } from '@/lib/utils/popupPositioning'
import Link from 'next/link' import Link from 'next/link'
// 常數定義 // 常數定義
@ -34,6 +35,7 @@ interface IdiomPopup {
idiom: string; idiom: string;
analysis: any; analysis: any;
position: { x: number; y: number }; position: { x: number; y: number };
placement?: 'top' | 'bottom' | 'center';
} }
function GenerateContent() { function GenerateContent() {
@ -442,15 +444,36 @@ function GenerateContent() {
key={index} 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" 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) => { onClick={(e) => {
// 使用新的API格式直接使用idiom物件 // 檢測移動設備
setIdiomPopup({ const isMobile = isMobileDevice()
idiom: idiom.idiom,
analysis: idiom, if (isMobile) {
position: { // 移動設備:使用底部居中 modal
x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2, setIdiomPopup({
y: e.currentTarget.getBoundingClientRect().bottom + 10 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}`} title={`${idiom.idiom}: ${idiom.translation}`}
> >
@ -494,7 +517,7 @@ function GenerateContent() {
</div> </div>
)} )}
{/* 片語彈窗 */} {/* 片語彈窗 - 智能定位系統 */}
{idiomPopup && ( {idiomPopup && (
<> <>
<div <div
@ -502,14 +525,27 @@ function GenerateContent() {
onClick={() => setIdiomPopup(null)} onClick={() => setIdiomPopup(null)}
/> />
<div <div
className="fixed z-50 bg-white rounded-xl shadow-lg w-96 max-w-md overflow-hidden" className={`
style={{ fixed z-50 bg-white rounded-xl shadow-lg overflow-hidden
left: `${idiomPopup.position.x}px`, ${idiomPopup.placement === 'center' || isMobileDevice()
top: `${idiomPopup.position.y}px`, ? 'w-full max-w-md mx-4 top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2'
transform: 'translate(-50%, 8px)', : 'w-96 max-w-md'
maxHeight: '85vh', }
overflowY: 'auto' `}
}} 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="bg-gradient-to-br from-blue-50 to-indigo-50 p-5 border-b border-blue-200">
<div className="flex justify-end mb-3"> <div className="flex justify-end mb-3">

View File

@ -1,6 +1,5 @@
import React, { useState } from 'react' import React from 'react'
import { Modal } from '@/components/ui/Modal' import { Modal } from '@/components/ui/Modal'
import { ContentBlock } from '@/components/shared/ContentBlock'
import { getCEFRColor } from '@/lib/utils/flashcardUtils' import { getCEFRColor } from '@/lib/utils/flashcardUtils'
import { BluePlayButton } from '@/components/shared/BluePlayButton' import { BluePlayButton } from '@/components/shared/BluePlayButton'
import { useWordAnalysis } from '@/hooks/word/useWordAnalysis' import { useWordAnalysis } from '@/hooks/word/useWordAnalysis'
@ -39,20 +38,18 @@ export const WordPopup: React.FC<WordPopupProps> = ({
return ( return (
<Modal isOpen={isOpen} onClose={onClose} size="md"> <Modal isOpen={isOpen} onClose={onClose} size="md">
{/* Header */} {/* 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"> <div className="mb-3">
<h3 className="text-xl sm:text-2xl font-bold text-gray-900 break-words"> <h3 className="text-2xl font-bold text-gray-900">{getWordProperty(wordAnalysis, 'word')}</h3>
{getWordProperty(wordAnalysis, 'word')}
</h3>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3"> <div className="flex items-center 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"> <span className="text-sm bg-gray-100 text-gray-700 px-3 py-1 rounded-full">
{getWordProperty(wordAnalysis, 'partOfSpeech')} {getWordProperty(wordAnalysis, 'partOfSpeech')}
</span> </span>
<div className="flex items-center gap-2"> <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')} {getWordProperty(wordAnalysis, 'pronunciation')}
</span> </span>
<BluePlayButton <BluePlayButton
@ -71,44 +68,42 @@ export const WordPopup: React.FC<WordPopupProps> = ({
</div> </div>
{/* Content */} {/* 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 */} {/* 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"> <p className="text-green-800 font-medium text-left">
{getWordProperty(wordAnalysis, 'translation')} {getWordProperty(wordAnalysis, 'translation')}
</p> </p>
</ContentBlock> </div>
{/* Definition */} {/* Definition */}
<ContentBlock title="英文定義" variant="gray"> <div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
<p className="text-gray-700 text-left text-sm leading-relaxed"> <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')} {getWordProperty(wordAnalysis, 'definition')}
</p> </p>
</ContentBlock> </div>
{/* Example */} {/* Example */}
{(() => { {getWordProperty(wordAnalysis, 'example') && (
const example = getWordProperty(wordAnalysis, 'example') <div className="bg-blue-50 rounded-lg p-3 border border-blue-200">
return example && example !== 'null' && example !== 'undefined' <h4 className="font-semibold text-blue-900 mb-2 text-left text-sm"></h4>
})() && (
<ContentBlock title="例句" variant="blue">
<div className="space-y-2"> <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')}" "{getWordProperty(wordAnalysis, 'example')}"
</p> </p>
<p className="text-blue-700 text-left text-sm"> <p className="text-blue-700 text-left">
{getWordProperty(wordAnalysis, 'exampleTranslation')} {getWordProperty(wordAnalysis, 'exampleTranslation')}
</p> </p>
</div> </div>
</ContentBlock> </div>
)} )}
{/* Synonyms */} {/* Synonyms */}
{(() => { {getWordProperty(wordAnalysis, 'synonyms')?.length > 0 && (
const synonyms = getWordProperty(wordAnalysis, 'synonyms') <div className="bg-purple-50 rounded-lg p-3 border border-purple-200">
return synonyms && Array.isArray(synonyms) && synonyms.length > 0 <h4 className="font-semibold text-purple-900 mb-2 text-left text-sm"></h4>
})() && (
<ContentBlock title="同義詞" variant="purple">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{getWordProperty(wordAnalysis, 'synonyms')?.map((synonym: string, index: number) => ( {getWordProperty(wordAnalysis, 'synonyms')?.map((synonym: string, index: number) => (
<span <span
@ -119,13 +114,13 @@ export const WordPopup: React.FC<WordPopupProps> = ({
</span> </span>
))} ))}
</div> </div>
</ContentBlock> </div>
)} )}
</div> </div>
{/* Save Button */} {/* Save Button */}
{onSaveWord && ( {onSaveWord && (
<div className="p-3 sm:p-4 pt-2"> <div className="p-4 pt-2">
<button <button
onClick={handleSaveWord} onClick={handleSaveWord}
disabled={isSaving} disabled={isSaving}

View File

@ -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'
})
}
}