You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
172 lines
6.2 KiB
172 lines
6.2 KiB
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<HTMLDivElement>(null) |
|
const [toolbar, setToolbar] = useState<{ |
|
selectedText: string |
|
paragraphContext: string |
|
top: number |
|
left: number |
|
} | null>(null) |
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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 ( |
|
<div ref={containerRef} onMouseUp={handleMouseUp} className="relative"> |
|
{children} |
|
{toolbar && ( |
|
<> |
|
<div |
|
className="fixed z-[150] flex items-center gap-1 rounded-md border bg-background px-2 py-1.5 shadow-lg" |
|
style={{ top: toolbar.top, left: toolbar.left }} |
|
> |
|
<Button |
|
type="button" |
|
variant="ghost" |
|
size="sm" |
|
className="h-8 gap-1.5" |
|
onClick={handleCreateHighlight} |
|
> |
|
<Highlighter className="h-4 w-4" /> |
|
{t('Create Highlight')} |
|
</Button> |
|
<Button type="button" variant="ghost" size="sm" className="h-8 px-2" onClick={handleDismiss}> |
|
{t('Cancel')} |
|
</Button> |
|
</div> |
|
<div className="fixed inset-0 z-[149]" aria-hidden onClick={handleDismiss} /> |
|
</> |
|
)} |
|
</div> |
|
) |
|
}
|
|
|