import React, { useEffect, useState, useRef, useCallback } from 'react'; import { CopyIcon, ScissorsIcon, ClipboardIcon } from './Icons'; import { useToast } from '../context/ToastContext'; import { useTranslation } from '../i18n'; interface MenuPosition { x: number; y: number; } const GlobalContextMenu: React.FC = () => { const [visible, setVisible] = useState(false); const [position, setPosition] = useState({ x: 0, y: 0 }); const targetRef = useRef(null); const menuRef = useRef(null); const { showToast } = useToast(); const { t } = useTranslation(); const handleContextMenu = useCallback((event: MouseEvent) => { // Safety check for target if (!(event.target instanceof HTMLElement)) return; const target = event.target as HTMLElement; // Check if target is input or textarea if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { const inputTarget = target as HTMLInputElement | HTMLTextAreaElement; // Don't show custom menu for non-text inputs (like range, color, checkbox) unless it's search/text/url/etc const type = inputTarget.getAttribute('type'); const validTypes = ['text', 'search', 'url', 'email', 'password', 'tel', 'number', null, '']; if (inputTarget.tagName === 'INPUT' && !validTypes.includes(type)) { return; } // Prevent default browser menu // NOTE: We do NOT call event.stopPropagation() here anymore. // This allows the event to bubble to React handlers (like SearchBox) // which will handle stopping propagation to the App background. event.preventDefault(); targetRef.current = inputTarget; // Calculate position to prevent overflow const menuWidth = 160; const menuHeight = 130; let x = event.clientX; let y = event.clientY; if (x + menuWidth > window.innerWidth) { x = window.innerWidth - menuWidth - 10; } if (y + menuHeight > window.innerHeight) { y = window.innerHeight - menuHeight - 10; } setPosition({ x, y }); setVisible(true); } else { // If clicked elsewhere, hide menu setVisible(false); } }, []); const handleClick = useCallback((event: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { setVisible(false); } }, []); const handleScroll = useCallback(() => { if (visible) setVisible(false); }, [visible]); useEffect(() => { // Use capture phase (true) to catch the event early document.addEventListener('contextmenu', handleContextMenu, true); document.addEventListener('click', handleClick); document.addEventListener('scroll', handleScroll, true); window.addEventListener('resize', handleScroll); return () => { document.removeEventListener('contextmenu', handleContextMenu, true); document.removeEventListener('click', handleClick); document.removeEventListener('scroll', handleScroll, true); window.removeEventListener('resize', handleScroll); }; }, [handleContextMenu, handleClick, handleScroll]); const updateReactState = (element: HTMLInputElement | HTMLTextAreaElement, newValue: string) => { // This helper is crucial for React controlled components // We must trigger a proper 'input' event so React updates its state const nativeInputValueSetter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, "value" )?.set; const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor( window.HTMLTextAreaElement.prototype, "value" )?.set; if (element.tagName === 'INPUT' && nativeInputValueSetter) { nativeInputValueSetter.call(element, newValue); } else if (element.tagName === 'TEXTAREA' && nativeTextAreaValueSetter) { nativeTextAreaValueSetter.call(element, newValue); } else { element.value = newValue; } const event = new Event('input', { bubbles: true }); element.dispatchEvent(event); }; const handleCopy = async () => { if (!targetRef.current) return; const element = targetRef.current; const selection = element.value.substring( element.selectionStart || 0, element.selectionEnd || 0 ); if (selection) { try { await navigator.clipboard.writeText(selection); // showToast(t.copy, 'success', 1000); } catch (err) { console.error('Failed to copy: ', err); showToast(t.copyFailed, 'error'); } } setVisible(false); element.focus(); }; const handleCut = async () => { if (!targetRef.current) return; const element = targetRef.current; const start = element.selectionStart || 0; const end = element.selectionEnd || 0; const selection = element.value.substring(start, end); if (selection) { try { await navigator.clipboard.writeText(selection); const newValue = element.value.slice(0, start) + element.value.slice(end); updateReactState(element, newValue); // Restore cursor position element.setSelectionRange(start, start); // showToast(t.cut, 'success', 1000); } catch (err) { console.error('Failed to cut: ', err); showToast(t.cutFailed, 'error'); } } setVisible(false); element.focus(); }; const handlePaste = async () => { if (!targetRef.current) return; const element = targetRef.current; try { const text = await navigator.clipboard.readText(); if (text) { const start = element.selectionStart || 0; const end = element.selectionEnd || 0; const newValue = element.value.slice(0, start) + text + element.value.slice(end); updateReactState(element, newValue); // Move cursor to end of pasted text const newCursorPos = start + text.length; element.setSelectionRange(newCursorPos, newCursorPos); } } catch (err) { console.error('Failed to paste: ', err); showToast(t.cannotReadClipboard, 'error'); } setVisible(false); element.focus(); }; if (!visible) return null; return (
); }; export default GlobalContextMenu;