import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Icon } from '@/app/Icon'; import type { MessageAction } from '@/lib/messages/actions'; interface MenuItem { action: MessageAction; label: string; icon: string; danger?: boolean; } const ITEMS: Record = { quote: { action: 'quote', label: '引用', icon: 'format_quote' }, copy: { action: 'copy', label: '复制', icon: 'content_copy' }, favorite: { action: 'favorite', label: '收藏', icon: 'inventory_2' }, forward: { action: 'forward', label: '转发', icon: 'reply' }, recall: { action: 'recall', label: '撤回', icon: 'undo' }, select: { action: 'select', label: '多选', icon: 'select_all' }, delete: { action: 'delete', label: '删除', icon: 'delete_outline', danger: true }, }; interface MessageContextMenuProps { x: number; y: number; actions: MessageAction[]; /** README §3.5: "撤回...右侧灰字剩余时限「1:42 内」" -- only meaningful when `actions` includes 'recall'. */ recallRemainingSeconds?: number | null; onAction: (action: MessageAction) => void; onClose: () => void; } function formatRecallCountdown(seconds: number): string { return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')} 内`; } /** README §3.5: 180px popup, divider before the destructive-leaning tail (recall/delete). */ export function MessageContextMenu({ x, y, actions, recallRemainingSeconds, onAction, onClose }: MessageContextMenuProps) { const ref = useRef(null); const [pos, setPos] = useState({ left: x, top: y }); useEffect(() => { const handler = (e: KeyboardEvent | MouseEvent | Event) => { if (e instanceof KeyboardEvent && e.key !== 'Escape') return; onClose(); }; window.addEventListener('click', handler); window.addEventListener('scroll', handler, true); window.addEventListener('keydown', handler); return () => { window.removeEventListener('click', handler); window.removeEventListener('scroll', handler, true); window.removeEventListener('keydown', handler); }; }, [onClose]); // Right-clicking near the window's right/bottom edge would otherwise render this partially // off-screen -- clamp against the menu's actual measured size once it's in the DOM. useLayoutEffect(() => { const el = ref.current; if (!el) return; const rect = el.getBoundingClientRect(); const left = Math.min(x, window.innerWidth - rect.width - 8); const top = Math.min(y, window.innerHeight - rect.height - 8); setPos({ left: Math.max(8, left), top: Math.max(8, top) }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [x, y]); const leading = actions.filter((a) => a !== 'recall' && a !== 'delete'); const trailing = actions.filter((a) => a === 'recall' || a === 'delete'); return (
e.stopPropagation()} > {leading.map((action) => { const item = ITEMS[action]; return ( ); })} {trailing.length ?
: null} {trailing.map((action) => { const item = ITEMS[action]; return ( ); })}
); }