tipitaka / webapp /tipitaka-web /src /components /reader /SelectionPopup.tsx
dhammawatthumpra's picture
feat: add 'Search' option to selection popup so users can search highlighted text directly in the database
b1e4620
Raw
History Blame Contribute Delete
7.42 kB
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;