Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208// src/layout/file-tree/FileTreeContextMenu.tsximport { 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<HTMLDivElement>(null) const itemsRef = useRef<HTMLButtonElement[]>([]) 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 = ( <div ref={menuRef} role="menu" className="file-tree-context-menu" style={style} onKeyDown={handleKeyDown} > {isFolder && ( <> <button ref={setItemRef(itemIndex++)} role="menuitem" className="file-tree-context-menu__item" onClick={handleNewFile} > <FilePlus size={16} className="file-tree-context-menu__icon" /> New File </button> <div className="file-tree-context-menu__divider" /> </> )} <button ref={setItemRef(itemIndex++)} role="menuitem" className="file-tree-context-menu__item" onClick={handleRename} > <Pencil size={16} className="file-tree-context-menu__icon" /> Rename </button> <button ref={setItemRef(itemIndex)} role="menuitem" className="file-tree-context-menu__item file-tree-context-menu__item--danger" onClick={handleDelete} > <Trash2 size={16} className="file-tree-context-menu__icon" /> Delete </button> </div> )
return createPortal(content, document.body)}