From 497c8ba9ee6af34956de079c6bfad93b46077a60 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 2 Jun 2026 23:47:46 +0200 Subject: [PATCH] fix highlights --- .../Note/SelectionHighlightTrigger.tsx | 312 +++++++++++++----- src/components/Note/index.tsx | 15 +- src/components/Profile/index.tsx | 2 +- src/components/ProfileBanner/index.tsx | 27 +- .../secondary/ProfileEditorPage/index.tsx | 1 - 5 files changed, 269 insertions(+), 88 deletions(-) diff --git a/src/components/Note/SelectionHighlightTrigger.tsx b/src/components/Note/SelectionHighlightTrigger.tsx index d210ceda..956d05a1 100644 --- a/src/components/Note/SelectionHighlightTrigger.tsx +++ b/src/components/Note/SelectionHighlightTrigger.tsx @@ -1,12 +1,17 @@ import { buildHighlightDataFromEvent } from '@/lib/build-highlight-data' -import { isMobileBrowserProfile } from '@/lib/client-platform' import { useCreateHighlight } from './CreateHighlightContext' +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer' +import { useScreenSize } from '@/providers/ScreenSizeProvider' 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' +const MOBILE_TOUCH_END_SETTLE_MS = 600 +const MOBILE_SELECTION_STABLE_MS = 1600 +const DESKTOP_SELECTION_DELAY_MS = 50 + function getParagraphContextFromRange(range: Range): string { let node: Node | null = range.commonAncestorContainer if (node.nodeType !== Node.ELEMENT_NODE) node = node.parentElement @@ -21,6 +26,46 @@ function getParagraphContextFromRange(range: Range): string { return range.toString().trim() } +function isRangeInContainer(range: Range, container: HTMLElement): boolean { + const commonAncestor = range.commonAncestorContainer + if (commonAncestor.nodeType === Node.ELEMENT_NODE) { + if (container.contains(commonAncestor as Element)) return true + } else { + const parent = commonAncestor.parentElement + if (parent && container.contains(parent)) return true + } + try { + const contentRect = container.getBoundingClientRect() + const rangeRect = range.getBoundingClientRect() + return !( + rangeRect.bottom < contentRect.top || + rangeRect.top > contentRect.bottom || + rangeRect.right < contentRect.left || + rangeRect.left > contentRect.right + ) + } catch { + return false + } +} + +function readSelectionInContainer(container: HTMLElement): { + selectedText: string + paragraphContext: string + rect: DOMRect +} | null { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null + const range = selection.getRangeAt(0) + if (!isRangeInContainer(range, container)) return null + const selectedText = selection.toString().trim() + if (!selectedText) return null + return { + selectedText, + paragraphContext: getParagraphContextFromRange(range), + rect: range.getBoundingClientRect() + } +} + export default function SelectionHighlightTrigger({ event, children @@ -29,144 +74,259 @@ export default function SelectionHighlightTrigger({ children: React.ReactNode }) { const { t } = useTranslation() + const { isSmallScreen } = useScreenSize() const openHighlight = useCreateHighlight() const containerRef = useRef(null) - const [toolbar, setToolbar] = useState<{ - selectedText: string - paragraphContext: string - top: number - left: number - } | null>(null) + const [selectedText, setSelectedText] = useState('') + const [paragraphContext, setParagraphContext] = useState('') + const [toolbarPos, setToolbarPos] = useState<{ top: number; left: number } | null>(null) + const [showMobileDrawer, setShowMobileDrawer] = useState(false) + 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 touchEndTimeoutRef = useRef | null>(null) + const selectionStableTimeoutRef = useRef | null>(null) + const isSelectingRef = useRef(false) + const lastSelectionChangeRef = useRef(0) + + const clearUi = useCallback(() => { + setSelectedText('') + setParagraphContext('') + setToolbarPos(null) + setShowMobileDrawer(false) + }, []) - 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)) + const applySelection = useCallback( + (forceShow = false) => { + if (!openHighlight || !containerRef.current) return + const hit = readSelectionInContainer(containerRef.current) + if (!hit) { + clearUi() + return + } + + setSelectedText(hit.selectedText) + setParagraphContext(hit.paragraphContext) + + if (isSmallScreen) { + if (forceShow || !isSelectingRef.current) { + setShowMobileDrawer(true) + setToolbarPos(null) + } + return + } + + const toolbarHeight = 44 + const margin = 8 + const top = + hit.rect.top - toolbarHeight < margin ? hit.rect.bottom + margin : hit.rect.top - toolbarHeight + const rawLeft = hit.rect.left + hit.rect.width / 2 - 80 + const left = Math.max(margin, Math.min(rawLeft, window.innerWidth - 176 - margin)) + setToolbarPos({ top, left }) + setShowMobileDrawer(false) + }, + [clearUi, isSmallScreen, openHighlight] + ) - setToolbar({ selectedText, paragraphContext: getParagraphContextFromRange(range), top, left }) - }, [openHighlight]) + const scheduleDesktopSelection = useCallback(() => { + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => applySelection(true), DESKTOP_SELECTION_DELAY_MS) + }, [applySelection]) - // Desktop: mouseup fires reliably after text selection by mouse. - const handleMouseUp = useCallback(() => { - evaluateSelection() - }, [evaluateSelection]) + const scheduleMobileStableSelection = useCallback(() => { + lastSelectionChangeRef.current = Date.now() + if (selectionStableTimeoutRef.current) clearTimeout(selectionStableTimeoutRef.current) + selectionStableTimeoutRef.current = setTimeout(() => { + const elapsed = Date.now() - lastSelectionChangeRef.current + if (elapsed >= MOBILE_SELECTION_STABLE_MS && !isSelectingRef.current) { + applySelection(true) + } + }, MOBILE_SELECTION_STABLE_MS) + }, [applySelection]) useEffect(() => { if (!openHighlight) return - const schedule = (delayMs: number) => { - if (debounceRef.current) clearTimeout(debounceRef.current) - debounceRef.current = setTimeout(evaluateSelection, delayMs) + const onMouseUp = (e: MouseEvent) => { + if (isSmallScreen) return + const el = + e.target instanceof Element ? e.target : e.target instanceof Node ? e.target.parentElement : null + if (el?.closest('[data-selection-highlight-ui]')) return + scheduleDesktopSelection() } - // 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 + if (!isSmallScreen) return + isSelectingRef.current = true + if (selectionStableTimeoutRef.current) clearTimeout(selectionStableTimeoutRef.current) + setShowMobileDrawer(false) + } + + const onTouchMove = () => { + if (!isSmallScreen) return + isSelectingRef.current = true + if (selectionStableTimeoutRef.current) clearTimeout(selectionStableTimeoutRef.current) + setShowMobileDrawer(false) } - // 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) + if (!isSmallScreen) return + if (touchEndTimeoutRef.current) clearTimeout(touchEndTimeoutRef.current) + touchEndTimeoutRef.current = setTimeout(() => { + isSelectingRef.current = false + scheduleMobileStableSelection() + }, MOBILE_TOUCH_END_SETTLE_MS) } - // 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) + if (isSmallScreen) { + lastSelectionChangeRef.current = Date.now() + if (isSelectingRef.current) return + + const selection = window.getSelection() + const hasSelection = + selection && + !selection.isCollapsed && + selection.rangeCount > 0 && + selection.toString().trim().length > 0 + + if (!hasSelection) { + if (selectionStableTimeoutRef.current) clearTimeout(selectionStableTimeoutRef.current) + clearUi() + return + } + + scheduleMobileStableSelection() + return + } + + scheduleDesktopSelection() } - // 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()) + const target = e.target + if (!(target instanceof Node) || !containerRef.current.contains(target)) return + queueMicrotask(() => applySelection(true)) } + document.addEventListener('mouseup', onMouseUp) document.addEventListener('touchstart', onTouchStart, { passive: true }) + document.addEventListener('touchmove', onTouchMove, { passive: true }) document.addEventListener('touchend', onTouchEnd, { passive: true }) document.addEventListener('selectionchange', onSelectionChange) document.addEventListener('contextmenu', onContextMenu) return () => { + document.removeEventListener('mouseup', onMouseUp) document.removeEventListener('touchstart', onTouchStart) + document.removeEventListener('touchmove', onTouchMove) document.removeEventListener('touchend', onTouchEnd) document.removeEventListener('selectionchange', onSelectionChange) document.removeEventListener('contextmenu', onContextMenu) if (debounceRef.current) clearTimeout(debounceRef.current) + if (touchEndTimeoutRef.current) clearTimeout(touchEndTimeoutRef.current) + if (selectionStableTimeoutRef.current) clearTimeout(selectionStableTimeoutRef.current) } - }, [openHighlight, evaluateSelection]) + }, [ + applySelection, + clearUi, + isSmallScreen, + openHighlight, + scheduleDesktopSelection, + scheduleMobileStableSelection + ]) const handleCreateHighlight = useCallback(() => { - if (!toolbar || !openHighlight) return - const highlightData = buildHighlightDataFromEvent(event, toolbar.paragraphContext) - openHighlight(highlightData, toolbar.selectedText) - setToolbar(null) + if (!selectedText || !openHighlight) return + const highlightData = buildHighlightDataFromEvent(event, paragraphContext) + openHighlight(highlightData, selectedText) + clearUi() window.getSelection()?.removeAllRanges() - }, [event, toolbar, openHighlight]) + }, [clearUi, event, openHighlight, paragraphContext, selectedText]) const handleDismiss = useCallback(() => { - setToolbar(null) - }, []) + clearUi() + window.getSelection()?.removeAllRanges() + }, [clearUi]) if (!openHighlight) return <>{children} + const showDesktopToolbar = !isSmallScreen && selectedText && toolbarPos + return ( -
+
{children} - {toolbar && ( + {showDesktopToolbar ? ( <>
-
-
+
- )} + ) : null} + + {isSmallScreen ? ( + 0} + onOpenChange={(open) => { + setShowMobileDrawer(open) + if (!open) handleDismiss() + }} + > + + + {t('Create Highlight')} + +
+
{t('Selected text')}:
+
“{selectedText}”
+ +
+
+
+ ) : null}
) } diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index caa41a6a..ad8a1f71 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -638,7 +638,20 @@ export default function Note({ onClick={disableClick ? undefined : (e) => { // Don't navigate if clicking on interactive elements const target = e.target as HTMLElement - if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-embedded-note]') || target.closest('[data-parent-note-preview]') || target.closest('[data-user-avatar]') || target.closest('[data-username]')) { + if (window.getSelection()?.toString().trim()) { + return + } + if ( + target.closest('button') || + target.closest('[role="button"]') || + target.closest('a') || + target.closest('[data-embedded-note]') || + target.closest('[data-parent-note-preview]') || + target.closest('[data-user-avatar]') || + target.closest('[data-username]') || + target.closest('[data-selection-highlight-ui]') || + target.closest('.highlight-button-container') + ) { return } e.stopPropagation() diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 836ecfea..bba8d8fe 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -369,7 +369,7 @@ export default function Profile({ {isVideo(avatar ?? '') ? ( diff --git a/src/components/ProfileBanner/index.tsx b/src/components/ProfileBanner/index.tsx index fea75594..6f56f53f 100644 --- a/src/components/ProfileBanner/index.tsx +++ b/src/components/ProfileBanner/index.tsx @@ -4,6 +4,12 @@ import { useEffect, useMemo, useState } from 'react' import Image from '../Image' import { cn } from '@/lib/utils' +/** Layout hint for {@link Image} wrapper — banners are always cropped to 3:1 regardless of source pixels. */ +const BANNER_DIM = { width: 3, height: 1 } as const + +const bannerShellClass = + 'relative w-full overflow-hidden aspect-[3/1] max-h-36 sm:max-h-44 md:max-h-52' + export default function ProfileBanner({ pubkey, banner, @@ -29,10 +35,10 @@ export default function ProfileBanner({ if (isVideo(bannerUrl)) { return ( -
+