/** * iOS Safari never fires `contextmenu`, so menus that open on right-click are out of reach on a * phone. Holding a finger on any element marked `data-long-press` dispatches the same * `contextmenu` event a right-click would, and the existing handler opens its menu. Android * Chrome fires its own `contextmenu` on long press; that one is swallowed when ours already ran. */ export const LONG_PRESS_MS = 450; const MOVE_TOLERANCE_PX = 10; type Press = { target: Element; x: number; y: number; timer: ReturnType }; export function installLongPressContextMenu(root: Document = document): () => void { let press: Press | null = null; let firedOn: Element | null = null; const cancel = () => { if (press) clearTimeout(press.timer); press = null; }; const onPointerDown = (event: PointerEvent) => { cancel(); firedOn = null; if (event.pointerType === "mouse" || !event.isPrimary) return; const target = (event.target as Element | null)?.closest?.("[data-long-press]"); if (!target) return; const { clientX: x, clientY: y } = event; const timer = setTimeout(() => { press = null; firedOn = target; target.dispatchEvent( new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: x, clientY: y }), ); }, LONG_PRESS_MS); press = { target, x, y, timer }; }; const onPointerMove = (event: PointerEvent) => { if (!press) return; if (Math.hypot(event.clientX - press.x, event.clientY - press.y) > MOVE_TOLERANCE_PX) cancel(); }; // The finger lifting after the menu opened must not also open the conversation. const onClick = (event: MouseEvent) => { if (!firedOn) return; firedOn = null; event.preventDefault(); event.stopPropagation(); }; const onNativeContextMenu = (event: MouseEvent) => { if (!event.isTrusted) return; if (press) cancel(); else if (firedOn && firedOn.contains(event.target as Node)) { event.preventDefault(); event.stopPropagation(); } }; root.addEventListener("pointerdown", onPointerDown, true); root.addEventListener("pointermove", onPointerMove, true); root.addEventListener("pointerup", cancel, true); root.addEventListener("pointercancel", cancel, true); root.addEventListener("click", onClick, true); root.addEventListener("contextmenu", onNativeContextMenu, true); return () => { cancel(); root.removeEventListener("pointerdown", onPointerDown, true); root.removeEventListener("pointermove", onPointerMove, true); root.removeEventListener("pointerup", cancel, true); root.removeEventListener("pointercancel", cancel, true); root.removeEventListener("click", onClick, true); root.removeEventListener("contextmenu", onNativeContextMenu, true); }; }