Browse Source

fix highlights

imwald
Silberengel 2 weeks ago
parent
commit
497c8ba9ee
  1. 290
      src/components/Note/SelectionHighlightTrigger.tsx
  2. 15
      src/components/Note/index.tsx
  3. 2
      src/components/Profile/index.tsx
  4. 17
      src/components/ProfileBanner/index.tsx
  5. 1
      src/pages/secondary/ProfileEditorPage/index.tsx

290
src/components/Note/SelectionHighlightTrigger.tsx

@ -1,12 +1,17 @@
import { buildHighlightDataFromEvent } from '@/lib/build-highlight-data' import { buildHighlightDataFromEvent } from '@/lib/build-highlight-data'
import { isMobileBrowserProfile } from '@/lib/client-platform'
import { useCreateHighlight } from './CreateHighlightContext' import { useCreateHighlight } from './CreateHighlightContext'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { Highlighter } from 'lucide-react' import { Highlighter } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' 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 { function getParagraphContextFromRange(range: Range): string {
let node: Node | null = range.commonAncestorContainer let node: Node | null = range.commonAncestorContainer
if (node.nodeType !== Node.ELEMENT_NODE) node = node.parentElement if (node.nodeType !== Node.ELEMENT_NODE) node = node.parentElement
@ -21,6 +26,46 @@ function getParagraphContextFromRange(range: Range): string {
return range.toString().trim() 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({ export default function SelectionHighlightTrigger({
event, event,
children children
@ -29,144 +74,259 @@ export default function SelectionHighlightTrigger({
children: React.ReactNode children: React.ReactNode
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const openHighlight = useCreateHighlight() const openHighlight = useCreateHighlight()
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const [toolbar, setToolbar] = useState<{ const [selectedText, setSelectedText] = useState('')
selectedText: string const [paragraphContext, setParagraphContext] = useState('')
paragraphContext: string const [toolbarPos, setToolbarPos] = useState<{ top: number; left: number } | null>(null)
top: number const [showMobileDrawer, setShowMobileDrawer] = useState(false)
left: number
} | null>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// True while a touch is physically in contact with the screen. const touchEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isTouchActiveRef = useRef(false) const selectionStableTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isSelectingRef = useRef(false)
const lastSelectionChangeRef = useRef(0)
const clearUi = useCallback(() => {
setSelectedText('')
setParagraphContext('')
setToolbarPos(null)
setShowMobileDrawer(false)
}, [])
const evaluateSelection = useCallback(() => { const applySelection = useCallback(
(forceShow = false) => {
if (!openHighlight || !containerRef.current) return if (!openHighlight || !containerRef.current) return
const sel = window.getSelection() const hit = readSelectionInContainer(containerRef.current)
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { if (!hit) {
setToolbar(null) clearUi()
return return
} }
const range = sel.getRangeAt(0)
if (!containerRef.current.contains(range.commonAncestorContainer)) { setSelectedText(hit.selectedText)
setToolbar(null) setParagraphContext(hit.paragraphContext)
return
if (isSmallScreen) {
if (forceShow || !isSelectingRef.current) {
setShowMobileDrawer(true)
setToolbarPos(null)
} }
const selectedText = range.toString().trim()
if (!selectedText) {
setToolbar(null)
return return
} }
const rect = range.getBoundingClientRect()
const toolbarHeight = 44 const toolbarHeight = 44
const margin = 8 const margin = 8
// Prefer above the selection; fall back to below if too close to top of viewport.
const top = const top =
rect.top - toolbarHeight < margin ? rect.bottom + margin : rect.top - toolbarHeight hit.rect.top - toolbarHeight < margin ? hit.rect.bottom + margin : hit.rect.top - toolbarHeight
const rawLeft = rect.left + rect.width / 2 - 80 const rawLeft = hit.rect.left + hit.rect.width / 2 - 80
const left = Math.max(margin, Math.min(rawLeft, window.innerWidth - 176 - margin)) 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 }) const scheduleDesktopSelection = useCallback(() => {
}, [openHighlight]) 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 scheduleMobileStableSelection = useCallback(() => {
const handleMouseUp = useCallback(() => { lastSelectionChangeRef.current = Date.now()
evaluateSelection() if (selectionStableTimeoutRef.current) clearTimeout(selectionStableTimeoutRef.current)
}, [evaluateSelection]) 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(() => { useEffect(() => {
if (!openHighlight) return if (!openHighlight) return
const schedule = (delayMs: number) => { const onMouseUp = (e: MouseEvent) => {
if (debounceRef.current) clearTimeout(debounceRef.current) if (isSmallScreen) return
debounceRef.current = setTimeout(evaluateSelection, delayMs) 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 = () => { 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 = () => { const onTouchEnd = () => {
isTouchActiveRef.current = false if (!isSmallScreen) return
schedule(isMobileBrowserProfile() ? 280 : 600) 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 = () => { const onSelectionChange = () => {
if (isTouchActiveRef.current) return if (isSmallScreen) {
schedule(80) 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) => { const onContextMenu = (e: MouseEvent) => {
if (!containerRef.current) return if (!containerRef.current) return
const t = e.target const target = e.target
if (!(t instanceof Node) || !containerRef.current.contains(t)) return if (!(target instanceof Node) || !containerRef.current.contains(target)) return
queueMicrotask(() => evaluateSelection()) queueMicrotask(() => applySelection(true))
} }
document.addEventListener('mouseup', onMouseUp)
document.addEventListener('touchstart', onTouchStart, { passive: true }) document.addEventListener('touchstart', onTouchStart, { passive: true })
document.addEventListener('touchmove', onTouchMove, { passive: true })
document.addEventListener('touchend', onTouchEnd, { passive: true }) document.addEventListener('touchend', onTouchEnd, { passive: true })
document.addEventListener('selectionchange', onSelectionChange) document.addEventListener('selectionchange', onSelectionChange)
document.addEventListener('contextmenu', onContextMenu) document.addEventListener('contextmenu', onContextMenu)
return () => { return () => {
document.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('touchstart', onTouchStart) document.removeEventListener('touchstart', onTouchStart)
document.removeEventListener('touchmove', onTouchMove)
document.removeEventListener('touchend', onTouchEnd) document.removeEventListener('touchend', onTouchEnd)
document.removeEventListener('selectionchange', onSelectionChange) document.removeEventListener('selectionchange', onSelectionChange)
document.removeEventListener('contextmenu', onContextMenu) document.removeEventListener('contextmenu', onContextMenu)
if (debounceRef.current) clearTimeout(debounceRef.current) 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(() => { const handleCreateHighlight = useCallback(() => {
if (!toolbar || !openHighlight) return if (!selectedText || !openHighlight) return
const highlightData = buildHighlightDataFromEvent(event, toolbar.paragraphContext) const highlightData = buildHighlightDataFromEvent(event, paragraphContext)
openHighlight(highlightData, toolbar.selectedText) openHighlight(highlightData, selectedText)
setToolbar(null) clearUi()
window.getSelection()?.removeAllRanges() window.getSelection()?.removeAllRanges()
}, [event, toolbar, openHighlight]) }, [clearUi, event, openHighlight, paragraphContext, selectedText])
const handleDismiss = useCallback(() => { const handleDismiss = useCallback(() => {
setToolbar(null) clearUi()
}, []) window.getSelection()?.removeAllRanges()
}, [clearUi])
if (!openHighlight) return <>{children}</> if (!openHighlight) return <>{children}</>
const showDesktopToolbar = !isSmallScreen && selectedText && toolbarPos
return ( return (
<div ref={containerRef} onMouseUp={handleMouseUp} className="relative"> <div ref={containerRef} className="relative select-text">
{children} {children}
{toolbar && ( {showDesktopToolbar ? (
<> <>
<div <div
className="fixed z-[150] flex items-center gap-1 rounded-md border bg-background px-2 py-1.5 shadow-lg" className="highlight-button-container 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 }} data-selection-highlight-ui
style={{ top: toolbarPos.top, left: toolbarPos.left }}
> >
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-8 gap-1.5" className="h-8 gap-1.5"
onClick={handleCreateHighlight} onClick={(e) => {
e.stopPropagation()
handleCreateHighlight()
}}
> >
<Highlighter className="h-4 w-4" /> <Highlighter className="h-4 w-4" />
{t('Create Highlight')} {t('Create Highlight')}
</Button> </Button>
<Button type="button" variant="ghost" size="sm" className="h-8 px-2" onClick={handleDismiss}> <Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={(e) => {
e.stopPropagation()
handleDismiss()
}}
>
{t('Cancel')} {t('Cancel')}
</Button> </Button>
</div> </div>
<div className="fixed inset-0 z-[149]" aria-hidden onClick={handleDismiss} /> <div
className="fixed inset-0 z-[149]"
aria-hidden
data-selection-highlight-ui
onClick={handleDismiss}
/>
</> </>
)} ) : null}
{isSmallScreen ? (
<Drawer
open={showMobileDrawer && selectedText.length > 0}
onOpenChange={(open) => {
setShowMobileDrawer(open)
if (!open) handleDismiss()
}}
>
<DrawerContent data-selection-highlight-ui>
<DrawerHeader>
<DrawerTitle>{t('Create Highlight')}</DrawerTitle>
</DrawerHeader>
<div className="space-y-4 p-4 pb-8">
<div className="text-sm text-muted-foreground">{t('Selected text')}:</div>
<div className="break-words rounded-lg bg-muted p-3 text-sm">&ldquo;{selectedText}&rdquo;</div>
<Button
className="w-full"
onClick={(e) => {
e.stopPropagation()
handleCreateHighlight()
}}
>
<Highlighter className="mr-2 h-4 w-4" />
{t('Create Highlight')}
</Button>
</div>
</DrawerContent>
</Drawer>
) : null}
</div> </div>
) )
} }

15
src/components/Note/index.tsx

@ -638,7 +638,20 @@ export default function Note({
onClick={disableClick ? undefined : (e) => { onClick={disableClick ? undefined : (e) => {
// Don't navigate if clicking on interactive elements // Don't navigate if clicking on interactive elements
const target = e.target as HTMLElement 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 return
} }
e.stopPropagation() e.stopPropagation()

2
src/components/Profile/index.tsx

@ -369,7 +369,7 @@ export default function Profile({
<ProfileBanner <ProfileBanner
banner={banner} banner={banner}
pubkey={pubkey} pubkey={pubkey}
className="relative z-0 w-full overflow-hidden aspect-[3/1]" className="relative z-0"
imageFetchPriority="low" imageFetchPriority="low"
/> />
{isVideo(avatar ?? '') ? ( {isVideo(avatar ?? '') ? (

17
src/components/ProfileBanner/index.tsx

@ -4,6 +4,12 @@ import { useEffect, useMemo, useState } from 'react'
import Image from '../Image' import Image from '../Image'
import { cn } from '@/lib/utils' 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({ export default function ProfileBanner({
pubkey, pubkey,
banner, banner,
@ -29,10 +35,10 @@ export default function ProfileBanner({
if (isVideo(bannerUrl)) { if (isVideo(bannerUrl)) {
return ( return (
<div className={cn('overflow-hidden rounded-none', className)}> <div className={cn(bannerShellClass, className)}>
<video <video
src={bannerUrl} src={bannerUrl}
className="h-full w-full object-cover object-center" className="absolute inset-0 h-full w-full object-cover object-center"
autoPlay autoPlay
muted muted
loop loop
@ -46,12 +52,15 @@ export default function ProfileBanner({
} }
return ( return (
<div className={cn(bannerShellClass, className)}>
<Image <Image
image={{ url: bannerUrl, pubkey }} image={{ url: bannerUrl, pubkey, dim: BANNER_DIM }}
alt={`${pubkey} banner`} alt={`${pubkey} banner`}
className={cn('rounded-none', className)} className="h-full w-full object-cover rounded-none"
classNames={{ wrapper: 'block h-full w-full' }}
fetchPriority={imageFetchPriority} fetchPriority={imageFetchPriority}
onError={() => setBannerUrl(defaultBanner)} onError={() => setBannerUrl(defaultBanner)}
/> />
</div>
) )
} }

1
src/pages/secondary/ProfileEditorPage/index.tsx

@ -496,7 +496,6 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
<ProfileBanner <ProfileBanner
banner={banner} banner={banner}
pubkey={account.pubkey} pubkey={account.pubkey}
className="w-full aspect-[3/1]"
imageFetchPriority="low" imageFetchPriority="low"
/> />
<div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/30"> <div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/30">

Loading…
Cancel
Save