/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; import React from 'react'; import type {PressEvent} from 'react-aria-components'; import { Menu as AriaMenu, MenuItem as AriaMenuItem, Popover as AriaPopover, MenuSection as AriaSection, Separator as AriaSeparator, SubmenuTrigger as AriaSubmenuTrigger, } from 'react-aria-components'; import {createPortal} from 'react-dom'; import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators'; import type {ContextMenu as ContextMenuType} from '~/stores/ContextMenuStore'; import ContextMenuStore from '~/stores/ContextMenuStore'; import LayerManager from '~/stores/LayerManager'; import {isMobileExperienceEnabled} from '~/utils/mobileExperience'; import {isScrollbarDragActive} from '~/utils/ScrollbarDragState'; import styles from './ContextMenu.module.css'; const ContextMenuCloseContext = React.createContext<() => void>(() => {}); export const useContextMenuClose = () => React.useContext(ContextMenuCloseContext); export const ContextMenuCloseProvider = ContextMenuCloseContext.Provider; interface RootContextMenuProps { contextMenu: ContextMenuType; } const RootContextMenuInner: React.FC = observer(({contextMenu}) => { const [isOpen, setIsOpen] = React.useState(true); const [isPositioned, setIsPositioned] = React.useState(false); const [position, setPosition] = React.useState({x: 0, y: 0}); const menuRef = React.useRef(null); const menuContentRef = React.useRef(null); const rafIdRef = React.useRef(null); const focusFirstMenuItem = React.useCallback(() => { const menuElement = menuContentRef.current; if (!menuElement) { return; } const firstInteractable = menuElement.querySelector( [ '[role="menuitem"]:not([aria-disabled="true"])', '[role="menuitemcheckbox"]:not([aria-disabled="true"])', '[role="menuitemradio"]:not([aria-disabled="true"])', ].join(', '), ); (firstInteractable ?? menuElement).focus({preventScroll: true}); }, []); const close = React.useCallback(() => { setIsOpen(false); ContextMenuActionCreators.close(); }, []); React.useLayoutEffect(() => { const {x, y} = contextMenu.target; const align = contextMenu.config?.align ?? 'top-left'; if (rafIdRef.current !== null) { cancelAnimationFrame(rafIdRef.current); } rafIdRef.current = requestAnimationFrame(() => { if (menuRef.current) { const rect = menuRef.current.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const cursorOffset = 4; const edgePadding = 12; let finalX: number; let finalY = y + cursorOffset; if (align === 'top-right') { finalX = x - rect.width; if (finalX < edgePadding) { finalX = x + cursorOffset; if (finalX + rect.width > viewportWidth - edgePadding) { finalX = Math.max(edgePadding, viewportWidth - rect.width - edgePadding); } } } else { finalX = x + cursorOffset; if (finalX + rect.width > viewportWidth - edgePadding) { finalX = x - rect.width - cursorOffset; if (finalX < edgePadding) { finalX = Math.max(edgePadding, viewportWidth - rect.width - edgePadding); } } } if (finalY + rect.height > viewportHeight - edgePadding) { finalY = y - rect.height - cursorOffset; if (finalY < edgePadding) { finalY = Math.max(edgePadding, viewportHeight - rect.height - edgePadding); } } finalX = Math.max(edgePadding, finalX); finalY = Math.max(edgePadding, finalY); setPosition({x: finalX, y: finalY}); } setIsPositioned(true); rafIdRef.current = null; }); return () => { if (rafIdRef.current !== null) { cancelAnimationFrame(rafIdRef.current); rafIdRef.current = null; } }; }, [contextMenu.target, contextMenu.config?.align]); React.useEffect(() => { if (isOpen) { LayerManager.addLayer('contextmenu', contextMenu.id, close); return () => { LayerManager.removeLayer('contextmenu', contextMenu.id); }; } return; }, [isOpen, contextMenu.id, close]); const handleBackdropClick = React.useCallback(() => { if (isScrollbarDragActive()) { return; } close(); }, [close]); React.useLayoutEffect(() => { if (!isOpen || !isPositioned) { return; } focusFirstMenuItem(); }, [isOpen, isPositioned, contextMenu.id, focusFirstMenuItem]); React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { close(); return; } if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { const menuElement = menuContentRef.current; if (!menuElement) return; const menuItems = menuElement.querySelectorAll('[role="menuitem"]'); const pressedKey = e.key.toLowerCase(); for (const item of menuItems) { const shortcutElement = item.querySelector(`.${styles.itemShortcut}`) || item.querySelector('[class*="shortcut"]'); if (shortcutElement) { const shortcutText = shortcutElement.textContent?.toLowerCase().trim(); if (shortcutText === pressedKey) { e.preventDefault(); e.stopPropagation(); item.click(); return; } } } } }; const handleClickOutside = (e: MouseEvent) => { if (isScrollbarDragActive()) { return; } if (menuRef.current && !menuRef.current.contains(e.target as Node)) { close(); } }; document.addEventListener('keydown', handleKeyDown); document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('mousedown', handleClickOutside); }; }, [close]); if (!isOpen) { return null; } const {x, y} = contextMenu.target; return (
); }); export const RootContextMenu: React.FC = observer(({contextMenu}) => { return ; }); interface MenuItemProps { label: string; disabled?: boolean; onClick?: (event: React.MouseEvent) => void; onSelect?: (event: PressEvent) => void; icon?: React.ReactNode; danger?: boolean; color?: string; className?: string; children?: React.ReactNode; closeOnSelect?: boolean; shortcut?: string; } export const MenuItem = React.forwardRef( ({label, disabled, onSelect, icon, danger, className, children, closeOnSelect = true, shortcut}, forwardedRef) => { const closeMenu = React.useContext(ContextMenuCloseContext); const handlePress = React.useCallback( (event: PressEvent) => { if (disabled) return; onSelect?.(event); if (closeOnSelect) { closeMenu(); } }, [closeMenu, closeOnSelect, disabled, onSelect], ); return ( {icon &&
{icon}
}
{children || label}
{shortcut &&
{shortcut}
}
); }, ); MenuItem.displayName = 'MenuItem'; interface SubMenuProps { label: string; icon?: React.ReactNode; disabled?: boolean; hint?: string; children?: React.ReactNode; onTriggerSelect?: () => void; selectionMode?: 'none' | 'single' | 'multiple'; } export const SubMenu = React.forwardRef( ({label, icon, disabled, hint, children, onTriggerSelect, selectionMode}, forwardedRef) => { const handleTriggerPress = React.useCallback(() => { if (disabled) return; onTriggerSelect?.(); }, [disabled, onTriggerSelect]); const handleLabelClick = React.useCallback( (event: React.MouseEvent) => { if (disabled || !onTriggerSelect) return; const target = event.target as HTMLElement; if (target.closest('[data-submenu-caret="true"]')) { return; } event.preventDefault(); event.stopPropagation(); onTriggerSelect(); }, [disabled, onTriggerSelect], ); const handleLabelKeyDown = React.useCallback( (event: React.KeyboardEvent) => { if (disabled || !onTriggerSelect) return; if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onTriggerSelect(); } }, [disabled, onTriggerSelect], ); return ( {icon &&
{icon}
}
{label}
{hint &&
{hint}
}
{children}
); }, ); SubMenu.displayName = 'SubMenu'; export const MenuSeparator: React.FC = observer(() => { return ; }); interface CheckboxItemProps { label: string; checked: boolean; onCheckedChange: (checked: boolean) => void; disabled?: boolean; icon?: React.ReactNode; children?: React.ReactNode; danger?: boolean; closeOnChange?: boolean; } export const CheckboxItem = React.forwardRef( ( {label, checked, onCheckedChange, disabled, icon, children, danger = false, closeOnChange = false}, forwardedRef, ) => { const closeMenu = React.useContext(ContextMenuCloseContext); const handleAction = React.useCallback( (_e: PressEvent) => { if (disabled) return; onCheckedChange(!checked); if (closeOnChange) { closeMenu(); } }, [checked, closeMenu, closeOnChange, disabled, onCheckedChange], ); return ( {icon &&
{icon}
}
{children || label}
); }, ); CheckboxItem.displayName = 'CheckboxItem'; interface MenuGroupProps { label?: string; children?: React.ReactNode; } export const MenuGroup: React.FC = observer(({children}) => { const validChildren = React.Children.toArray(children).filter((child): child is React.ReactElement => { if (!React.isValidElement(child)) return false; if (child.type === React.Fragment && !(child.props as {children?: React.ReactNode}).children) return false; return true; }); if (validChildren.length === 0) { return null; } return {validChildren}; }); export const ContextMenu: React.FC = observer(() => { if (isMobileExperienceEnabled()) { return null; } const contextMenu = ContextMenuStore.contextMenu; if (!contextMenu) return null; return createPortal(, document.body); });