Files
modeling-app/src/components/ContextMenu.tsx
Frank Noirot 058fccb5e1 Add a right-click menu to the stream, but only when not dragging (#4745)
* Refactor ContextMenu to be able to take a guard and other event types

* refactor: break out ViewControlMenu into its own component

* Add ViewControlMenu to Stream, but only on right-click non-drag mouseup

* Fix lints

* Don't use `useCallback` for contextmenu guard

* Update context menu position on subsequent right-clicks
2024-12-11 17:57:38 +00:00

211 lines
5.7 KiB
TypeScript

import toast from 'react-hot-toast'
import { ActionIcon, ActionIconProps } from './ActionIcon'
import {
MouseEvent,
RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { Dialog } from '@headlessui/react'
export interface ContextMenuProps
extends Omit<React.HTMLAttributes<HTMLUListElement>, 'children'> {
items?: React.ReactElement[]
menuTargetElement?: RefObject<HTMLElement>
guard?: (e: globalThis.MouseEvent) => boolean
event?: 'contextmenu' | 'mouseup'
}
const DefaultContextMenuItems = [
<ContextMenuItemRefresh />,
<ContextMenuItemCopy />,
// add more default context menu items here
]
export function ContextMenu({
items = DefaultContextMenuItems,
menuTargetElement,
className,
guard,
event = 'contextmenu',
...props
}: ContextMenuProps) {
const dialogRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const [windowSize, setWindowSize] = useState({
width: globalThis?.window?.innerWidth,
height: globalThis?.window?.innerHeight,
})
const [position, setPosition] = useState({ x: 0, y: 0 })
useHotkeys('esc', () => setOpen(false), {
enabled: open,
})
const handleContextMenu = useCallback(
(e: globalThis.MouseEvent) => {
if (guard && !guard(e)) return
e.preventDefault()
setPosition({ x: e.clientX, y: e.clientY })
setOpen(true)
},
[guard, setPosition, setOpen]
)
const dialogPositionStyle = useMemo(() => {
if (!dialogRef.current)
return {
top: 0,
left: 0,
right: 'auto',
bottom: 'auto',
}
return {
top:
position.y + dialogRef.current.clientHeight > windowSize.height
? 'auto'
: position.y,
left:
position.x + dialogRef.current.clientWidth > windowSize.width
? 'auto'
: position.x,
right:
position.x + dialogRef.current.clientWidth > windowSize.width
? windowSize.width - position.x
: 'auto',
bottom:
position.y + dialogRef.current.clientHeight > windowSize.height
? windowSize.height - position.y
: 'auto',
}
}, [position, windowSize, dialogRef.current])
// Listen for window resize to update context menu position
useEffect(() => {
const handleResize = () => {
setWindowSize({
width: globalThis?.window?.innerWidth,
height: globalThis?.window?.innerHeight,
})
}
globalThis?.window?.addEventListener('resize', handleResize)
return () => {
globalThis?.window?.removeEventListener('resize', handleResize)
}
}, [])
// Add context menu listener to target once mounted
useEffect(() => {
menuTargetElement?.current?.addEventListener(event, handleContextMenu)
return () => {
menuTargetElement?.current?.removeEventListener(event, handleContextMenu)
}
}, [menuTargetElement?.current])
return (
<Dialog open={open} onClose={() => setOpen(false)}>
<div
className="fixed inset-0 z-50 w-screen h-screen"
onContextMenu={(e) => {
e.preventDefault()
setPosition({ x: e.clientX, y: e.clientY })
}}
>
<Dialog.Backdrop className="fixed z-10 inset-0" />
<Dialog.Panel
ref={dialogRef}
className={`w-48 fixed bg-chalkboard-10 dark:bg-chalkboard-90
border border-solid border-chalkboard-10 dark:border-chalkboard-90 rounded
shadow-lg backdrop:fixed backdrop:inset-0 backdrop:bg-primary ${className}`}
style={{
...dialogPositionStyle,
...props.style,
}}
>
<ul
{...props}
className="relative flex flex-col gap-0.5 items-stretch content-stretch"
onClick={() => setOpen(false)}
>
{...items}
</ul>
</Dialog.Panel>
</div>
</Dialog>
)
}
export function ContextMenuDivider() {
return <hr className="border-chalkboard-20 dark:border-chalkboard-80" />
}
interface ContextMenuItemProps {
children: React.ReactNode
icon?: ActionIconProps['icon']
onClick?: () => void
hotkey?: string
'data-testid'?: string
}
export function ContextMenuItem(props: ContextMenuItemProps) {
const { children, icon, onClick, hotkey } = props
return (
<button
data-testid={props['data-testid']}
className="flex items-center gap-2 py-1 px-2 cursor-pointer hover:bg-chalkboard-20 dark:hover:bg-chalkboard-80 border-none text-left"
onClick={onClick}
>
{icon && <ActionIcon icon={icon} bgClassName="!bg-transparent" />}
<div className="flex-1">{children}</div>
{hotkey && (
<kbd className="px-1.5 py-0.5 rounded bg-primary/10 text-primary dark:bg-chalkboard-80 dark:text-chalkboard-40">
{hotkey}
</kbd>
)}
</button>
)
}
export function ContextMenuItemRefresh() {
return (
<ContextMenuItem
icon="arrowRotateRight"
onClick={() => globalThis?.window?.location.reload()}
>
Refresh
</ContextMenuItem>
)
}
interface ContextMenuItemCopyProps {
toBeCopiedContent?: string
toBeCopiedLabel?: string
}
export function ContextMenuItemCopy({
toBeCopiedContent = globalThis.window?.getSelection()?.toString(),
toBeCopiedLabel = 'selection',
}: ContextMenuItemCopyProps) {
return (
<ContextMenuItem
icon="clipboardPlus"
onClick={() => {
if (toBeCopiedContent) {
globalThis?.navigator?.clipboard
.writeText(toBeCopiedContent)
.then(() => toast.success(`Copied ${toBeCopiedLabel} to clipboard`))
.catch(() =>
toast.error(`Failed to copy ${toBeCopiedLabel} to clipboard`)
)
}
}}
>
Copy
</ContextMenuItem>
)
}