import { buildHighlightDataFromEvent } from '@/lib/build-highlight-data' import { isMobileBrowserProfile } from '@/lib/client-platform' import { useCreateHighlight } from './CreateHighlightContext' import { Event } from 'nostr-tools' import { Highlighter } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' function getParagraphContextFromRange(range: Range): string { let node: Node | null = range.commonAncestorContainer if (node.nodeType !== Node.ELEMENT_NODE) node = node.parentElement let el = node as Element | null while (el) { const tag = el.tagName?.toLowerCase() if (tag === 'p' || (tag?.startsWith('h') && /^h[1-6]$/.test(tag))) { return el.textContent?.trim() || range.toString().trim() } el = el.parentElement } return range.toString().trim() } export default function SelectionHighlightTrigger({ event, children }: { event: Event children: React.ReactNode }) { const { t } = useTranslation() const openHighlight = useCreateHighlight() const containerRef = useRef(null) const [toolbar, setToolbar] = useState<{ selectedText: string paragraphContext: string top: number left: number } | null>(null) const debounceRef = useRef | null>(null) // True while a touch is physically in contact with the screen. const isTouchActiveRef = useRef(false) const evaluateSelection = useCallback(() => { if (!openHighlight || !containerRef.current) return const sel = window.getSelection() if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { setToolbar(null) return } const range = sel.getRangeAt(0) if (!containerRef.current.contains(range.commonAncestorContainer)) { setToolbar(null) return } const selectedText = range.toString().trim() if (!selectedText) { setToolbar(null) return } const rect = range.getBoundingClientRect() const toolbarHeight = 44 const margin = 8 // Prefer above the selection; fall back to below if too close to top of viewport. const top = rect.top - toolbarHeight < margin ? rect.bottom + margin : rect.top - toolbarHeight const rawLeft = rect.left + rect.width / 2 - 80 const left = Math.max(margin, Math.min(rawLeft, window.innerWidth - 176 - margin)) setToolbar({ selectedText, paragraphContext: getParagraphContextFromRange(range), top, left }) }, [openHighlight]) // Desktop: mouseup fires reliably after text selection by mouse. const handleMouseUp = useCallback(() => { evaluateSelection() }, [evaluateSelection]) useEffect(() => { if (!openHighlight) return const schedule = (delayMs: number) => { if (debounceRef.current) clearTimeout(debounceRef.current) debounceRef.current = setTimeout(evaluateSelection, delayMs) } // Mobile: finger touches screen — mark active so selectionchange is suppressed during // the gesture itself (avoids positioning the toolbar mid-drag). const onTouchStart = () => { isTouchActiveRef.current = true } // Mobile: finger lifts — wait for the browser to settle the selection, then evaluate. // Shorter delay on coarse pointers; contextmenu (below) is the reliable path when the OS shows the callout. const onTouchEnd = () => { isTouchActiveRef.current = false schedule(isMobileBrowserProfile() ? 280 : 600) } // Both: covers keyboard selection (Shift+Arrow) on desktop and selection-handle // dragging on mobile (which may not generate touch events in our DOM). const onSelectionChange = () => { if (isTouchActiveRef.current) return schedule(80) } // When the system opens the text callout / context menu, selection is still valid here; delayed // touchend/selectionchange often misses on iOS/Android because the selection is cleared before we run. const onContextMenu = (e: MouseEvent) => { if (!containerRef.current) return const t = e.target if (!(t instanceof Node) || !containerRef.current.contains(t)) return queueMicrotask(() => evaluateSelection()) } document.addEventListener('touchstart', onTouchStart, { passive: true }) document.addEventListener('touchend', onTouchEnd, { passive: true }) document.addEventListener('selectionchange', onSelectionChange) document.addEventListener('contextmenu', onContextMenu) return () => { document.removeEventListener('touchstart', onTouchStart) document.removeEventListener('touchend', onTouchEnd) document.removeEventListener('selectionchange', onSelectionChange) document.removeEventListener('contextmenu', onContextMenu) if (debounceRef.current) clearTimeout(debounceRef.current) } }, [openHighlight, evaluateSelection]) const handleCreateHighlight = useCallback(() => { if (!toolbar || !openHighlight) return const highlightData = buildHighlightDataFromEvent(event, toolbar.paragraphContext) openHighlight(highlightData, toolbar.selectedText) setToolbar(null) window.getSelection()?.removeAllRanges() }, [event, toolbar, openHighlight]) const handleDismiss = useCallback(() => { setToolbar(null) }, []) if (!openHighlight) return <>{children} return (
{children} {toolbar && ( <>
)}
) }