Spaces:
Running
Running
feat: add 'Search' option to selection popup so users can search highlighted text directly in the database
b1e4620 | import React, { useEffect, useRef, useState, useCallback } from 'react'; | |
| import { Copy, Sparkles, CheckCircle, Search } from 'lucide-react'; | |
| import { useAIStore } from '../../stores/aiStore'; | |
| import { useReaderStore, useUIStore } from '../../stores/appStore'; | |
| import { useSearchStore } from '../../stores/searchStore'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| interface PopupPos { x: number; y: number } | |
| const SelectionPopup: React.FC = () => { | |
| const [pos, setPos] = useState<PopupPos | null>(null); | |
| const [selected, setSelected] = useState(''); | |
| const [copied, setCopied] = useState(false); | |
| const popupRef = useRef<HTMLDivElement>(null); | |
| const { askAI } = useAIStore(); | |
| const { currentVolume, currentPage, volumeTitle } = useReaderStore(); | |
| const { setNavOpen, setActiveTab } = useUIStore(); | |
| const { setQuery, performSearch } = useSearchStore(); | |
| const hide = useCallback(() => { | |
| setPos(null); | |
| setSelected(''); | |
| setCopied(false); | |
| }, []); | |
| useEffect(() => { | |
| const onPointerUp = (e: MouseEvent | TouchEvent) => { | |
| // Don't interfere if the pointer was released inside the popup — | |
| // clicking the copy button clears the selection, which would | |
| // trigger hide() and prevent the CheckCircle confirmation from showing. | |
| if (popupRef.current?.contains(e.target as Node)) return; | |
| // Small delay — let browser finalise selection rect | |
| setTimeout(() => { | |
| const sel = window.getSelection(); | |
| const text = sel?.toString().trim() ?? ''; | |
| if (!text || text.length < 2) { hide(); return; } | |
| // Only fire inside .reader-content elements | |
| const anchor = sel?.anchorNode?.parentElement; | |
| if (!anchor?.closest('.reader-content')) { hide(); return; } | |
| const range = sel!.getRangeAt(0); | |
| const rect = range.getBoundingClientRect(); | |
| setSelected(text); | |
| // getBoundingClientRect() = viewport-relative → position:fixed also viewport-relative | |
| // No window.scrollX/Y needed since we're using fixed positioning | |
| setPos({ | |
| x: rect.left + rect.width / 2, | |
| y: rect.top - 8, // 8px gap above selection | |
| }); | |
| setCopied(false); | |
| }, 10); | |
| }; | |
| // Click outside popup → close | |
| const onMouseDown = (e: MouseEvent) => { | |
| if (popupRef.current && !popupRef.current.contains(e.target as Node)) { | |
| hide(); | |
| } | |
| }; | |
| document.addEventListener('mouseup', onPointerUp); | |
| document.addEventListener('touchend', onPointerUp); | |
| document.addEventListener('mousedown', onMouseDown); | |
| return () => { | |
| document.removeEventListener('mouseup', onPointerUp); | |
| document.removeEventListener('touchend', onPointerUp); | |
| document.removeEventListener('mousedown', onMouseDown); | |
| }; | |
| }, [hide]); | |
| const handleCopy = async () => { | |
| try { | |
| await navigator.clipboard.writeText(selected); | |
| } catch { | |
| // Safari fallback | |
| const ta = document.createElement('textarea'); | |
| ta.value = selected; | |
| document.body.appendChild(ta); | |
| ta.select(); | |
| document.execCommand('copy'); | |
| document.body.removeChild(ta); | |
| } | |
| setCopied(true); | |
| setTimeout(hide, 900); | |
| }; | |
| const handleAsk = () => { | |
| // askAI(question, context) — sets isOpen: true automatically | |
| // Include volume/page metadata so the LLM can cite the actual source | |
| const title = volumeTitle ? ` (${volumeTitle})` : ''; | |
| const context = `เล่มที่ ${currentVolume} หน้าที่ ${currentPage}${title}\n\n${selected}`; | |
| askAI( | |
| `วิเคราะห์ข้อความต่อไปนี้จากพระไตรปิฎก:\n\n"${selected}"`, | |
| context, | |
| ); | |
| hide(); | |
| }; | |
| const handleSearch = () => { | |
| setQuery(selected); | |
| setActiveTab('search'); | |
| setNavOpen(true); | |
| setTimeout(() => { | |
| performSearch(); | |
| }, 50); | |
| hide(); | |
| }; | |
| return ( | |
| <AnimatePresence> | |
| {pos && ( | |
| <motion.div | |
| ref={popupRef} | |
| key="sel-popup" | |
| initial={{ opacity: 0, scale: 0.88, y: 4 }} | |
| animate={{ opacity: 1, scale: 1, y: 0 }} | |
| exit={{ opacity: 0, scale: 0.88, y: 4 }} | |
| transition={{ duration: 0.14, ease: 'easeOut' }} | |
| style={{ | |
| position: 'fixed', | |
| left: pos.x, | |
| top: pos.y, | |
| transform: 'translate(-50%, -100%)', | |
| zIndex: 9999, | |
| }} | |
| // prevent the mousedown-outside handler from closing us | |
| onMouseDown={e => e.preventDefault()} | |
| className="flex items-center gap-1 px-2 py-1.5 rounded-xl | |
| bg-[#1a1a2e] border border-[#3a3a5e] shadow-2xl select-none" | |
| > | |
| {/* ── Copy ─────────────────────────────────────────────── */} | |
| <button | |
| onClick={handleCopy} | |
| title="คัดลอกข้อความ (Ctrl+C)" | |
| className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium | |
| text-[#e0e0e0] hover:bg-white/10 hover:text-white transition-colors" | |
| > | |
| {copied | |
| ? <CheckCircle size={14} className="text-green-400" /> | |
| : <Copy size={14} /> | |
| } | |
| <span className="whitespace-nowrap">{copied ? 'คัดลอกแล้ว' : 'คัดลอก'}</span> | |
| </button> | |
| {/* divider */} | |
| <div className="w-px h-5 bg-[#3a3a5e] flex-shrink-0" /> | |
| {/* ── Search ────────────────────────────────────────────── */} | |
| <button | |
| onClick={handleSearch} | |
| title="ค้นหาข้อความนี้ในพระไตรปิฎก" | |
| className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium | |
| text-[#c8860a] hover:bg-[#c8860a]/15 hover:text-[#e0980f] transition-colors" | |
| > | |
| <Search size={14} /> | |
| <span className="whitespace-nowrap">ค้นหาคำ</span> | |
| </button> | |
| {/* divider */} | |
| <div className="w-px h-5 bg-[#3a3a5e] flex-shrink-0" /> | |
| {/* ── AI Analyse ───────────────────────────────────────── */} | |
| <button | |
| onClick={handleAsk} | |
| title="ส่งให้ AI วิเคราะห์" | |
| className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium | |
| text-[#0d9488] hover:bg-[#0d9488]/15 hover:text-[#0f766e] transition-colors" | |
| > | |
| <Sparkles size={14} /> | |
| <span className="whitespace-nowrap">วิเคราะห์ AI</span> | |
| </button> | |
| {/* tail arrow pointing down */} | |
| <div | |
| className="pointer-events-none absolute left-1/2 -translate-x-1/2 -bottom-[6px] | |
| w-3 h-3 bg-[#1a1a2e] border-r border-b border-[#3a3a5e] rotate-45" | |
| /> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| ); | |
| }; | |
| export default SelectionPopup; | |