mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 22:54:42 +02:00
fix(radial): use backdrop element for click-outside dismiss
Replace unreliable window event listener with transparent full-screen backdrop element. Clicking anywhere outside the radial menu now properly dismisses it. Also handles right-click on backdrop for dismiss. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2e975ea59e
commit
741d760760
1 changed files with 140 additions and 130 deletions
|
|
@ -44,24 +44,6 @@ export default function RadialMenu({
|
||||||
return () => window.removeEventListener('keydown', handleKey)
|
return () => window.removeEventListener('keydown', handleKey)
|
||||||
}, [open, onDismiss])
|
}, [open, onDismiss])
|
||||||
|
|
||||||
// Handle click outside
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
const handleClick = (e) => {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
||||||
onDismiss?.()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Delay to avoid triggering on the same click that opened the menu
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
window.addEventListener('click', handleClick)
|
|
||||||
}, 50)
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer)
|
|
||||||
window.removeEventListener('click', handleClick)
|
|
||||||
}
|
|
||||||
}, [open, onDismiss])
|
|
||||||
|
|
||||||
// Calculate which wedge the pointer is over
|
// Calculate which wedge the pointer is over
|
||||||
const getWedgeAtPoint = useCallback((clientX, clientY) => {
|
const getWedgeAtPoint = useCallback((clientX, clientY) => {
|
||||||
const dx = clientX - x
|
const dx = clientX - x
|
||||||
|
|
@ -109,6 +91,17 @@ export default function RadialMenu({
|
||||||
onDismiss?.()
|
onDismiss?.()
|
||||||
}, [getWedgeAtPoint, lat, lon, onDismiss])
|
}, [getWedgeAtPoint, lat, lon, onDismiss])
|
||||||
|
|
||||||
|
// Handle backdrop click (dismiss menu)
|
||||||
|
const handleBackdropClick = useCallback((e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDismiss?.()
|
||||||
|
}, [onDismiss])
|
||||||
|
|
||||||
|
// Prevent menu container clicks from reaching backdrop
|
||||||
|
const handleContainerClick = useCallback((e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Generate wedge paths
|
// Generate wedge paths
|
||||||
const generateWedgePath = (index) => {
|
const generateWedgePath = (index) => {
|
||||||
const startAngle = (index * wedgeAngle - 90) * (Math.PI / 180)
|
const startAngle = (index * wedgeAngle - 90) * (Math.PI / 180)
|
||||||
|
|
@ -144,126 +137,143 @@ export default function RadialMenu({
|
||||||
const clampedY = Math.max(padding, Math.min(window.innerHeight - padding, y))
|
const clampedY = Math.max(padding, Math.min(window.innerHeight - padding, y))
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<div
|
<>
|
||||||
ref={containerRef}
|
{/* Full-screen transparent backdrop for dismiss */}
|
||||||
className="radial-menu-container"
|
<div
|
||||||
style={{
|
onClick={handleBackdropClick}
|
||||||
position: 'fixed',
|
onContextMenu={handleBackdropClick}
|
||||||
left: clampedX,
|
style={{
|
||||||
top: clampedY,
|
position: 'fixed',
|
||||||
zIndex: 9999,
|
inset: 0,
|
||||||
transform: 'translate(-50%, -50%)',
|
zIndex: 9998,
|
||||||
animation: 'radialFadeIn 100ms ease-out',
|
background: 'transparent',
|
||||||
}}
|
cursor: 'default',
|
||||||
onMouseMove={handlePointerMove}
|
}}
|
||||||
onMouseUp={handlePointerUp}
|
/>
|
||||||
onTouchMove={handlePointerMove}
|
|
||||||
onTouchEnd={handlePointerUp}
|
{/* Radial menu container */}
|
||||||
>
|
<div
|
||||||
<svg
|
ref={containerRef}
|
||||||
width={outerRadius * 2 + 40}
|
className="radial-menu-container"
|
||||||
height={outerRadius * 2 + 40}
|
onClick={handleContainerClick}
|
||||||
viewBox={`${-outerRadius - 20} ${-outerRadius - 20} ${outerRadius * 2 + 40} ${outerRadius * 2 + 40}`}
|
style={{
|
||||||
style={{ overflow: 'visible' }}
|
position: 'fixed',
|
||||||
|
left: clampedX,
|
||||||
|
top: clampedY,
|
||||||
|
zIndex: 9999,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
animation: 'radialFadeIn 100ms ease-out',
|
||||||
|
}}
|
||||||
|
onMouseMove={handlePointerMove}
|
||||||
|
onMouseUp={handlePointerUp}
|
||||||
|
onTouchMove={handlePointerMove}
|
||||||
|
onTouchEnd={handlePointerUp}
|
||||||
>
|
>
|
||||||
{/* Wedges */}
|
<svg
|
||||||
{wedges.map((wedge, i) => {
|
width={outerRadius * 2 + 40}
|
||||||
const iconPos = getIconPosition(i)
|
height={outerRadius * 2 + 40}
|
||||||
const Icon = wedge.icon
|
viewBox={`${-outerRadius - 20} ${-outerRadius - 20} ${outerRadius * 2 + 40} ${outerRadius * 2 + 40}`}
|
||||||
return (
|
style={{ overflow: 'visible' }}
|
||||||
<g key={wedge.id} className="radial-wedge" data-wedge-id={wedge.id}>
|
>
|
||||||
<path
|
{/* Wedges */}
|
||||||
d={generateWedgePath(i)}
|
{wedges.map((wedge, i) => {
|
||||||
fill="rgba(30, 28, 26, 0.85)"
|
const iconPos = getIconPosition(i)
|
||||||
stroke="rgba(180, 160, 140, 0.3)"
|
const Icon = wedge.icon
|
||||||
strokeWidth="1"
|
return (
|
||||||
style={{ transition: 'fill 100ms ease' }}
|
<g key={wedge.id} className="radial-wedge" data-wedge-id={wedge.id}>
|
||||||
className="wedge-path"
|
<path
|
||||||
/>
|
d={generateWedgePath(i)}
|
||||||
<g transform={`translate(${iconPos.x}, ${iconPos.y})`}>
|
fill="rgba(30, 28, 26, 0.85)"
|
||||||
{Icon && (
|
stroke="rgba(180, 160, 140, 0.3)"
|
||||||
<Icon
|
strokeWidth="1"
|
||||||
size={18}
|
style={{ transition: 'fill 100ms ease' }}
|
||||||
stroke="rgba(230, 220, 210, 0.9)"
|
className="wedge-path"
|
||||||
strokeWidth={1.5}
|
/>
|
||||||
style={{ transform: 'translate(-9px, -12px)' }}
|
<g transform={`translate(${iconPos.x}, ${iconPos.y})`}>
|
||||||
/>
|
{Icon && (
|
||||||
)}
|
<Icon
|
||||||
{wedge.requiresAuth && (
|
size={18}
|
||||||
<Lock
|
stroke="rgba(230, 220, 210, 0.9)"
|
||||||
size={10}
|
strokeWidth={1.5}
|
||||||
stroke="rgba(230, 220, 210, 0.6)"
|
style={{ transform: 'translate(-9px, -12px)' }}
|
||||||
strokeWidth={1.5}
|
/>
|
||||||
style={{ transform: 'translate(4px, -14px)' }}
|
)}
|
||||||
/>
|
{wedge.requiresAuth && (
|
||||||
)}
|
<Lock
|
||||||
<text
|
size={10}
|
||||||
y={10}
|
stroke="rgba(230, 220, 210, 0.6)"
|
||||||
textAnchor="middle"
|
strokeWidth={1.5}
|
||||||
fontSize="9"
|
style={{ transform: 'translate(4px, -14px)' }}
|
||||||
fill="rgba(230, 220, 210, 0.8)"
|
/>
|
||||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
)}
|
||||||
>
|
<text
|
||||||
{wedge.label}
|
y={10}
|
||||||
</text>
|
textAnchor="middle"
|
||||||
|
fontSize="9"
|
||||||
|
fill="rgba(230, 220, 210, 0.8)"
|
||||||
|
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||||
|
>
|
||||||
|
{wedge.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
)
|
||||||
)
|
})}
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Center disc */}
|
{/* Center disc */}
|
||||||
<circle
|
<circle
|
||||||
cx={0}
|
cx={0}
|
||||||
cy={0}
|
cy={0}
|
||||||
r={innerRadius - 2}
|
r={innerRadius - 2}
|
||||||
fill="rgba(50, 45, 40, 0.95)"
|
fill="rgba(50, 45, 40, 0.95)"
|
||||||
stroke="rgba(180, 160, 140, 0.4)"
|
stroke="rgba(180, 160, 140, 0.4)"
|
||||||
strokeWidth="1"
|
strokeWidth="1"
|
||||||
/>
|
/>
|
||||||
<text
|
|
||||||
y={-4}
|
|
||||||
textAnchor="middle"
|
|
||||||
fontSize="10"
|
|
||||||
fontFamily="monospace"
|
|
||||||
fill="rgba(230, 220, 210, 0.9)"
|
|
||||||
>
|
|
||||||
{lat?.toFixed(4)}
|
|
||||||
</text>
|
|
||||||
<text
|
|
||||||
y={8}
|
|
||||||
textAnchor="middle"
|
|
||||||
fontSize="10"
|
|
||||||
fontFamily="monospace"
|
|
||||||
fill="rgba(230, 220, 210, 0.9)"
|
|
||||||
>
|
|
||||||
{lon?.toFixed(4)}
|
|
||||||
</text>
|
|
||||||
{centerLabel && (
|
|
||||||
<text
|
<text
|
||||||
y={20}
|
y={-4}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fontSize="9"
|
fontSize="10"
|
||||||
fill="rgba(200, 180, 160, 0.9)"
|
fontFamily="monospace"
|
||||||
style={{ fontStyle: 'italic' }}
|
fill="rgba(230, 220, 210, 0.9)"
|
||||||
>
|
>
|
||||||
{centerLabel.length > 15 ? centerLabel.slice(0, 15) + '…' : centerLabel}
|
{lat?.toFixed(4)}
|
||||||
</text>
|
</text>
|
||||||
)}
|
<text
|
||||||
</svg>
|
y={8}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="10"
|
||||||
|
fontFamily="monospace"
|
||||||
|
fill="rgba(230, 220, 210, 0.9)"
|
||||||
|
>
|
||||||
|
{lon?.toFixed(4)}
|
||||||
|
</text>
|
||||||
|
{centerLabel && (
|
||||||
|
<text
|
||||||
|
y={20}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="9"
|
||||||
|
fill="rgba(200, 180, 160, 0.9)"
|
||||||
|
style={{ fontStyle: 'italic' }}
|
||||||
|
>
|
||||||
|
{centerLabel.length > 15 ? centerLabel.slice(0, 15) + '…' : centerLabel}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.radial-wedge.active .wedge-path {
|
.radial-wedge.active .wedge-path {
|
||||||
fill: rgba(180, 160, 140, 0.4) !important;
|
fill: rgba(180, 160, 140, 0.4) !important;
|
||||||
}
|
}
|
||||||
.radial-wedge:hover .wedge-path {
|
.radial-wedge:hover .wedge-path {
|
||||||
fill: rgba(180, 160, 140, 0.3);
|
fill: rgba(180, 160, 140, 0.3);
|
||||||
}
|
}
|
||||||
@keyframes radialFadeIn {
|
@keyframes radialFadeIn {
|
||||||
from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
|
from { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
|
||||||
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
return createPortal(content, document.body)
|
return createPortal(content, document.body)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue