ui-nyla/src/components/FolderTree/actions/FileActionContextMenu.tsx
2026-04-21 23:49:50 +02:00

146 lines
4.4 KiB
TypeScript

/**
* FileActionContextMenu — Floating Right-Click-Menu für FolderTree.
*
* Wird vom FolderTree gemountet wenn `onContextMenu` auf einer Zeile feuert.
* Schließt sich bei Backdrop-Klick, ESC oder nach Aktion-Dispatch.
*/
import React, { useEffect, useRef } from 'react';
import {
type FileAction,
type FileActionContext,
type FileActionTarget,
resolveActionLabel,
} from './types';
import { runAction } from './registry';
import styles from './FileActionContextMenu.module.css';
interface Props {
/** Sichtbar/positioniert. ``null`` → nicht gemountet. */
anchor: { x: number; y: number } | null;
actions: FileAction[];
target: FileActionTarget;
ctx: FileActionContext;
/** Wird aufgerufen sobald das Menü schließen soll (Backdrop, ESC, nach Action). */
onClose: () => void;
/** Optional: Header-Label (z. B. Dateiname). */
title?: string;
/** Optionaler Confirm-Provider (z. B. browser native ``window.confirm``). */
confirm?: (title: string, body: string) => boolean | Promise<boolean>;
}
export const FileActionContextMenu: React.FC<Props> = ({
anchor,
actions,
target,
ctx,
onClose,
title,
confirm,
}) => {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!anchor) return;
const _onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', _onKey);
return () => window.removeEventListener('keydown', _onKey);
}, [anchor, onClose]);
useEffect(() => {
if (!anchor || !menuRef.current) return;
menuRef.current.focus();
}, [anchor]);
if (!anchor) return null;
const adjusted = _adjustToViewport(anchor, menuRef.current);
const _handleClick = async (action: FileAction) => {
onClose();
await runAction(action, target, ctx, confirm);
};
return (
<>
<div
className={styles.backdrop}
onClick={onClose}
onContextMenu={(e) => {
e.preventDefault();
onClose();
}}
/>
<div
ref={menuRef}
className={styles.menu}
role="menu"
tabIndex={-1}
style={{ left: adjusted.x, top: adjusted.y }}
>
{title && <div className={styles.header}>{title}</div>}
{actions.length === 0 ? (
<div className={styles.empty}></div>
) : (
actions.map((a, idx) => {
const Icon = a.icon;
const isDangerCls = a.danger ? `${styles.item} ${styles.danger}` : styles.item;
return (
<React.Fragment key={a.id}>
{idx > 0 && a.danger && actions[idx - 1] && !actions[idx - 1].danger && (
<div className={styles.divider} />
)}
<button
type="button"
role="menuitem"
className={isDangerCls}
onClick={() => _handleClick(a)}
style={a.iconColor ? { color: a.iconColor } : undefined}
>
<span className={styles.icon}>
<Icon size={13} />
</span>
<span className={styles.label}>{resolveActionLabel(a, target)}</span>
{a.shortcut && <span className={styles.shortcut}>{_formatShortcut(a.shortcut)}</span>}
</button>
</React.Fragment>
);
})
)}
</div>
</>
);
};
function _adjustToViewport(
anchor: { x: number; y: number },
menu: HTMLDivElement | null,
): { x: number; y: number } {
if (!menu) return anchor;
const rect = menu.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const margin = 4;
let x = anchor.x;
let y = anchor.y;
if (x + rect.width + margin > vw) x = Math.max(margin, vw - rect.width - margin);
if (y + rect.height + margin > vh) y = Math.max(margin, vh - rect.height - margin);
return { x, y };
}
function _formatShortcut(s: string): string {
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
return s
.split('+')
.map((part) => {
const k = part.trim().toLowerCase();
if (k === 'mod') return isMac ? '\u2318' : 'Ctrl';
if (k === 'shift') return isMac ? '\u21E7' : 'Shift';
if (k === 'alt') return isMac ? '\u2325' : 'Alt';
if (k === 'ctrl') return 'Ctrl';
return k.length === 1 ? k.toUpperCase() : part;
})
.join(isMac ? '' : '+');
}