// src/layout/file-tree/FileTreeContextMenu.tsx import { useEffect, useRef, useCallback, useLayoutEffect, useState, type KeyboardEvent } from 'react' import { createPortal } from 'react-dom' import { FilePlus, Pencil, Trash2 } from 'lucide-react' import type { AutomergeUrl } from '../../storage/document-runtime-contract' import type { DirectoryEntry } from '../../types/pushwork' import './FileTreeContextMenu.css' /** Margin from viewport edges */ const VIEWPORT_MARGIN = 8 export interface FileTreeContextMenuProps { isOpen: boolean position: { x: number; y: number } entry: DirectoryEntry | null folderUrl: AutomergeUrl | null onClose: () => void onRename: (entry: DirectoryEntry) => void onDelete: (entry: DirectoryEntry) => void onNewFile: (folderUrl: AutomergeUrl) => void } export function FileTreeContextMenu({ isOpen, position, entry, folderUrl, onClose, onRename, onDelete, onNewFile, }: FileTreeContextMenuProps) { const menuRef = useRef(null) const itemsRef = useRef([]) const focusedIndexRef = useRef(0) const [adjustedPosition, setAdjustedPosition] = useState(position) const isFolder = entry?.type === 'folder' const targetFolderUrl = isFolder ? entry.url : folderUrl // Adjust position based on actual menu dimensions after render useLayoutEffect(() => { if (!isOpen || !menuRef.current) { setAdjustedPosition(position) return } const menu = menuRef.current const rect = menu.getBoundingClientRect() const viewportWidth = window.innerWidth const viewportHeight = window.innerHeight let x = position.x let y = position.y // Check right edge if (x + rect.width > viewportWidth - VIEWPORT_MARGIN) { x = viewportWidth - rect.width - VIEWPORT_MARGIN } // Check bottom edge if (y + rect.height > viewportHeight - VIEWPORT_MARGIN) { y = viewportHeight - rect.height - VIEWPORT_MARGIN } // Ensure not too far left/top x = Math.max(VIEWPORT_MARGIN, x) y = Math.max(VIEWPORT_MARGIN, y) setAdjustedPosition({ x, y }) }, [isOpen, position]) // Focus first item when menu opens useEffect(() => { if (isOpen && itemsRef.current.length > 0) { focusedIndexRef.current = 0 itemsRef.current[0]?.focus() } }, [isOpen]) // Handle click outside useEffect(() => { if (!isOpen) return function handleMouseDown(e: MouseEvent) { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { onClose() } } document.addEventListener('mousedown', handleMouseDown) return () => document.removeEventListener('mousedown', handleMouseDown) }, [isOpen, onClose]) // Handle escape key useEffect(() => { if (!isOpen) return function handleKeyDown(e: globalThis.KeyboardEvent) { if (e.key === 'Escape') { onClose() } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [isOpen, onClose]) const handleKeyDown = useCallback((e: KeyboardEvent) => { const items = itemsRef.current.filter(Boolean) const currentIndex = focusedIndexRef.current switch (e.key) { case 'ArrowDown': e.preventDefault() focusedIndexRef.current = (currentIndex + 1) % items.length items[focusedIndexRef.current]?.focus() break case 'ArrowUp': e.preventDefault() focusedIndexRef.current = (currentIndex - 1 + items.length) % items.length items[focusedIndexRef.current]?.focus() break } }, []) const handleRename = useCallback(() => { if (entry) { onRename(entry) onClose() } }, [entry, onRename, onClose]) const handleDelete = useCallback(() => { if (entry) { onDelete(entry) onClose() } }, [entry, onDelete, onClose]) const handleNewFile = useCallback(() => { if (targetFolderUrl) { onNewFile(targetFolderUrl) onClose() } }, [targetFolderUrl, onNewFile, onClose]) if (!isOpen || !entry) return null const style = { left: `${adjustedPosition.x}px`, top: `${adjustedPosition.y}px`, } // Clear refs array at start of render to avoid stale refs // when switching between folder (4 items) and file (2 items) menus itemsRef.current = [] const setItemRef = (index: number) => (el: HTMLButtonElement | null) => { if (el) itemsRef.current[index] = el } let itemIndex = 0 const content = (
{isFolder && ( <>
)}
) return createPortal(content, document.body) }