leistungsbilanz-ts/src/frontend/components/grid-context-menu.tsx
Julian Appel 17a3945813 Add cell copy/paste, multi-row edits and a context menu to the circuit grid
Ctrl+C/Ctrl+V copy and paste grid cells without opening an editor, using
the document copy/paste events so it also works over plain HTTP in the
LAN. Protection and cable summaries copy the complete circuit record;
tab-separated blocks from Excel spread over visible rows and columns.
With a Ctrl/Shift multi-selection, typing, pasting and the protection
dialog apply to every selected row that can take the value, including
device power and GZF; other cells are skipped and reported.

All of these writes use the new circuit-list.bulk-update command. It
carries complete circuit.update, circuit-device-row.update and
circuit-protection.update commands, applies them with the single-command
rules in one transaction and stores the reversed inverses, so each edit
is one undo step. The single-command apply logic is extracted for reuse.

A right-click menu (also context-menu key / Shift+F10) offers copy,
paste, edit, protection, cable sizing, insert circuit below, add device
and delete. The protection summary is now selected by click and opened
by double click, Enter or F2. Existing numbers open in the inline editor
with a decimal comma, so confirming an unchanged decimal no longer fails.

Refs #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 07:38:28 +01:00

154 lines
4.5 KiB
TypeScript

"use client";
import type { LucideIcon } from "lucide-react";
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
export type GridContextMenuEntry =
| {
kind: "action";
label: string;
icon: LucideIcon;
onSelect: () => void;
shortcut?: string;
disabled?: boolean;
title?: string;
variant?: "default" | "danger";
}
| { kind: "separator" };
/**
* Right-click menu of the circuit grid at fixed viewport coordinates. It
* stays inside the viewport, closes on outside click, Escape, scroll or
* resize, and supports arrow-key navigation between enabled actions.
*/
export function GridContextMenu({
x,
y,
entries,
onClose,
ariaLabel,
}: {
x: number;
y: number;
entries: GridContextMenuEntry[];
/** `restoreFocus` is true when the menu was dismissed by keyboard. */
onClose: (restoreFocus: boolean) => void;
ariaLabel: string;
}) {
const menuRef = useRef<HTMLDivElement | null>(null);
// Optional groups may leave leading, trailing or doubled separators.
const visibleEntries = entries.filter(
(entry, index) =>
entry.kind !== "separator" ||
(index > 0 &&
index < entries.length - 1 &&
entries[index - 1].kind !== "separator")
);
const [position, setPosition] = useState({ left: x, top: y });
useLayoutEffect(() => {
const menu = menuRef.current;
if (!menu) {
return;
}
const { width, height } = menu.getBoundingClientRect();
const margin = 8;
setPosition({
left: Math.max(margin, Math.min(x, window.innerWidth - width - margin)),
top: Math.max(margin, Math.min(y, window.innerHeight - height - margin)),
});
menu
.querySelector<HTMLButtonElement>("button:not(:disabled)")
?.focus({ preventScroll: true });
}, [x, y]);
useEffect(() => {
function handlePointerDown(event: PointerEvent) {
if (!menuRef.current?.contains(event.target as Node)) {
onClose(false);
}
}
function handleViewportChange() {
onClose(true);
}
document.addEventListener("pointerdown", handlePointerDown, true);
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
};
}, [onClose]);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape" || event.key === "Tab") {
event.preventDefault();
onClose(true);
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
return;
}
event.preventDefault();
const buttons = [
...(menuRef.current?.querySelectorAll<HTMLButtonElement>(
"button:not(:disabled)"
) ?? []),
];
if (buttons.length === 0) {
return;
}
const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
const next =
event.key === "ArrowDown"
? (index + 1) % buttons.length
: (index - 1 + buttons.length) % buttons.length;
buttons[next].focus();
}
return (
<div
aria-label={ariaLabel}
className="grid-context-menu"
onContextMenu={(event) => event.preventDefault()}
onKeyDown={handleKeyDown}
ref={menuRef}
role="menu"
style={{ left: position.left, top: position.top }}
>
{visibleEntries.map((entry, index) =>
entry.kind === "separator" ? (
<div
aria-hidden="true"
className="grid-context-menu-separator"
key={`separator-${index}`}
/>
) : (
<button
className={
entry.variant === "danger"
? "grid-context-menu-item danger"
: "grid-context-menu-item"
}
disabled={entry.disabled}
key={entry.label}
onClick={() => {
onClose(false);
entry.onSelect();
}}
role="menuitem"
title={entry.title}
type="button"
>
<entry.icon aria-hidden="true" size={15} strokeWidth={2} />
<span className="grid-context-menu-label">{entry.label}</span>
{entry.shortcut ? (
<span className="grid-context-menu-shortcut">{entry.shortcut}</span>
) : null}
</button>
)
)}
</div>
);
}