index.tsx raw
1 import { Button } from '@/components/ui/button'
2 import { Highlighter } from 'lucide-react'
3 import { useEffect, useRef, useState } from 'react'
4 import { useTranslation } from 'react-i18next'
5
6 interface HighlightButtonProps {
7 onHighlight: (selectedText: string) => void
8 containerRef?: React.RefObject<HTMLElement>
9 }
10
11 export default function HighlightButton({ onHighlight, containerRef }: HighlightButtonProps) {
12 const { t } = useTranslation()
13 const [position, setPosition] = useState<{ top: number; left: number } | null>(null)
14 const [selectedText, setSelectedText] = useState('')
15 const buttonRef = useRef<HTMLButtonElement>(null)
16
17 useEffect(() => {
18 const handleSelectionEnd = () => {
19 // Use a small delay to ensure selection is complete
20 setTimeout(() => {
21 const selection = window.getSelection()
22 const text = selection?.toString().trim()
23
24 if (!text || text.length === 0) {
25 setPosition(null)
26 setSelectedText('')
27 return
28 }
29
30 // Check if selection is within the container (if provided)
31 if (containerRef?.current) {
32 const range = selection?.getRangeAt(0)
33 if (range && !containerRef.current.contains(range.commonAncestorContainer)) {
34 setPosition(null)
35 setSelectedText('')
36 return
37 }
38 }
39
40 const range = selection?.getRangeAt(0)
41 if (!range) return
42
43 // Get the bounding rect of the entire selection
44 const rect = range.getBoundingClientRect()
45 const scrollTop = window.pageYOffset || document.documentElement.scrollTop
46 const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft
47
48 // Position button above the selection area, centered horizontally
49 setPosition({
50 top: rect.top + scrollTop - 48, // 48px above the selection
51 left: rect.left + scrollLeft + rect.width / 2 // Center of the selection
52 })
53 setSelectedText(text)
54 }, 10)
55 }
56
57 // Only listen to mouseup and touchend (when user finishes selection)
58 document.addEventListener('mouseup', handleSelectionEnd)
59 document.addEventListener('touchend', handleSelectionEnd)
60
61 return () => {
62 document.removeEventListener('mouseup', handleSelectionEnd)
63 document.removeEventListener('touchend', handleSelectionEnd)
64 }
65 }, [containerRef])
66
67 useEffect(() => {
68 const handleClickOutside = (event: MouseEvent) => {
69 if (buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
70 const selection = window.getSelection()
71 if (!selection?.toString().trim()) {
72 setPosition(null)
73 setSelectedText('')
74 }
75 }
76 }
77
78 document.addEventListener('mousedown', handleClickOutside)
79 return () => {
80 document.removeEventListener('mousedown', handleClickOutside)
81 }
82 }, [])
83
84 if (!position || !selectedText) {
85 return null
86 }
87
88 return (
89 <div
90 className="fixed z-50 animate-in fade-in-0 slide-in-from-bottom-4 duration-200"
91 style={{
92 top: `${position.top}px`,
93 left: `${position.left}px`
94 }}
95 >
96 <Button
97 ref={buttonRef}
98 size="sm"
99 variant="default"
100 className="shadow-lg gap-2 -translate-x-1/2"
101 onClick={(e) => {
102 e.stopPropagation()
103 onHighlight(selectedText)
104 // Clear selection after highlighting
105 window.getSelection()?.removeAllRanges()
106 setPosition(null)
107 setSelectedText('')
108 }}
109 >
110 <Highlighter className="h-4 w-4" />
111 {t('Highlight')}
112 </Button>
113 </div>
114 )
115 }
116