mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 22:54:42 +02:00
Implements RadialMenu component (general-purpose, configurable wedges) and useContextMenu hook (right-click on desktop, 450ms long-press with 8px movement threshold on touch). First wired action: "What's here" — reverse-geocodes the trigger location and opens the place panel for the result. Remaining wedges (Drop pin, Directions from here, Directions to here, Add as stop, Save place) render but stub to a toast — wiring deferred to follow-up sessions. Per design doc NAVI-DIRECTIONS-REDESIGN.md sections covering Phases a and b of the implementation sequence.
93 lines
2.6 KiB
JavaScript
93 lines
2.6 KiB
JavaScript
import { useRef, useCallback } from 'react'
|
|
|
|
/**
|
|
* useContextMenu - Combined right-click and long-press trigger
|
|
*
|
|
* Returns event handlers to attach to a container element.
|
|
* Fires onTrigger with { x, y, originalEvent } when:
|
|
* - Right-click (desktop) - immediate
|
|
* - Long-press 450ms with <8px movement (touch) - after delay
|
|
*
|
|
* @param {function} onTrigger - Callback receiving { x, y, originalEvent }
|
|
* @param {object} options
|
|
* @param {number} options.delay - Long-press delay in ms (default 450)
|
|
* @param {number} options.moveThreshold - Max movement in px before abort (default 8)
|
|
*/
|
|
export default function useContextMenu(onTrigger, options = {}) {
|
|
const { delay = 450, moveThreshold = 8 } = options
|
|
|
|
const timerRef = useRef(null)
|
|
const startPosRef = useRef({ x: 0, y: 0 })
|
|
const triggeredRef = useRef(false)
|
|
|
|
// Clear any pending timer
|
|
const clearTimer = useCallback(() => {
|
|
if (timerRef.current) {
|
|
clearTimeout(timerRef.current)
|
|
timerRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
// Right-click handler (desktop)
|
|
const onContextMenu = useCallback((e) => {
|
|
e.preventDefault()
|
|
onTrigger?.({
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
originalEvent: e,
|
|
})
|
|
}, [onTrigger])
|
|
|
|
// Touch start - begin long-press timer
|
|
const onTouchStart = useCallback((e) => {
|
|
if (e.touches.length !== 1) return
|
|
|
|
const touch = e.touches[0]
|
|
startPosRef.current = { x: touch.clientX, y: touch.clientY }
|
|
triggeredRef.current = false
|
|
|
|
clearTimer()
|
|
timerRef.current = setTimeout(() => {
|
|
if (!triggeredRef.current) {
|
|
triggeredRef.current = true
|
|
onTrigger?.({
|
|
x: startPosRef.current.x,
|
|
y: startPosRef.current.y,
|
|
originalEvent: e,
|
|
})
|
|
}
|
|
}, delay)
|
|
}, [onTrigger, delay, clearTimer])
|
|
|
|
// Touch move - abort if moved too far
|
|
const onTouchMove = useCallback((e) => {
|
|
if (!timerRef.current || triggeredRef.current) return
|
|
|
|
const touch = e.touches[0]
|
|
const dx = touch.clientX - startPosRef.current.x
|
|
const dy = touch.clientY - startPosRef.current.y
|
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
|
|
if (dist > moveThreshold) {
|
|
clearTimer()
|
|
}
|
|
}, [moveThreshold, clearTimer])
|
|
|
|
// Touch end - clear timer
|
|
const onTouchEnd = useCallback(() => {
|
|
clearTimer()
|
|
}, [clearTimer])
|
|
|
|
// Touch cancel - clear timer
|
|
const onTouchCancel = useCallback(() => {
|
|
clearTimer()
|
|
}, [clearTimer])
|
|
|
|
return {
|
|
onContextMenu,
|
|
onTouchStart,
|
|
onTouchMove,
|
|
onTouchEnd,
|
|
onTouchCancel,
|
|
}
|
|
}
|