146 lines
4.4 KiB
TypeScript
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 ? '' : '+');
|
|
}
|