import type { display } from 'objdiff-wasm'; import styles from './ContextMenu.module.css'; import { createContext, useCallback, useContext, useEffect, useRef, useState, } from 'react'; export type ContextMenuCallback = ( e: React.MouseEvent, data: T, ) => void; export type ContextMenuState = Readonly<{ visible: boolean; clickPosition: { x: number; y: number }; position: { x: number; y: number }; target: HTMLElement; data: T; }>; export type ContextMenuRender = ( state: ContextMenuState, close: () => void, ) => React.ReactNode; export type ContextMenuProps = React.PropsWithChildren<{ className?: string; render?: ContextMenuRender; }>; export function createContextMenu(): { ContextMenuProvider: React.FC>; useContextMenu: () => ContextMenuCallback; } { const Context = createContext>((e) => { e.preventDefault(); }); return { ContextMenuProvider: ({ children, className, render, }: ContextMenuProps) => { const elemRef = useRef(null); const [state, setState] = useState | null>(null); const callback = useCallback>((e, data) => { e.preventDefault(); setState({ visible: false, clickPosition: { x: e.clientX, y: e.clientY }, position: { x: e.clientX, y: e.clientY }, target: e.target as HTMLElement, data, }); }, []); const close = useCallback(() => setState(null), []); const closeIfOutside = useCallback( (e: Event) => { if (elemRef.current && !elemRef.current.contains(e.target as Node)) { close(); } }, [close], ); useEffect(() => { const clickOptions = { capture: true }; const scrollOptions = { capture: true, passive: true }; const resizeOptions = { passive: true }; document.addEventListener('click', closeIfOutside, clickOptions); document.addEventListener('mousedown', closeIfOutside, clickOptions); document.addEventListener('scroll', closeIfOutside, scrollOptions); window.addEventListener('resize', close, resizeOptions); return () => { document.removeEventListener('click', closeIfOutside, clickOptions); document.removeEventListener( 'mousedown', closeIfOutside, clickOptions, ); document.removeEventListener('scroll', closeIfOutside, { capture: true, }); window.removeEventListener('resize', close); }; }, [closeIfOutside, close]); // Close context menu if target element is removed useEffect(() => { if (state?.target?.parentNode) { const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { // biome-ignore lint/complexity/noForEach: NodeList mutation.removedNodes.forEach((node) => { if (node === state.target) { close(); } }); } }); observer.observe(state.target.parentNode, { childList: true, }); return () => observer.disconnect(); } }, [state?.target, close]); // biome-ignore lint/correctness/useExhaustiveDependencies: on purpose useEffect(() => { const tooltip = elemRef.current; if (!state?.clickPosition || !tooltip) { return; } const rect = tooltip.getBoundingClientRect(); let x = state.clickPosition.x; let y = state.target.getBoundingClientRect().bottom; if (x < 10) { x = 10; } else if (x + rect.width > window.innerWidth - 10) { x = window.innerWidth - 10 - rect.width; } if (y + rect.height > window.innerHeight - 10) { y = window.innerHeight - 10 - rect.height; } setState((prev) => ({ ...prev!, position: { x, y }, visible: true, })); }, [state?.clickPosition]); let tooltip: React.ReactNode = null; if (state?.position) { const children = render?.(state, close); if (children) { tooltip = (
{children}
); } } return ( <> {children} {tooltip} ); }, useContextMenu: () => useContext(Context), }; } export function renderContextItems( items: display.ContextItem[], close: () => void, ): React.ReactNode { if (items.length === 0) { return null; } return items.map((item, i) => { const key = `${item.tag}-${i}`; switch (item.tag) { case 'copy': return (
{ navigator.clipboard .writeText(item.val.copyString || item.val.value) .then( () => close(), (e) => console.warn('Failed to copy:', e), ); }} > Copy " {item.val.value} "{item.val.label ? ` (${item.val.label})` : ''}
); case 'navigate': return (
{ // TODO }} > {item.val.label}
); case 'separator': return
; default: return null; } }); }