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,
useRef,
useState,
type CSSProperties,
type RefObject
type CSSProperties
} from 'react'
import { useTranslation } from '@/lib/use-translation'
@@ -17,8 +16,7 @@ type SessionActionMenuProps = {
onRename: () => void
onArchive: () => void
onDelete: () => void
anchorRef?: RefObject<HTMLElement | null>
align?: 'start' | 'end'
anchorPoint: { x: number; y: number }
menuId?: string
}
@@ -101,8 +99,7 @@ export function SessionActionMenu(props: SessionActionMenuProps) {
onRename,
onArchive,
onDelete,
anchorRef,
align = 'end',
anchorPoint,
menuId
} = props
const menuRef = useRef<HTMLDivElement | null>(null)
@@ -136,32 +133,19 @@ export function SessionActionMenu(props: SessionActionMenuProps) {
const padding = 8
const gap = 8
let top = (viewportHeight - menuRect.height) / 2
let left = (viewportWidth - menuRect.width) / 2
let transformOrigin = 'top center'
const spaceBelow = viewportHeight - anchorPoint.y
const spaceAbove = anchorPoint.y
const openAbove = spaceBelow < menuRect.height + gap && spaceAbove > spaceBelow
const anchorEl = anchorRef?.current
if (anchorEl) {
const anchorRect = anchorEl.getBoundingClientRect()
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'
}
}
let top = openAbove ? anchorPoint.y - menuRect.height - gap : anchorPoint.y + gap
let left = anchorPoint.x - menuRect.width / 2
const transformOrigin = openAbove ? 'bottom center' : 'top center'
top = Math.min(Math.max(top, padding), viewportHeight - menuRect.height - padding)
left = Math.min(Math.max(left, padding), viewportWidth - menuRect.width - padding)
setMenuPosition({ top, left, transformOrigin })
}, [align, anchorRef])
}, [anchorPoint])
useLayoutEffect(() => {
if (!isOpen) return
@@ -177,7 +161,6 @@ export function SessionActionMenu(props: SessionActionMenuProps) {
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node
if (menuRef.current?.contains(target)) return
if (anchorRef?.current?.contains(target)) return
onClose()
}
@@ -202,7 +185,7 @@ export function SessionActionMenu(props: SessionActionMenuProps) {
window.removeEventListener('resize', handleReflow)
window.removeEventListener('scroll', handleReflow, true)
}
}, [anchorRef, isOpen, onClose, updatePosition])
}, [isOpen, onClose, updatePosition])
useEffect(() => {
if (!isOpen) return
+12 -3
View File
@@ -72,6 +72,7 @@ export function SessionHeader(props: {
const worktreeBranch = session.metadata?.worktree?.branch
const [menuOpen, setMenuOpen] = useState(false)
const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
const menuId = useId()
const menuAnchorRef = useRef<HTMLButtonElement | null>(null)
const [renameOpen, setRenameOpen] = useState(false)
@@ -89,6 +90,14 @@ export function SessionHeader(props: {
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)
if (isTelegramApp()) {
return null
@@ -151,7 +160,8 @@ export function SessionHeader(props: {
<button
type="button"
onClick={() => setMenuOpen((open) => !open)}
onClick={handleMenuToggle}
onPointerDown={(e) => e.stopPropagation()}
ref={menuAnchorRef}
aria-haspopup="menu"
aria-expanded={menuOpen}
@@ -171,8 +181,7 @@ export function SessionHeader(props: {
onRename={() => setRenameOpen(true)}
onArchive={() => setArchiveOpen(true)}
onDelete={() => setDeleteOpen(true)}
anchorRef={menuAnchorRef}
align="end"
anchorPoint={menuAnchorPoint}
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 { ApiClient } from '@/api/client'
import { useLongPress } from '@/hooks/useLongPress'
@@ -171,7 +171,7 @@ function SessionItem(props: {
const { session: s, onSelect, showPath = true, api } = props
const { haptic } = usePlatform()
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 [archiveOpen, setArchiveOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
@@ -183,8 +183,9 @@ function SessionItem(props: {
)
const longPressHandlers = useLongPress({
onLongPress: () => {
onLongPress: (point) => {
haptic.impact('medium')
setMenuAnchorPoint(point)
setMenuOpen(true)
},
onClick: () => {
@@ -204,7 +205,6 @@ function SessionItem(props: {
<button
type="button"
{...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"
style={{ WebkitTouchCallout: 'none' }}
>
@@ -271,8 +271,7 @@ function SessionItem(props: {
onRename={() => setRenameOpen(true)}
onArchive={() => setArchiveOpen(true)}
onDelete={() => setDeleteOpen(true)}
anchorRef={menuAnchorRef}
align="end"
anchorPoint={menuAnchorPoint}
/>
<RenameSessionDialog
+10 -7
View File
@@ -2,7 +2,7 @@ import type React from 'react'
import { useCallback, useRef } from 'react'
type UseLongPressOptions = {
onLongPress: () => void
onLongPress: (point: { x: number; y: number }) => void
onClick?: () => void
threshold?: number
disabled?: boolean
@@ -25,6 +25,7 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isLongPressRef = useRef(false)
const touchMoved = useRef(false)
const pressPointRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
const clearTimer = useCallback(() => {
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
clearTimer()
isLongPressRef.current = false
touchMoved.current = false
pressPointRef.current = { x: clientX, y: clientY }
timerRef.current = setTimeout(() => {
isLongPressRef.current = true
onLongPress()
onLongPress(pressPointRef.current)
}, threshold)
}, [disabled, clearTimer, onLongPress, threshold])
@@ -59,7 +61,7 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers
const onMouseDown = useCallback<React.MouseEventHandler>((e) => {
if (e.button !== 0) return
startTimer()
startTimer(e.clientX, e.clientY)
}, [startTimer])
const onMouseUp = useCallback<React.MouseEventHandler>(() => {
@@ -70,8 +72,9 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers
handleEnd(false)
}, [handleEnd])
const onTouchStart = useCallback<React.TouchEventHandler>(() => {
startTimer()
const onTouchStart = useCallback<React.TouchEventHandler>((e) => {
const touch = e.touches[0]
startTimer(touch.clientX, touch.clientY)
}, [startTimer])
const onTouchEnd = useCallback<React.TouchEventHandler>((e) => {
@@ -91,7 +94,7 @@ export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers
e.preventDefault()
clearTimer()
isLongPressRef.current = true
onLongPress()
onLongPress({ x: e.clientX, y: e.clientY })
}
}, [disabled, clearTimer, onLongPress])