fix: position session action menu at touch/click point

- Updated useLongPress.ts to pass click coordinates to onLongPress callback
- Modified SessionList.tsx to use menuAnchorPoint state instead of menuAnchorRef
- Updated SessionActionMenu.tsx to position menu based on anchorPoint coordinates
- Enhanced SessionHeader.tsx to calculate anchorPoint from button position and added stopPropagation to fix toggle issue

The menu now appears at the exact touch/click position instead of being centered or aligned to the button edge.
This commit is contained in:
weishu
2026-01-12 19:08:01 +08:00
parent 83664a7fc9
commit 8ed94193d8
4 changed files with 38 additions and 44 deletions
+11 -28
View File
@@ -5,8 +5,7 @@ import {
useLayoutEffect, useLayoutEffect,
useRef, useRef,
useState, useState,
type CSSProperties, type CSSProperties
type RefObject
} from 'react' } from 'react'
import { useTranslation } from '@/lib/use-translation' import { useTranslation } from '@/lib/use-translation'
@@ -17,8 +16,7 @@ type SessionActionMenuProps = {
onRename: () => void onRename: () => void
onArchive: () => void onArchive: () => void
onDelete: () => void onDelete: () => void
anchorRef?: RefObject<HTMLElement | null> anchorPoint: { x: number; y: number }
align?: 'start' | 'end'
menuId?: string menuId?: string
} }
@@ -101,8 +99,7 @@ export function SessionActionMenu(props: SessionActionMenuProps) {
onRename, onRename,
onArchive, onArchive,
onDelete, onDelete,
anchorRef, anchorPoint,
align = 'end',
menuId menuId
} = props } = props
const menuRef = useRef<HTMLDivElement | null>(null) const menuRef = useRef<HTMLDivElement | null>(null)
@@ -136,32 +133,19 @@ export function SessionActionMenu(props: SessionActionMenuProps) {
const padding = 8 const padding = 8
const gap = 8 const gap = 8
let top = (viewportHeight - menuRect.height) / 2 const spaceBelow = viewportHeight - anchorPoint.y
let left = (viewportWidth - menuRect.width) / 2 const spaceAbove = anchorPoint.y
let transformOrigin = 'top center' const openAbove = spaceBelow < menuRect.height + gap && spaceAbove > spaceBelow
const anchorEl = anchorRef?.current let top = openAbove ? anchorPoint.y - menuRect.height - gap : anchorPoint.y + gap
if (anchorEl) { let left = anchorPoint.x - menuRect.width / 2
const anchorRect = anchorEl.getBoundingClientRect() const transformOrigin = openAbove ? 'bottom center' : 'top center'
const spaceBelow = viewportHeight - anchorRect.bottom
const spaceAbove = anchorRect.top
const openAbove = spaceBelow < menuRect.height + gap && spaceAbove > spaceBelow
top = openAbove ? anchorRect.top - menuRect.height - gap : anchorRect.bottom + gap
if (align === 'start') {
left = anchorRect.left
transformOrigin = openAbove ? 'bottom left' : 'top left'
} else {
left = anchorRect.right - menuRect.width
transformOrigin = openAbove ? 'bottom right' : 'top right'
}
}
top = Math.min(Math.max(top, padding), viewportHeight - menuRect.height - padding) top = Math.min(Math.max(top, padding), viewportHeight - menuRect.height - padding)
left = Math.min(Math.max(left, padding), viewportWidth - menuRect.width - padding) left = Math.min(Math.max(left, padding), viewportWidth - menuRect.width - padding)
setMenuPosition({ top, left, transformOrigin }) setMenuPosition({ top, left, transformOrigin })
}, [align, anchorRef]) }, [anchorPoint])
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isOpen) return if (!isOpen) return
@@ -177,7 +161,6 @@ export function SessionActionMenu(props: SessionActionMenuProps) {
const handlePointerDown = (event: PointerEvent) => { const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node const target = event.target as Node
if (menuRef.current?.contains(target)) return if (menuRef.current?.contains(target)) return
if (anchorRef?.current?.contains(target)) return
onClose() onClose()
} }
@@ -202,7 +185,7 @@ export function SessionActionMenu(props: SessionActionMenuProps) {
window.removeEventListener('resize', handleReflow) window.removeEventListener('resize', handleReflow)
window.removeEventListener('scroll', handleReflow, true) window.removeEventListener('scroll', handleReflow, true)
} }
}, [anchorRef, isOpen, onClose, updatePosition]) }, [isOpen, onClose, updatePosition])
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
+12 -3
View File
@@ -72,6 +72,7 @@ export function SessionHeader(props: {
const worktreeBranch = session.metadata?.worktree?.branch const worktreeBranch = session.metadata?.worktree?.branch
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
const menuId = useId() const menuId = useId()
const menuAnchorRef = useRef<HTMLButtonElement | null>(null) const menuAnchorRef = useRef<HTMLButtonElement | null>(null)
const [renameOpen, setRenameOpen] = useState(false) const [renameOpen, setRenameOpen] = useState(false)
@@ -89,6 +90,14 @@ export function SessionHeader(props: {
onSessionDeleted?.() onSessionDeleted?.()
} }
const handleMenuToggle = () => {
if (!menuOpen && menuAnchorRef.current) {
const rect = menuAnchorRef.current.getBoundingClientRect()
setMenuAnchorPoint({ x: rect.right, y: rect.bottom })
}
setMenuOpen((open) => !open)
}
// In Telegram, don't render header (Telegram provides its own) // In Telegram, don't render header (Telegram provides its own)
if (isTelegramApp()) { if (isTelegramApp()) {
return null return null
@@ -151,7 +160,8 @@ export function SessionHeader(props: {
<button <button
type="button" type="button"
onClick={() => setMenuOpen((open) => !open)} onClick={handleMenuToggle}
onPointerDown={(e) => e.stopPropagation()}
ref={menuAnchorRef} ref={menuAnchorRef}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={menuOpen} aria-expanded={menuOpen}
@@ -171,8 +181,7 @@ export function SessionHeader(props: {
onRename={() => setRenameOpen(true)} onRename={() => setRenameOpen(true)}
onArchive={() => setArchiveOpen(true)} onArchive={() => setArchiveOpen(true)}
onDelete={() => setDeleteOpen(true)} onDelete={() => setDeleteOpen(true)}
anchorRef={menuAnchorRef} anchorPoint={menuAnchorPoint}
align="end"
menuId={menuId} menuId={menuId}
/> />
+5 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import type { SessionSummary } from '@/types/api' import type { SessionSummary } from '@/types/api'
import type { ApiClient } from '@/api/client' import type { ApiClient } from '@/api/client'
import { useLongPress } from '@/hooks/useLongPress' import { useLongPress } from '@/hooks/useLongPress'
@@ -171,7 +171,7 @@ function SessionItem(props: {
const { session: s, onSelect, showPath = true, api } = props const { session: s, onSelect, showPath = true, api } = props
const { haptic } = usePlatform() const { haptic } = usePlatform()
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const menuAnchorRef = useRef<HTMLButtonElement | null>(null) const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
const [renameOpen, setRenameOpen] = useState(false) const [renameOpen, setRenameOpen] = useState(false)
const [archiveOpen, setArchiveOpen] = useState(false) const [archiveOpen, setArchiveOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false)
@@ -183,8 +183,9 @@ function SessionItem(props: {
) )
const longPressHandlers = useLongPress({ const longPressHandlers = useLongPress({
onLongPress: () => { onLongPress: (point) => {
haptic.impact('medium') haptic.impact('medium')
setMenuAnchorPoint(point)
setMenuOpen(true) setMenuOpen(true)
}, },
onClick: () => { onClick: () => {
@@ -204,7 +205,6 @@ function SessionItem(props: {
<button <button
type="button" type="button"
{...longPressHandlers} {...longPressHandlers}
ref={menuAnchorRef}
className="session-list-item flex w-full flex-col gap-1.5 px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)] select-none" className="session-list-item flex w-full flex-col gap-1.5 px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)] select-none"
style={{ WebkitTouchCallout: 'none' }} style={{ WebkitTouchCallout: 'none' }}
> >
@@ -271,8 +271,7 @@ function SessionItem(props: {
onRename={() => setRenameOpen(true)} onRename={() => setRenameOpen(true)}
onArchive={() => setArchiveOpen(true)} onArchive={() => setArchiveOpen(true)}
onDelete={() => setDeleteOpen(true)} onDelete={() => setDeleteOpen(true)}
anchorRef={menuAnchorRef} anchorPoint={menuAnchorPoint}
align="end"
/> />
<RenameSessionDialog <RenameSessionDialog
+10 -7
View File
@@ -2,7 +2,7 @@ import type React from 'react'
import { useCallback, useRef } from 'react' import { useCallback, useRef } from 'react'
type UseLongPressOptions = { type UseLongPressOptions = {
onLongPress: () => void onLongPress: (point: { x: number; y: number }) => void
onClick?: () => void onClick?: () => void
threshold?: number threshold?: number
disabled?: boolean disabled?: boolean
@@ -25,6 +25,7 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isLongPressRef = useRef(false) const isLongPressRef = useRef(false)
const touchMoved = useRef(false) const touchMoved = useRef(false)
const pressPointRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
const clearTimer = useCallback(() => { const clearTimer = useCallback(() => {
if (timerRef.current) { if (timerRef.current) {
@@ -33,16 +34,17 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers
} }
}, []) }, [])
const startTimer = useCallback(() => { const startTimer = useCallback((clientX: number, clientY: number) => {
if (disabled) return if (disabled) return
clearTimer() clearTimer()
isLongPressRef.current = false isLongPressRef.current = false
touchMoved.current = false touchMoved.current = false
pressPointRef.current = { x: clientX, y: clientY }
timerRef.current = setTimeout(() => { timerRef.current = setTimeout(() => {
isLongPressRef.current = true isLongPressRef.current = true
onLongPress() onLongPress(pressPointRef.current)
}, threshold) }, threshold)
}, [disabled, clearTimer, onLongPress, threshold]) }, [disabled, clearTimer, onLongPress, threshold])
@@ -59,7 +61,7 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers
const onMouseDown = useCallback<React.MouseEventHandler>((e) => { const onMouseDown = useCallback<React.MouseEventHandler>((e) => {
if (e.button !== 0) return if (e.button !== 0) return
startTimer() startTimer(e.clientX, e.clientY)
}, [startTimer]) }, [startTimer])
const onMouseUp = useCallback<React.MouseEventHandler>(() => { const onMouseUp = useCallback<React.MouseEventHandler>(() => {
@@ -70,8 +72,9 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers
handleEnd(false) handleEnd(false)
}, [handleEnd]) }, [handleEnd])
const onTouchStart = useCallback<React.TouchEventHandler>(() => { const onTouchStart = useCallback<React.TouchEventHandler>((e) => {
startTimer() const touch = e.touches[0]
startTimer(touch.clientX, touch.clientY)
}, [startTimer]) }, [startTimer])
const onTouchEnd = useCallback<React.TouchEventHandler>((e) => { const onTouchEnd = useCallback<React.TouchEventHandler>((e) => {
@@ -91,7 +94,7 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers
e.preventDefault() e.preventDefault()
clearTimer() clearTimer()
isLongPressRef.current = true isLongPressRef.current = true
onLongPress() onLongPress({ x: e.clientX, y: e.clientY })
} }
}, [disabled, clearTimer, onLongPress]) }, [disabled, clearTimer, onLongPress])