* 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
211 lines
5.7 KiB
TypeScript
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>
|
|
)
|
|
}
|