mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
181 lines
5.9 KiB
TypeScript
181 lines
5.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useEffect, useRef } from "react";
|
|
import type { ContextMenuPosition } from "@/components/ui/context-menu";
|
|
|
|
interface ContextMenuState<T> {
|
|
isOpen: boolean;
|
|
position: ContextMenuPosition;
|
|
data: T | null;
|
|
}
|
|
|
|
export interface UseContextMenuReturn<T> {
|
|
contextMenu: ContextMenuState<T>;
|
|
/**
|
|
* Open from a contextmenu or click event. Opened by keyboard (a click
|
|
* from Enter/Space, or Shift+F10 / the Menu key) it anchors to the
|
|
* element the handler sits on instead of the pointer.
|
|
*/
|
|
openContextMenu: (e: React.MouseEvent, data: T) => void;
|
|
/** Open under an element, e.g. a ⋮ button: lined up with its start or end edge. */
|
|
openContextMenuFrom: (element: HTMLElement, data: T, align?: "start" | "end") => void;
|
|
closeContextMenu: () => void;
|
|
menuRef: React.RefObject<HTMLDivElement | null>;
|
|
}
|
|
|
|
const MENU_WIDTH = 200;
|
|
const MENU_HEIGHT = 320; // Approximate max height
|
|
const VIEWPORT_MARGIN = 10;
|
|
|
|
/** Shift+F10 or the Menu key: open the focused element's context menu. */
|
|
export function isContextMenuKey(e: { key: string; shiftKey: boolean }): boolean {
|
|
return e.key === "ContextMenu" || (e.shiftKey && e.key === "F10");
|
|
}
|
|
|
|
/** Whether a mouse event came from the keyboard rather than a pointer. */
|
|
function openedByKeyboard(e: React.MouseEvent): boolean {
|
|
if (e.type === "click") return e.detail === 0;
|
|
// Shift+F10 and the Menu key fire contextmenu at 0,0 (or without a point).
|
|
if (e.type === "contextmenu") return !e.clientX && !e.clientY;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Where the focus goes back to when the menu closes: the control inside the
|
|
* opener that had it (a folder row's button, a message row's checkbox, where
|
|
* Shift+F10 was pressed), else the opener itself.
|
|
*/
|
|
function focusReturnTarget(opener: HTMLElement): HTMLElement {
|
|
const active = typeof document === "undefined" ? null : document.activeElement;
|
|
return active instanceof HTMLElement && opener.contains(active) ? active : opener;
|
|
}
|
|
|
|
/** The position that opens a menu under an element. */
|
|
export function anchorPosition(element: HTMLElement, align: "start" | "end" = "start"): ContextMenuPosition {
|
|
const rect = element.getBoundingClientRect();
|
|
return {
|
|
x: rect.left,
|
|
y: rect.bottom,
|
|
anchor: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, align },
|
|
trigger: element,
|
|
};
|
|
}
|
|
|
|
export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
|
const [contextMenu, setContextMenu] = useState<ContextMenuState<T>>({
|
|
isOpen: false,
|
|
position: { x: 0, y: 0 },
|
|
data: null,
|
|
});
|
|
|
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const calculatePosition = useCallback((clientX: number, clientY: number): ContextMenuPosition => {
|
|
const viewportWidth = window.innerWidth;
|
|
const viewportHeight = window.innerHeight;
|
|
|
|
let x = clientX;
|
|
let y = clientY;
|
|
|
|
// Adjust for right edge
|
|
if (x + MENU_WIDTH > viewportWidth - VIEWPORT_MARGIN) {
|
|
x = viewportWidth - MENU_WIDTH - VIEWPORT_MARGIN;
|
|
}
|
|
|
|
// Adjust for bottom edge
|
|
if (y + MENU_HEIGHT > viewportHeight - VIEWPORT_MARGIN) {
|
|
y = viewportHeight - MENU_HEIGHT - VIEWPORT_MARGIN;
|
|
}
|
|
|
|
// Ensure minimum position
|
|
x = Math.max(VIEWPORT_MARGIN, x);
|
|
y = Math.max(VIEWPORT_MARGIN, y);
|
|
|
|
return { x, y };
|
|
}, []);
|
|
|
|
const openContextMenu = useCallback((e: React.MouseEvent, data: T) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const target = e.currentTarget instanceof HTMLElement ? e.currentTarget : null;
|
|
const trigger = target ? focusReturnTarget(target) : null;
|
|
const position = target && openedByKeyboard(e)
|
|
? { ...anchorPosition(target), trigger }
|
|
: { ...calculatePosition(e.clientX, e.clientY), trigger };
|
|
|
|
setContextMenu({
|
|
isOpen: true,
|
|
position,
|
|
data,
|
|
});
|
|
}, [calculatePosition]);
|
|
|
|
const openContextMenuFrom = useCallback((element: HTMLElement, data: T, align: "start" | "end" = "start") => {
|
|
setContextMenu({ isOpen: true, position: anchorPosition(element, align), data });
|
|
}, []);
|
|
|
|
const closeContextMenu = useCallback(() => {
|
|
setContextMenu((prev) => ({
|
|
...prev,
|
|
isOpen: false,
|
|
}));
|
|
}, []);
|
|
|
|
// Close on escape key, click outside, and scroll. (ContextMenu answers
|
|
// Escape itself and keeps it from the app; this one is a fallback.)
|
|
useEffect(() => {
|
|
if (!contextMenu.isOpen) return;
|
|
|
|
const handleEscape = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
closeContextMenu();
|
|
}
|
|
};
|
|
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
closeContextMenu();
|
|
}
|
|
};
|
|
|
|
const handleScroll = (e: Event) => {
|
|
if (menuRef.current && e.target instanceof Node && menuRef.current.contains(e.target)) {
|
|
return;
|
|
}
|
|
closeContextMenu();
|
|
};
|
|
|
|
const handleBlur = () => {
|
|
// A focused control in the menu may have opened a system dialog that
|
|
// took the focus from the window, such as a color input's (#1366).
|
|
if (menuRef.current?.contains(document.activeElement)) return;
|
|
closeContextMenu();
|
|
};
|
|
|
|
// Add listeners with a slight delay to prevent immediate closing
|
|
const timeoutId = setTimeout(() => {
|
|
document.addEventListener("keydown", handleEscape);
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
document.addEventListener("scroll", handleScroll, true);
|
|
window.addEventListener("blur", handleBlur);
|
|
}, 0);
|
|
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
document.removeEventListener("keydown", handleEscape);
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
document.removeEventListener("scroll", handleScroll, true);
|
|
window.removeEventListener("blur", handleBlur);
|
|
};
|
|
}, [contextMenu.isOpen, closeContextMenu]);
|
|
|
|
return {
|
|
contextMenu,
|
|
openContextMenu,
|
|
openContextMenuFrom,
|
|
closeContextMenu,
|
|
menuRef,
|
|
};
|
|
}
|