mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user